diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/INSTALLER b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/METADATA b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..3b61b00ef2e74c40246845fb3e9574550519da0d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/METADATA @@ -0,0 +1,175 @@ +Metadata-Version: 2.4 +Name: mcp +Version: 2.0.0 +Summary: Model Context Protocol SDK +Project-URL: Homepage, https://modelcontextprotocol.io +Project-URL: Documentation, https://py.sdk.modelcontextprotocol.io/ +Project-URL: Repository, https://github.com/modelcontextprotocol/python-sdk +Project-URL: Issues, https://github.com/modelcontextprotocol/python-sdk/issues +Author: Model Context Protocol a Series of LF Projects, LLC. +Maintainer-email: David Soria Parra , Marcelo Trylesinski , Max Isbey , Felix Weinberger +License: MIT +License-File: LICENSE +Keywords: automation,llm,mcp +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Requires-Python: >=3.10 +Requires-Dist: anyio>=4.10; python_version >= '3.14' +Requires-Dist: anyio>=4.9; python_version < '3.14' +Requires-Dist: httpx2>=2.5.0 +Requires-Dist: jsonschema>=4.20.0 +Requires-Dist: mcp-types==2.0.0 +Requires-Dist: opentelemetry-api>=1.28.0 +Requires-Dist: pydantic>=2.12.0 +Requires-Dist: pyjwt[crypto]>=2.10.1 +Requires-Dist: python-multipart>=0.0.9 +Requires-Dist: pywin32>=311; sys_platform == 'win32' +Requires-Dist: sse-starlette>=3.0.0 +Requires-Dist: starlette>=0.27; python_version < '3.14' +Requires-Dist: starlette>=0.48.0; python_version >= '3.14' +Requires-Dist: typing-extensions>=4.13.0 +Requires-Dist: typing-inspection>=0.4.1 +Requires-Dist: uvicorn>=0.31.1; sys_platform != 'emscripten' +Provides-Extra: cli +Requires-Dist: python-dotenv>=1.0.0; extra == 'cli' +Requires-Dist: typer>=0.16.0; extra == 'cli' +Provides-Extra: rich +Requires-Dist: rich>=13.9.4; extra == 'rich' +Description-Content-Type: text/markdown + +# MCP Python SDK + +
+ +Python implementation of the Model Context Protocol (MCP) + +[![PyPI][pypi-badge]][pypi-url] +[![MIT licensed][mit-badge]][mit-url] +[![Python Version][python-badge]][python-url] +[![Documentation][docs-badge]][docs-url] +[![Protocol][protocol-badge]][protocol-url] +[![Specification][spec-badge]][spec-url] + +
+ +> [!NOTE] +> **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. +> +> **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 . 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. +> +> 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). + +## Documentation + +**The documentation lives at .** + +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/). + +## What is MCP? + +The [Model Context Protocol](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but designed for LLM interactions. With this SDK you can: + +- **Build MCP servers** that expose tools, resources, and prompts to any MCP host +- **Build MCP clients** that connect to any MCP server +- Speak every standard transport: stdio, Streamable HTTP, and SSE + +## Requirements + +Python 3.10+. + +## Installation + +```bash +uv add "mcp[cli]" # or: pip install "mcp[cli]" +``` + +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. + +## A server in 15 lines + +Create a `server.py`: + + +```python +from mcp.server import MCPServer + +mcp = MCPServer("Demo") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@mcp.resource("greeting://{name}") +def greeting(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" +``` + +_Full example: [docs_src/index/tutorial001.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs_src/index/tutorial001.py)_ + + +That's a complete MCP server: one tool, one templated resource. Open it in the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): + +```bash +uv run mcp dev server.py +``` + +Call `add` with `a=1`, `b=2` and you get `3` back. + +Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring. + +[Get started](https://py.sdk.modelcontextprotocol.io/get-started/) takes it from here. + +## A client in 10 lines + +The same package is a full MCP **client**. `Client` connects to a URL, a stdio subprocess, a custom transport, or (for tests) straight to a server object in memory with no transport at all: + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + print(result.structured_content) # {'result': 3} + + +asyncio.run(main()) +``` + +Swap `mcp` for `"http://localhost:8000/mcp"` and the exact same code talks to a remote server. + +## Contributing + +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started. + +## License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE) file for details. + +[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg +[pypi-url]: https://pypi.org/project/mcp/ +[mit-badge]: https://img.shields.io/pypi/l/mcp.svg +[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE +[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg +[python-url]: https://www.python.org/downloads/ +[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg +[docs-url]: https://py.sdk.modelcontextprotocol.io/ +[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg +[protocol-url]: https://modelcontextprotocol.io +[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg +[spec-url]: https://modelcontextprotocol.io/specification/latest diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/RECORD b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..2729f448bcd6cb397ecbfdef99bab2fcf739c94d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/RECORD @@ -0,0 +1,253 @@ +../../../bin/mcp,sha256=xBj1o--izDbW5fuime-ORyhoLLs9YnGDWjRI70FA7xA,260 +mcp-2.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +mcp-2.0.0.dist-info/METADATA,sha256=dhx2va06eG7PC3x9nEiw_nvQ484IWrOIpiK95Nvugfk,7708 +mcp-2.0.0.dist-info/RECORD,, +mcp-2.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +mcp-2.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87 +mcp-2.0.0.dist-info/entry_points.txt,sha256=gnGBbo3yGF0YJNXfZpgkAg1-CQtKxnzxrlA53c8viYA,42 +mcp-2.0.0.dist-info/licenses/LICENSE,sha256=XhPbvB0SD8KgPOzefJFCSuLX3hG2PVje0vRDHiYe5Q0,1071 +mcp/__init__.py,sha256=yYm2cScDTFl5ryD6SimPnb0DsTxSKhlUvI1ZrvsIT5o,3719 +mcp/__pycache__/__init__.cpython-313.pyc,, +mcp/cli/__init__.py,sha256=CIKfYFV5bAEtsFivmJkY7NHihqKlfp-kbX3lRm6DSqE,103 +mcp/cli/__pycache__/__init__.cpython-313.pyc,, +mcp/cli/__pycache__/claude.cpython-313.pyc,, +mcp/cli/__pycache__/cli.cpython-313.pyc,, +mcp/cli/claude.py,sha256=iXeCBUQMfgT-XBfwOEACQ6ENgpK64c_VIXNiT7O4sQ4,6059 +mcp/cli/cli.py,sha256=5ynhHfRGUjb_0Fll2oCj4LzCJhmTNU5dco7XaqMrCMQ,15152 +mcp/client/__init__.py,sha256=8WMVUrx02eQuKBXU6tD1eEbGnxRAnqTWhBeGHfBIfDk,1033 +mcp/client/__main__.py,sha256=4siuy244dpMqsVPteG3eA4_EY0-PChS3QDteHXWYi7Y,2413 +mcp/client/__pycache__/__init__.cpython-313.pyc,, +mcp/client/__pycache__/__main__.cpython-313.pyc,, +mcp/client/__pycache__/_input_required.cpython-313.pyc,, +mcp/client/__pycache__/_memory.cpython-313.pyc,, +mcp/client/__pycache__/_probe.cpython-313.pyc,, +mcp/client/__pycache__/_transport.cpython-313.pyc,, +mcp/client/__pycache__/caching.cpython-313.pyc,, +mcp/client/__pycache__/client.cpython-313.pyc,, +mcp/client/__pycache__/context.cpython-313.pyc,, +mcp/client/__pycache__/extension.cpython-313.pyc,, +mcp/client/__pycache__/session.cpython-313.pyc,, +mcp/client/__pycache__/session_group.cpython-313.pyc,, +mcp/client/__pycache__/sse.cpython-313.pyc,, +mcp/client/__pycache__/stdio.cpython-313.pyc,, +mcp/client/__pycache__/streamable_http.cpython-313.pyc,, +mcp/client/__pycache__/subscriptions.cpython-313.pyc,, +mcp/client/_input_required.py,sha256=sVpGcZ0tYuLff0PVWL_EIya4LQ5YOzpc2o7R8nbYgjs,5323 +mcp/client/_memory.py,sha256=nYngTy-aDh97AvTkhMDJohAVVk8SdvakI4Jy-QhlvUM,4938 +mcp/client/_probe.py,sha256=3F8liG_P8Ks-WaQwLVg3iXSuYp8L7td6O9rSU1FiIZI,5392 +mcp/client/_transport.py,sha256=EyPFBefm_GgEu491SKF0Dl1NAj9uKomjXBTGdSTjnUg,695 +mcp/client/auth/__init__.py,sha256=9plDBtxrVeOOXztjjv5JfbsDhwp-YtTQL2kCw7-0h9w,571 +mcp/client/auth/__pycache__/__init__.cpython-313.pyc,, +mcp/client/auth/__pycache__/exceptions.cpython-313.pyc,, +mcp/client/auth/__pycache__/oauth2.cpython-313.pyc,, +mcp/client/auth/__pycache__/utils.cpython-313.pyc,, +mcp/client/auth/exceptions.py,sha256=uUDHX_P0S3R_tt7oAS8ALQrUgOiV2Dgc0QQAdaaBcW8,264 +mcp/client/auth/extensions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +mcp/client/auth/extensions/__pycache__/__init__.cpython-313.pyc,, +mcp/client/auth/extensions/__pycache__/client_credentials.cpython-313.pyc,, +mcp/client/auth/extensions/__pycache__/identity_assertion.cpython-313.pyc,, +mcp/client/auth/extensions/client_credentials.py,sha256=8qDflJFw8My3BU1RDyqh7eQVePv1UKq6SSoqsMa-6Z4,13367 +mcp/client/auth/extensions/identity_assertion.py,sha256=hRGRFe-fmwQ0Wg5TvsIRtlOwAW1Maf9rkZAZG7H1ygk,10785 +mcp/client/auth/oauth2.py,sha256=kS9D9goCO2awXRAqXyMSr_vMtqYmZE8ZWqZznVLIcXw,38993 +mcp/client/auth/utils.py,sha256=sdqg4jkjCveA_ufPp_OjGADursCJJ0J02Etqqe1oK1M,16547 +mcp/client/caching.py,sha256=LI_zvXquqT9gmaH5G1gkPWHjIfdw8qATGgmHoPWc1qg,17358 +mcp/client/client.py,sha256=JVUXkY1Im2RbKKcUxR4gDeQUET5VTCB0lmgl-3fNRUQ,43293 +mcp/client/context.py,sha256=kC4AOFHdcrAF13DudyTSnekcxhOa7rabQkpawXbhIQc,136 +mcp/client/extension.py,sha256=YfU1M7MYrMRSsmJJ7tw6BrNxB96MFG-38FkiWHBadZs,8428 +mcp/client/session.py,sha256=d6bH690acrZQSK2nLn2nRN4PV9jfyzB2sRJyB5U6I5U,67109 +mcp/client/session_group.py,sha256=pY4ONoN3WdXwfcYrnBmuHBOjxddeofQm3wsbjANbn50,17976 +mcp/client/sse.py,sha256=PMjUSwPQk1A7UzQsK1cYWLJMnCoRELl8gBOaQNI811o,7918 +mcp/client/stdio.py,sha256=vJh5JaPacvTKWczWJ6b8a6JYWC1QUVpTJQOr5tG526s,13827 +mcp/client/streamable_http.py,sha256=bMc_jsfScL8wQzqFxbBaY4Sv3QjVQzaMVBh_lIQOBOU,32909 +mcp/client/subscriptions.py,sha256=5C9MUqbY60E8nArgJmOaFgNnlaDEwfx3sECESN5Wr5Y,11563 +mcp/os/__init__.py,sha256=uqaGIfCF4I0IPeSsEEZMNwZuDF91U0WKAIbDcKzPRLo,43 +mcp/os/__pycache__/__init__.cpython-313.pyc,, +mcp/os/posix/__init__.py,sha256=l_2j8jhhgw69s_-eH4dCDk4QYxNlxKGIpaOCvcbv1Qk,40 +mcp/os/posix/__pycache__/__init__.cpython-313.pyc,, +mcp/os/posix/__pycache__/utilities.cpython-313.pyc,, +mcp/os/posix/utilities.py,sha256=yi36QfVndZKMZs-H1aKG8KS7isQL3E5Y_7h95rkMfVc,2392 +mcp/os/win32/__init__.py,sha256=7xyI7KWpZhDS3omGEBcu3200Q-DjIsS7c-a-vrn28Y4,42 +mcp/os/win32/__pycache__/__init__.cpython-313.pyc,, +mcp/os/win32/__pycache__/utilities.cpython-313.pyc,, +mcp/os/win32/utilities.py,sha256=mOh8d2oUzzJuY_jaAHEZFW061kOuf78n6bbm7x3FNL8,10336 +mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +mcp/server/__init__.py,sha256=jVxuDGAX-cxe4kLz2rg8WvzsURel4NeEE9g6fI6eqsY,318 +mcp/server/__main__.py,sha256=RTnPFUeIO3xPAkEfZBUenQ0vnBcY59TPTvgOfbetVMQ,585 +mcp/server/__pycache__/__init__.cpython-313.pyc,, +mcp/server/__pycache__/__main__.cpython-313.pyc,, +mcp/server/__pycache__/_otel.cpython-313.pyc,, +mcp/server/__pycache__/_streamable_http_modern.cpython-313.pyc,, +mcp/server/__pycache__/apps.cpython-313.pyc,, +mcp/server/__pycache__/caching.cpython-313.pyc,, +mcp/server/__pycache__/connection.cpython-313.pyc,, +mcp/server/__pycache__/context.cpython-313.pyc,, +mcp/server/__pycache__/elicitation.cpython-313.pyc,, +mcp/server/__pycache__/extension.cpython-313.pyc,, +mcp/server/__pycache__/models.cpython-313.pyc,, +mcp/server/__pycache__/request_state.cpython-313.pyc,, +mcp/server/__pycache__/runner.cpython-313.pyc,, +mcp/server/__pycache__/session.cpython-313.pyc,, +mcp/server/__pycache__/sse.cpython-313.pyc,, +mcp/server/__pycache__/stdio.cpython-313.pyc,, +mcp/server/__pycache__/streamable_http.cpython-313.pyc,, +mcp/server/__pycache__/streamable_http_manager.cpython-313.pyc,, +mcp/server/__pycache__/subscriptions.cpython-313.pyc,, +mcp/server/__pycache__/transport_security.cpython-313.pyc,, +mcp/server/__pycache__/validation.cpython-313.pyc,, +mcp/server/_otel.py,sha256=k71jSlD1U7uQjx0wWHkDW9W-wqUvddZIce5sNCrJjms,3359 +mcp/server/_streamable_http_modern.py,sha256=HAsp0ZmjYJoZBugVFtCeBTZdYNrYTRbSHWK-XVAQt6g,19411 +mcp/server/apps.py,sha256=srksMEEFBHjtyrZ2S9q7upQ1jhEiuj4fP9hm9gBPOso,9680 +mcp/server/auth/__init__.py,sha256=_JhI3YU6YP6401tYr0L_Fi6rJhUusJTye-xZd0_xCqQ,49 +mcp/server/auth/__pycache__/__init__.cpython-313.pyc,, +mcp/server/auth/__pycache__/errors.cpython-313.pyc,, +mcp/server/auth/__pycache__/json_response.cpython-313.pyc,, +mcp/server/auth/__pycache__/provider.cpython-313.pyc,, +mcp/server/auth/__pycache__/routes.cpython-313.pyc,, +mcp/server/auth/__pycache__/settings.cpython-313.pyc,, +mcp/server/auth/errors.py,sha256=8YjlejJPl-qMjZrbJTDzMPBOAOITn0Da0pC5QWn9QdE,224 +mcp/server/auth/handlers/__init__.py,sha256=KuVuAHZLupFuUBp46a48h3AV9Z6Qp7_IPlxegGiW-xs,56 +mcp/server/auth/handlers/__pycache__/__init__.cpython-313.pyc,, +mcp/server/auth/handlers/__pycache__/authorize.cpython-313.pyc,, +mcp/server/auth/handlers/__pycache__/metadata.cpython-313.pyc,, +mcp/server/auth/handlers/__pycache__/register.cpython-313.pyc,, +mcp/server/auth/handlers/__pycache__/revoke.cpython-313.pyc,, +mcp/server/auth/handlers/__pycache__/token.cpython-313.pyc,, +mcp/server/auth/handlers/authorize.py,sha256=qMcje0xGcZcgFWAV6qTG1w8hEGgaK-l90_igKOrLtvQ,9773 +mcp/server/auth/handlers/metadata.py,sha256=mWPkrs1DfG-Qzq1nZU2HzwxSwk8K_vXe-ZpEDZ0k_QA,853 +mcp/server/auth/handlers/register.py,sha256=f03h4UFVdYGRuUG13-7MpyO4dxbSCeXTa6tRVUE2ObQ,7179 +mcp/server/auth/handlers/revoke.py,sha256=6z9TW1-9oJ08GlRN3prMokzQVyPdLhLaQFC_ndQYCU4,3083 +mcp/server/auth/handlers/token.py,sha256=OCbUZW_joKh7r83gH1qwyxVSoX039jOfxX-VcFpA3Ng,12544 +mcp/server/auth/json_response.py,sha256=EMv3qAH62vRuw8XBHdFU3kYp0S8Jd6DjRGfwc7Jc9XU,377 +mcp/server/auth/middleware/__init__.py,sha256=hzEnzjSR61OBZBrDxL8Om3LLET20teK1HS-pqnqQEVw,40 +mcp/server/auth/middleware/__pycache__/__init__.cpython-313.pyc,, +mcp/server/auth/middleware/__pycache__/auth_context.cpython-313.pyc,, +mcp/server/auth/middleware/__pycache__/bearer_auth.cpython-313.pyc,, +mcp/server/auth/middleware/__pycache__/client_auth.cpython-313.pyc,, +mcp/server/auth/middleware/auth_context.py,sha256=HwdfSIwEKvTfWNn1jtLTQbAYTI5JT3fPbF5MZx_k2X0,1686 +mcp/server/auth/middleware/bearer_auth.py,sha256=fTts5E_9f5C1WYS_RqD1EEnOaDSG9VErGgRmvmmqauA,5172 +mcp/server/auth/middleware/client_auth.py,sha256=z_sJU8Pfn3wnwKNc24zjuvPNr4Z9UlT1U7w7vyh5JLw,5019 +mcp/server/auth/provider.py,sha256=zFxkeqHeU9KhdsauSf1SqkJ96Vjedh2rJwEZEOarWDs,14164 +mcp/server/auth/routes.py,sha256=3Scx7RSkgAN8zVs6VjgDRS494L4VcQyQDF4BbeOGB3Y,10049 +mcp/server/auth/settings.py,sha256=rEIZxhX0Kw69zZm42sxBfDg1vL_Dv1yn9O3ZzfFN7E0,1802 +mcp/server/caching.py,sha256=AYLvl9PXNgPmVnsnUwp8ujbCS7My1xYuXDLTb7LUz3k,3623 +mcp/server/connection.py,sha256=TfQplwP8FEPNb7UwHyQlZWckmEnHhvWAGgqFuUhvLaQ,21924 +mcp/server/context.py,sha256=eWx8i0eed4d7qQb1HdB8y24xV6WAl4kjj7X8f9v-2s4,8546 +mcp/server/elicitation.py,sha256=DTer9VWiXBIygYqIxRr45Nvw3dhK9kzoTA2D_U5Erw4,7015 +mcp/server/extension.py,sha256=xcT7Mqo4CzEUw2EKgJItuaThqxht6Ke4jAznyJ51U5Q,8028 +mcp/server/lowlevel/__init__.py,sha256=zUMGFpya9MVyLjNBoo7lkMGjPUDLcDsEMhbCfouxCTQ,93 +mcp/server/lowlevel/__pycache__/__init__.cpython-313.pyc,, +mcp/server/lowlevel/__pycache__/helper_types.cpython-313.pyc,, +mcp/server/lowlevel/__pycache__/server.cpython-313.pyc,, +mcp/server/lowlevel/helper_types.py,sha256=LVC0N6JMPynVs4fMJSfBmqKIigHNLsRNhBvbU06rg6A,251 +mcp/server/lowlevel/server.py,sha256=U1q8fnWzhYZxKkAw3_tOhiFBSoKsAXYavJXrtRR6Nvk,35998 +mcp/server/mcpserver/__init__.py,sha256=qxvMcxd6rs4-ObyWAqKTiWpANnwTqPaCE7KFnqgDW-s,1340 +mcp/server/mcpserver/__pycache__/__init__.cpython-313.pyc,, +mcp/server/mcpserver/__pycache__/context.cpython-313.pyc,, +mcp/server/mcpserver/__pycache__/exceptions.cpython-313.pyc,, +mcp/server/mcpserver/__pycache__/resolve.cpython-313.pyc,, +mcp/server/mcpserver/__pycache__/server.cpython-313.pyc,, +mcp/server/mcpserver/context.py,sha256=y0sV3lV49GfM2ttxSWzMf0PRlHsmhSDTcTZPznUPfkM,16418 +mcp/server/mcpserver/exceptions.py,sha256=F7v5JqEu3NIaHGHXoDa1l7OxanhlS-s1PdFV-4bMcjc,676 +mcp/server/mcpserver/prompts/__init__.py,sha256=4BsMxoYolpoxg74xkkkzCFL8vvdkLVJ5cIPNs1ND1Jo,99 +mcp/server/mcpserver/prompts/__pycache__/__init__.cpython-313.pyc,, +mcp/server/mcpserver/prompts/__pycache__/base.cpython-313.pyc,, +mcp/server/mcpserver/prompts/__pycache__/manager.cpython-313.pyc,, +mcp/server/mcpserver/prompts/base.py,sha256=DaSnTV6K-8ctaGQpFk5XQwpKWJp3y32ceyfYfhOwUmk,7764 +mcp/server/mcpserver/prompts/manager.py,sha256=j3fAmQCAQdbEL98A7Io1uAqwk4PcdR9rp-_ALh-ADaE,2028 +mcp/server/mcpserver/resolve.py,sha256=5VTfUx7bIc2msbwv1eV4AMq3RbdZV6XVwHy-yL60Lcw,35408 +mcp/server/mcpserver/resources/__init__.py,sha256=FLGQHcOjvM-7ANl0To68SXUueovIGewXpzpUWpYzhKg,618 +mcp/server/mcpserver/resources/__pycache__/__init__.cpython-313.pyc,, +mcp/server/mcpserver/resources/__pycache__/base.cpython-313.pyc,, +mcp/server/mcpserver/resources/__pycache__/resource_manager.cpython-313.pyc,, +mcp/server/mcpserver/resources/__pycache__/templates.cpython-313.pyc,, +mcp/server/mcpserver/resources/__pycache__/types.cpython-313.pyc,, +mcp/server/mcpserver/resources/base.py,sha256=YkEG24pEbbsYYBTwEGX4qXeBRkpQhSkc_o6YXtY1w-0,1660 +mcp/server/mcpserver/resources/resource_manager.py,sha256=MVuw2vciN9NLHlND_evojeD9cCabZxqArkIrTjx-T9U,5124 +mcp/server/mcpserver/resources/templates.py,sha256=gSOVALIQrjnJG2b1JUI36ihcDG2vFjwVijnZJ-23Jb8,10090 +mcp/server/mcpserver/resources/types.py,sha256=J7rrVY6fW8FkFXhdpsl-cKmdF67NOjOvWWKi4p1QcIc,9484 +mcp/server/mcpserver/server.py,sha256=zE_FLRTweCaXfJoqOGcZtzJQcYbAgWAZh91Y387gVKo,55896 +mcp/server/mcpserver/tools/__init__.py,sha256=ZboxhyMJDl87Svjov8YwNYwNZi55P9VhmpTjBZLesnk,96 +mcp/server/mcpserver/tools/__pycache__/__init__.cpython-313.pyc,, +mcp/server/mcpserver/tools/__pycache__/base.cpython-313.pyc,, +mcp/server/mcpserver/tools/__pycache__/tool_manager.cpython-313.pyc,, +mcp/server/mcpserver/tools/base.py,sha256=-j7Ej17e8xOeCUZN4KmegCS_mS4CFEQ_PVqED7XLNmI,7852 +mcp/server/mcpserver/tools/tool_manager.py,sha256=peEBf_SpzOPJOtN51XjlccMYfJJQopV1DLLhhxeQODg,2805 +mcp/server/mcpserver/utilities/__init__.py,sha256=RU33vSfWMx0-WDN5Sowi2M9h4tchQmDizGTL9-kGPd8,33 +mcp/server/mcpserver/utilities/__pycache__/__init__.cpython-313.pyc,, +mcp/server/mcpserver/utilities/__pycache__/context_injection.cpython-313.pyc,, +mcp/server/mcpserver/utilities/__pycache__/func_metadata.cpython-313.pyc,, +mcp/server/mcpserver/utilities/__pycache__/logging.cpython-313.pyc,, +mcp/server/mcpserver/utilities/__pycache__/types.cpython-313.pyc,, +mcp/server/mcpserver/utilities/context_injection.py,sha256=4pDn-HrSdxzQM4LUTa6gAV9xbBLA4KkkxzwRJo8ar60,2053 +mcp/server/mcpserver/utilities/func_metadata.py,sha256=B90oV26YO4VLg9RyjRgu1vkGTOJngcxtcOfjR5xMBuo,25118 +mcp/server/mcpserver/utilities/logging.py,sha256=pwvj0_Y9Eg9am3UtfEVQLUg1Lk07Iwqu_2pDK7Qtoh8,978 +mcp/server/mcpserver/utilities/types.py,sha256=irHdBNRsBkTDnpWmSpB5abkUQu4J-1rAuvDBQlrhLP8,3430 +mcp/server/models.py,sha256=mODTf-8u7fCzVahQh7PZZ2xvccOmZy4ZdRDrFSvB5M8,474 +mcp/server/request_state.py,sha256=DavmX50RO4XhfjjawctsVD2rbg2K3PRckxshLqTexCk,19798 +mcp/server/runner.py,sha256=vlXLQsvKJhwpl7tPVOKsiS-7XDWTVAnvlRrJ9ztSeyk,39549 +mcp/server/session.py,sha256=YTLKaChdDyog0Z8ONGsR-4ZvLPSFCgmw79RN-XRSByY,20478 +mcp/server/sse.py,sha256=pn7pLOyexuf_vAGYAdkK0ddtXBRZm3jMPWUI71clR-w,11815 +mcp/server/stdio.py,sha256=Kj5zruVwVqBd74HLrSEEWEN96XtNoMmZMyWxlja8qew,8137 +mcp/server/streamable_http.py,sha256=hexH1Ncnfns1AvIQaSS7pZaFVZHhss4pW4c9U_kzurs,49655 +mcp/server/streamable_http_manager.py,sha256=LVDs_rCFLUjF3o9cL2OQTGqMp4uHQ35h8F584f0QMdU,21934 +mcp/server/subscriptions.py,sha256=V7MIO8YUwaO7P0XRj2UCJuB2JorLHT9jdsnghYTLXZ0,11043 +mcp/server/transport_security.py,sha256=bEH7iVU_VN3ufFfzYmM2vJvENmuaP6KKFJm3-SiO9Us,4482 +mcp/server/validation.py,sha256=pTtwz1QK15UCZ3PSzFTGhSqiYyfQliLSQwua8kQxMUA,3665 +mcp/shared/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +mcp/shared/__pycache__/__init__.cpython-313.pyc,, +mcp/shared/__pycache__/_callable_inspection.cpython-313.pyc,, +mcp/shared/__pycache__/_compat.cpython-313.pyc,, +mcp/shared/__pycache__/_context_streams.cpython-313.pyc,, +mcp/shared/__pycache__/_httpx_utils.cpython-313.pyc,, +mcp/shared/__pycache__/_otel.cpython-313.pyc,, +mcp/shared/__pycache__/_stream_protocols.cpython-313.pyc,, +mcp/shared/__pycache__/auth.cpython-313.pyc,, +mcp/shared/__pycache__/auth_utils.cpython-313.pyc,, +mcp/shared/__pycache__/context.cpython-313.pyc,, +mcp/shared/__pycache__/direct_dispatcher.cpython-313.pyc,, +mcp/shared/__pycache__/dispatcher.cpython-313.pyc,, +mcp/shared/__pycache__/exceptions.cpython-313.pyc,, +mcp/shared/__pycache__/extension.cpython-313.pyc,, +mcp/shared/__pycache__/inbound.cpython-313.pyc,, +mcp/shared/__pycache__/jsonrpc_dispatcher.cpython-313.pyc,, +mcp/shared/__pycache__/memory.cpython-313.pyc,, +mcp/shared/__pycache__/message.cpython-313.pyc,, +mcp/shared/__pycache__/metadata_utils.cpython-313.pyc,, +mcp/shared/__pycache__/path_security.cpython-313.pyc,, +mcp/shared/__pycache__/peer.cpython-313.pyc,, +mcp/shared/__pycache__/subscriptions.cpython-313.pyc,, +mcp/shared/__pycache__/tool_name_validation.cpython-313.pyc,, +mcp/shared/__pycache__/transport_context.cpython-313.pyc,, +mcp/shared/__pycache__/uri_template.cpython-313.pyc,, +mcp/shared/_callable_inspection.py,sha256=8ODRH9Bls7m_lgPqS0comKAswWRLGHLbSXXO5qaAkpY,867 +mcp/shared/_compat.py,sha256=bzQH1crrW-VNX_qeAokeroLj4F3bqocRXRT9U271QSI,815 +mcp/shared/_context_streams.py,sha256=4OmFYdaxKgtDduD7DnIZXX9ZQcFdo-NqHYgfuVTiejA,3759 +mcp/shared/_httpx_utils.py,sha256=5rfKm1rC4s-qvhOvibqXOMyNYkyZGFlrrvpWm2zhR_Q,2977 +mcp/shared/_otel.py,sha256=1PMkVOc00FvlqxmTgCw_4hDvaqWlZFhtcoGfGU0VOSE,1748 +mcp/shared/_stream_protocols.py,sha256=8FqmXR9NN4rVrevHWnEZL0cHDe8H4ileZiJOWfJGFLc,1470 +mcp/shared/auth.py,sha256=iZr9-k82jonWZyFBQxtqMvg5-eOKfws6bmZUzaJSAOI,12116 +mcp/shared/auth_utils.py,sha256=aX1l1S3pDlE_9r0Fv25I76huJp1wqB25iUROb25xuuc,2881 +mcp/shared/context.py,sha256=r4ybUzYfkt9BzOUpzBwDYPZtJf0fTEOSUxZTSwFA_fQ,3096 +mcp/shared/direct_dispatcher.py,sha256=2FCGiL_t5Y-cK4hwc-CMGuKOqoolZHYK93VxeOiQ334,14610 +mcp/shared/dispatcher.py,sha256=k5woCHfD8oSM_Ovb2X4A3n3QfN5Lj6tw_ePU_4sOT4Y,10892 +mcp/shared/exceptions.py,sha256=P3hVB1g0LspGyMF89KhlnUpfPh4HSwMulA_Afg-LX64,4225 +mcp/shared/extension.py,sha256=8Xyv-GR0ZB16GTX7mnVqZj9VTl6nfFRAt2o1gE2X_8A,1168 +mcp/shared/inbound.py,sha256=s4zcr04IXywKYONspJdLjdZDvVVTIof4oiC-_LXwDEk,25369 +mcp/shared/jsonrpc_dispatcher.py,sha256=gq32ZILKNr3Dzoj7WOmfd4UUlJ1T6rOBgCTvmFF4BS0,38066 +mcp/shared/memory.py,sha256=U-L68iheegxdooC--mxIgEwXolllxVdSSJWWOp19qP4,1479 +mcp/shared/message.py,sha256=UoeMEjzal32I1RmELn0zAj5k7TRluXdHm-JW_iBtHCk,2356 +mcp/shared/metadata_utils.py,sha256=NOwz9Gf_PqVcNSg6RO0juzA0m3onKXnodsXuKTnxvdA,1737 +mcp/shared/path_security.py,sha256=TFAW2v-iWK0BKMsGEeLp9LYwHc70qAlsiIsRc0Q5SNw,6210 +mcp/shared/peer.py,sha256=F6L4dc66gzQKDnb9uIdMSRxfda796sbZKK9Ebqzh-as,9797 +mcp/shared/subscriptions.py,sha256=mMSwdFZUpmpCydHuKgwuHb_8VVpXzn4VKwqtvG5Ye5U,4049 +mcp/shared/tool_name_validation.py,sha256=2MQFDVgED5BMQyS0_L24as1Xf-kJNkbExeHiNnvptg8,4535 +mcp/shared/transport_context.py,sha256=W2Mg4wlLD2b97znWAcXs9yBnl0jwhmXCxeHTxfsCkxg,1867 +mcp/shared/uri_template.py,sha256=b3grvBhqaf4QHT1uodxEOg9aUNyOKOeAy3-F8HYXtEU,43857 +mcp/types/__init__.py,sha256=ovVpPdU3IhzC1uzF4qaVUTRHKjg_MHy3_hBy09AhFnY,1259 +mcp/types/__pycache__/__init__.cpython-313.pyc,, +mcp/types/__pycache__/jsonrpc.cpython-313.pyc,, +mcp/types/__pycache__/methods.cpython-313.pyc,, +mcp/types/__pycache__/version.cpython-313.pyc,, +mcp/types/jsonrpc.py,sha256=oClAyfV6VTSqu7x-xekGdT4CZxdeVUDOJ_noz10uIC0,589 +mcp/types/methods.py,sha256=TiilvvUqJzWhRH-ngzMyFEh1Fii8dJg-f_jIWn_gA1U,572 +mcp/types/version.py,sha256=ysQCr4rfyGeVXqZ-4KaCLbdrIwG1FPaVy6mBXOgbXUw,606 diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/REQUESTED b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/REQUESTED new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/WHEEL b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..7401812e19af977cc5088f3b8fb1ef6bc0441c0a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.31.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/entry_points.txt b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/entry_points.txt new file mode 100644 index 0000000000000000000000000000000000000000..df7969b46fd2faeec8a129666d3a246f8c638871 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +mcp = mcp.cli:app [cli] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/licenses/LICENSE b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..3d48435454b105021b4f777c11b6b07d8d2ffea3 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/licenses/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/client_credentials.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/client_credentials.py new file mode 100644 index 0000000000000000000000000000000000000000..29197bb5048a518bab3a8325d71673a9d31c390a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/client_credentials.py @@ -0,0 +1,333 @@ +"""OAuth client credential extensions for MCP. + +Provides OAuth providers for machine-to-machine authentication flows: +- ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret +- PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt authentication + (typically using a pre-built JWT from workload identity federation) +""" + +import time +from collections.abc import Awaitable, Callable +from typing import Any, Literal +from uuid import uuid4 + +import httpx2 +import jwt +from pydantic import BaseModel, Field + +from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata + + +class ClientCredentialsOAuthProvider(OAuthClientProvider): + """OAuth provider for client_credentials grant with client_id + client_secret. + + This provider sets client_info directly, bypassing dynamic client registration. + Use this when you already have client credentials (client_id and client_secret). + + Example: + ```python + provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + client_secret="my-client-secret", + ) + ``` + """ + + def __init__( + self, + server_url: str, + storage: TokenStorage, + client_id: str, + client_secret: str, + token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic", + scope: str | None = None, + ) -> None: + """Initialize client_credentials OAuth provider. + + Args: + server_url: The MCP server URL. + storage: Token storage implementation. + client_id: The OAuth client ID. + client_secret: The OAuth client secret. + token_endpoint_auth_method: Authentication method for token endpoint. + Either "client_secret_basic" (default) or "client_secret_post". + scope: Optional space-separated list of scopes to request. + """ + # Build minimal client_metadata for the base class + client_metadata = OAuthClientMetadata( + redirect_uris=None, + grant_types=["client_credentials"], + token_endpoint_auth_method=token_endpoint_auth_method, + scope=scope, + ) + super().__init__(server_url, client_metadata, storage, None, None) + # Store client_info to be set during _initialize - no dynamic registration needed + self._fixed_client_info = OAuthClientInformationFull( + redirect_uris=None, + client_id=client_id, + client_secret=client_secret, + grant_types=["client_credentials"], + token_endpoint_auth_method=token_endpoint_auth_method, + scope=scope, + ) + + async def _initialize(self) -> None: + """Load stored tokens and set pre-configured client_info.""" + self.context.current_tokens = await self.context.storage.get_tokens() + self.context.client_info = self._fixed_client_info + self._initialized = True + + async def _perform_authorization(self) -> httpx2.Request: + """Perform client_credentials authorization.""" + return await self._exchange_token_client_credentials() + + async def _exchange_token_client_credentials(self) -> httpx2.Request: + """Build token exchange request for client_credentials grant.""" + token_data: dict[str, Any] = { + "grant_type": "client_credentials", + } + + headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"} + + # Use standard auth methods (client_secret_basic, client_secret_post, none) + token_data, headers = self.context.prepare_token_auth(token_data, headers) + + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() + + if self.context.client_metadata.scope: + token_data["scope"] = self.context.client_metadata.scope + + token_url = self._get_token_endpoint() + return httpx2.Request("POST", token_url, data=token_data, headers=headers) + + +def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]: + """Create an assertion provider that returns a static JWT token. + + Use this when you have a pre-built JWT (e.g., from workload identity federation) + that doesn't need the audience parameter. + + Example: + ```python + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=static_assertion_provider(my_prebuilt_jwt), + ) + ``` + + Args: + token: The pre-built JWT assertion string. + + Returns: + An async callback suitable for use as an assertion_provider. + """ + + async def provider(audience: str) -> str: + return token + + return provider + + +class SignedJWTParameters(BaseModel): + """Parameters for creating SDK-signed JWT assertions. + + Use `create_assertion_provider()` to create an assertion provider callback + for use with `PrivateKeyJWTOAuthProvider`. + + Example: + ```python + jwt_params = SignedJWTParameters( + issuer="my-client-id", + subject="my-client-id", + signing_key=private_key_pem, + ) + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=jwt_params.create_assertion_provider(), + ) + ``` + """ + + issuer: str = Field(description="Issuer for JWT assertions (typically client_id).") + subject: str = Field(description="Subject identifier for JWT assertions (typically client_id).") + signing_key: str = Field(description="Private key for JWT signing (PEM format).") + signing_algorithm: str = Field(default="RS256", description="Algorithm for signing JWT assertions.") + lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.") + additional_claims: dict[str, Any] | None = Field(default=None, description="Additional claims.") + + def create_assertion_provider(self) -> Callable[[str], Awaitable[str]]: + """Create an assertion provider callback for use with PrivateKeyJWTOAuthProvider. + + Returns: + An async callback that takes the audience (authorization server issuer URL) + and returns a signed JWT assertion. + """ + + async def provider(audience: str) -> str: + now = int(time.time()) + claims: dict[str, Any] = { + "iss": self.issuer, + "sub": self.subject, + "aud": audience, + "exp": now + self.lifetime_seconds, + "iat": now, + "jti": str(uuid4()), + } + if self.additional_claims: + claims.update(self.additional_claims) + + return jwt.encode(claims, self.signing_key, algorithm=self.signing_algorithm) + + return provider + + +class PrivateKeyJWTOAuthProvider(OAuthClientProvider): + """OAuth provider for client_credentials grant with private_key_jwt authentication. + + Uses RFC 7523 Section 2.2 for client authentication via JWT assertion. + + The JWT assertion's audience MUST be the authorization server's issuer identifier + (per RFC 7523bis security updates). The `assertion_provider` callback receives + this audience value and must return a JWT with that audience. + + **Option 1: Pre-built JWT via Workload Identity Federation** + + In production scenarios, the JWT assertion is typically obtained from a workload + identity provider (e.g., GCP, AWS IAM, Azure AD): + + ```python + async def get_workload_identity_token(audience: str) -> str: + # Fetch JWT from your identity provider + # The JWT's audience must match the provided audience parameter + return await fetch_token_from_identity_provider(audience=audience) + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=get_workload_identity_token, + ) + ``` + + **Option 2: Static pre-built JWT** + + If you have a static JWT that doesn't need the audience parameter: + + ```python + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=static_assertion_provider(my_prebuilt_jwt), + ) + ``` + + **Option 3: SDK-signed JWT (for testing/simple setups)** + + For testing or simple deployments, use `SignedJWTParameters.create_assertion_provider()`: + + ```python + jwt_params = SignedJWTParameters( + issuer="my-client-id", + subject="my-client-id", + signing_key=private_key_pem, + ) + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=jwt_params.create_assertion_provider(), + ) + ``` + """ + + def __init__( + self, + server_url: str, + storage: TokenStorage, + client_id: str, + assertion_provider: Callable[[str], Awaitable[str]], + scope: str | None = None, + ) -> None: + """Initialize private_key_jwt OAuth provider. + + Args: + server_url: The MCP server URL. + storage: Token storage implementation. + client_id: The OAuth client ID. + assertion_provider: Async callback that takes the audience (authorization + server's issuer identifier) and returns a JWT assertion. Use + `SignedJWTParameters.create_assertion_provider()` for SDK-signed JWTs, + `static_assertion_provider()` for pre-built JWTs, or provide your own + callback for workload identity federation. + scope: Optional space-separated list of scopes to request. + """ + # Build minimal client_metadata for the base class + client_metadata = OAuthClientMetadata( + redirect_uris=None, + grant_types=["client_credentials"], + token_endpoint_auth_method="private_key_jwt", + scope=scope, + ) + super().__init__(server_url, client_metadata, storage, None, None) + self._assertion_provider = assertion_provider + # Store client_info to be set during _initialize - no dynamic registration needed + self._fixed_client_info = OAuthClientInformationFull( + redirect_uris=None, + client_id=client_id, + grant_types=["client_credentials"], + token_endpoint_auth_method="private_key_jwt", + scope=scope, + ) + + async def _initialize(self) -> None: + """Load stored tokens and set pre-configured client_info.""" + self.context.current_tokens = await self.context.storage.get_tokens() + self.context.client_info = self._fixed_client_info + self._initialized = True + + async def _perform_authorization(self) -> httpx2.Request: + """Perform client_credentials authorization with private_key_jwt.""" + return await self._exchange_token_client_credentials() + + async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) -> None: + """Add JWT assertion for client authentication to token endpoint parameters.""" + if not self.context.oauth_metadata: + raise OAuthFlowError("Missing OAuth metadata for private_key_jwt flow") # pragma: no cover + + # Audience MUST be the issuer identifier of the authorization server + # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01 + audience = str(self.context.oauth_metadata.issuer) + assertion = await self._assertion_provider(audience) + + # RFC 7523 Section 2.2: client authentication via JWT + token_data["client_assertion"] = assertion + token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + async def _exchange_token_client_credentials(self) -> httpx2.Request: + """Build token exchange request for client_credentials grant with private_key_jwt.""" + token_data: dict[str, Any] = { + "grant_type": "client_credentials", + } + + headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"} + + # Add JWT client authentication (RFC 7523 Section 2.2) + await self._add_client_authentication_jwt(token_data=token_data) + + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() + + if self.context.client_metadata.scope: + token_data["scope"] = self.context.client_metadata.scope + + token_url = self._get_token_endpoint() + return httpx2.Request("POST", token_url, data=token_data, headers=headers) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/identity_assertion.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/identity_assertion.py new file mode 100644 index 0000000000000000000000000000000000000000..17738a70bb24ea2fa1b52eaadadf965ecb8ce084 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/identity_assertion.py @@ -0,0 +1,213 @@ +"""SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) client provider. + +`IdentityAssertionOAuthProvider` is the client side of SEP-990 leg 2: it presents an Identity +Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise identity provider - +to the MCP authorization server's token endpoint using the RFC 7523 jwt-bearer grant +(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an +MCP access token. + +The authorization server is configuration, not discovery. SEP-990's trust model is the inverse of +the default OAuth client's: the AS issuer is supplied at construction, authorization-server metadata +is fetched from that issuer's own RFC 8414 well-known, and the resource server is never asked which +AS to use - so it cannot redirect the ID-JAG or client secret elsewhere. There is no protected +resource metadata fetch, no dynamic client registration, and no server-driven scope selection. + +Obtaining the ID-JAG (logging into the IdP and the leg-1 token exchange against it) is +deployment-specific and out of scope for the SDK. The caller supplies it through the +`assertion_provider` callback, which receives the configured issuer (the `aud` the ID-JAG must +carry) and the MCP server's resource identifier (the `resource` claim it must carry, per ext-auth +section 4.3), and returns the ID-JAG. +""" + +import base64 +import time +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Literal +from urllib.parse import quote, urlsplit + +import anyio +import httpx2 + +from mcp.client.auth import OAuthFlowError, OAuthTokenError, TokenStorage +from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + create_oauth_metadata_request, + extract_field_from_www_auth, + extract_scope_from_www_auth, + handle_auth_metadata_response, + handle_token_response_scopes, + union_scopes, + validate_metadata_issuer, +) +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken +from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url + +_DEFAULT_PORTS = {"https": 443, "http": 80} + + +def _origin(url: str) -> tuple[str, str, int | None]: + """Return the (scheme, host, port) origin of a URL for same-origin comparison. + + The port is normalized to the scheme's default so an explicit `:443`/`:80` compares equal to the + same origin written without a port. + """ + parsed = urlsplit(url) + port = parsed.port if parsed.port is not None else _DEFAULT_PORTS.get(parsed.scheme) + return (parsed.scheme, parsed.hostname or "", port) + + +class IdentityAssertionOAuthProvider(httpx2.Auth): + """`httpx2.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS. + + The authorization server `issuer` is fixed at construction; metadata is fetched from its + RFC 8414 well-known and the ID-JAG and client secret are sent only to that issuer's token + endpoint. The resource server is never consulted for AS selection. The ID-JAG is fetched lazily + from `assertion_provider` so a fresh assertion is used on each exchange. + + Example: + ```python + async def fetch_id_jag(audience: str, resource: str) -> str: + # `audience` is the configured issuer (the ID-JAG `aud`); `resource` is the MCP + # server's identifier (the ID-JAG `resource` claim). Obtaining the ID-JAG from the + # enterprise IdP is deployment-specific and not handled by the SDK. + return await my_idp.issue_id_jag(audience=audience, resource=resource) + + + provider = IdentityAssertionOAuthProvider( + server_url="https://mcp.example.com/mcp", + storage=my_token_storage, + client_id="my-client-id", + client_secret="my-client-secret", + issuer="https://auth.example.com", + assertion_provider=fetch_id_jag, + ) + ``` + """ + + requires_response_body = True + + def __init__( + self, + server_url: str, + storage: TokenStorage, + client_id: str, + client_secret: str, + issuer: str, + assertion_provider: Callable[[str, str], Awaitable[str]], + scope: str | None = None, + token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_post", + ) -> None: + """Initialize the identity-assertion OAuth provider. + + Args: + server_url: The MCP server URL. + storage: Token storage implementation. + client_id: The OAuth client ID registered with the MCP authorization server. + client_secret: The client secret. SEP-990 section 5.1 requires a confidential client. + issuer: The issuer identifier of the MCP authorization server this client is provisioned + for. Authorization-server metadata is fetched from this issuer's well-known and the + ID-JAG and secret are sent only to its token endpoint. + assertion_provider: Async callback taking `(audience, resource)` - the configured issuer + and the MCP server's resource identifier - and returning the ID-JAG. + scope: Optional space-separated list of scopes to request. + token_endpoint_auth_method: Confidential-client auth method, either `client_secret_post` + (default) or `client_secret_basic`. + """ + if not client_secret: + raise ValueError("client_secret is required: SEP-990 mandates a confidential client") + if not issuer: + raise ValueError("issuer is required: the authorization server is configuration, not discovery") + self._resource = resource_url_from_server_url(server_url) + self._storage = storage + self._issuer = issuer + self._assertion_provider = assertion_provider + self._scope = scope + self._client = OAuthClientInformationFull( + client_id=client_id, + client_secret=client_secret, + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method=token_endpoint_auth_method, + issuer=issuer, + ) + self._token_endpoint: str | None = None + self._tokens: OAuthToken | None = None + self._expiry: float | None = None + self._lock = anyio.Lock() + self._initialized = False + + def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Request: + """Build the RFC 7523 jwt-bearer token request, applying confidential-client auth.""" + assert self._token_endpoint is not None + assert self._client.client_id is not None and self._client.client_secret is not None + data: dict[str, str] = { + "grant_type": JWT_BEARER_GRANT_TYPE, + "assertion": assertion, + "client_id": self._client.client_id, + "resource": self._resource, + } + if scope: + data["scope"] = scope + headers = {"Content-Type": "application/x-www-form-urlencoded"} + if self._client.token_endpoint_auth_method == "client_secret_basic": + # RFC 6749 section 2.3.1: URL-encode each part, then base64 the colon-joined pair. + encoded_id = quote(self._client.client_id, safe="") + encoded_secret = quote(self._client.client_secret, safe="") + credentials = base64.b64encode(f"{encoded_id}:{encoded_secret}".encode()).decode() + headers["Authorization"] = f"Basic {credentials}" + else: + data["client_secret"] = self._client.client_secret + return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers) + + async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + async with self._lock: + if not self._initialized: + self._tokens = await self._storage.get_tokens() + self._expiry = calculate_token_expiry(self._tokens.expires_in) if self._tokens else None + self._initialized = True + + if self._tokens and (self._expiry is None or time.time() <= self._expiry): + request.headers["Authorization"] = f"Bearer {self._tokens.access_token}" + response = yield request + + if response.status_code == 401: + scope_to_request = self._scope + elif response.status_code == 403 and extract_field_from_www_auth(response, "error") == "insufficient_scope": + scope_to_request = union_scopes(self._scope, extract_scope_from_www_auth(response)) + else: + return + + # Discover ASM from the configured issuer's well-known. The RS is not consulted: both + # arguments are the issuer, so even the helper's legacy fallback resolves there. + if self._token_endpoint is None: + for url in build_oauth_authorization_server_metadata_discovery_urls(self._issuer, self._issuer): + asm_response = yield create_oauth_metadata_request(url) + ok, asm = await handle_auth_metadata_response(asm_response) + if not ok: + break + if asm is not None: + validate_metadata_issuer(asm, self._issuer) + token_endpoint = str(asm.token_endpoint) + if _origin(token_endpoint) != _origin(self._issuer): + raise OAuthFlowError( + f"Token endpoint {token_endpoint} is not on the configured issuer origin {self._issuer}" + ) + self._token_endpoint = token_endpoint + break + if self._token_endpoint is None: + raise OAuthFlowError(f"No authorization server metadata at configured issuer {self._issuer}") + + assertion = await self._assertion_provider(self._issuer, self._resource) + token_response = yield self._build_token_request(scope_to_request, assertion) + if token_response.status_code != 200: + body = (await token_response.aread()).decode(errors="replace") + raise OAuthTokenError(f"Token exchange failed ({token_response.status_code}): {body}") + tokens = await handle_token_response_scopes(token_response) + if tokens.scope is None: + tokens.scope = scope_to_request + self._tokens = tokens + self._expiry = calculate_token_expiry(tokens.expires_in) + await self._storage.set_tokens(tokens) + + request.headers["Authorization"] = f"Bearer {tokens.access_token}" + yield request diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/oauth2.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/oauth2.py new file mode 100644 index 0000000000000000000000000000000000000000..7dc62b52b99ff83c6f7a7ec5cc7e89c73438f015 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/oauth2.py @@ -0,0 +1,785 @@ +"""OAuth2 Authentication implementation for httpx2. + +Implements authorization code flow with PKCE and automatic token refresh. +""" + +import base64 +import hashlib +import logging +import secrets +import string +import time +from collections.abc import AsyncGenerator, Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, Protocol, get_args +from urllib.parse import quote, urlencode, urljoin, urlparse + +import anyio +import httpx2 +from mcp_types.version import is_version_at_least +from pydantic import BaseModel, Field, ValidationError + +from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError +from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + build_protected_resource_metadata_discovery_urls, + create_client_info_from_metadata_url, + create_client_registration_request, + create_oauth_metadata_request, + credentials_match_issuer, + extract_field_from_www_auth, + extract_resource_metadata_from_www_auth, + extract_scope_from_www_auth, + get_client_metadata_scopes, + handle_auth_metadata_response, + handle_protected_resource_response, + handle_registration_response, + handle_token_response_scopes, + is_valid_client_metadata_url, + should_use_client_metadata_url, + union_scopes, + validate_authorization_response_iss, + validate_metadata_issuer, +) +from mcp.shared.auth import ( + AuthorizationCodeResult, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, + ProtectedResourceMetadata, + TokenEndpointAuthMethod, +) +from mcp.shared.auth_utils import ( + calculate_token_expiry, + check_resource_allowed, + resource_url_from_server_url, +) +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER + +logger = logging.getLogger(__name__) + +# Methods a registered client's record may carry without a token request being an error, +# derived from the set the SDK is willing to request so the two cannot drift. `None`/"none" +# send no client secret. `private_key_jwt` sends none from here either: only +# `PrivateKeyJWTOAuthProvider` signs the assertion, and only in its client-credentials +# exchange, so its inherited refresh path must pass through here without raising - a refresh +# the server then rejects falls back to a fresh client-credentials exchange, which signs. +# Anything else is a method no client here can apply. +_KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod)) + +# Methods that authenticate the token request with the minted `client_secret`; a +# registration assigning one is only usable if the server issued that secret. +_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = ("client_secret_post", "client_secret_basic") + +# Methods a registration completed by the authorization-code flow can act on. That flow +# authenticates the token request with the minted client secret (or nothing); it holds no key +# to sign a `private_key_jwt` assertion, so a server assigning that method has registered a +# client this flow cannot use. `PrivateKeyJWTOAuthProvider` never registers dynamically. +_REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = tuple( + method for method in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS if method != "private_key_jwt" +) + + +def check_registration_usable(client_info: OAuthClientInformationFull) -> None: + """Confirm a registration this flow completed is one it can act on. + + RFC 7591 ยง3.2.1 lets the authorization server replace requested metadata and leaves it to + the client to "check the values in the response to determine if the registration is + sufficient for use". Two substitutions make the minted credentials unusable, and both are + judged here - before the record is persisted or any interactive authorization begins - + rather than surfacing later as an opaque failure at the token endpoint: a token-endpoint + auth method the authorization-code flow cannot apply (one it does not implement, or + `private_key_jwt`, whose assertion this flow has no key to sign), and a secret-based + method the flow could apply but for which the server issued no `client_secret`. + + Raises: + OAuthRegistrationError: The server registered the client with a + `token_endpoint_auth_method` this flow cannot apply, or with a secret-based + method but no `client_secret`. + """ + method = client_info.token_endpoint_auth_method + if method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: + raise OAuthRegistrationError( + f"Authorization server registered the client with unsupported token_endpoint_auth_method {method!r}" + ) + if method in _SECRET_TOKEN_ENDPOINT_AUTH_METHODS and client_info.client_secret is None: + raise OAuthRegistrationError( + f"Authorization server registered the client for {method!r} but issued no client_secret" + ) + + +class PKCEParameters(BaseModel): + """PKCE (Proof Key for Code Exchange) parameters.""" + + code_verifier: str = Field(..., min_length=43, max_length=128) + code_challenge: str = Field(..., min_length=43, max_length=128) + + @classmethod + def generate(cls) -> "PKCEParameters": + """Generate new PKCE parameters.""" + code_verifier = "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128)) + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") + return cls(code_verifier=code_verifier, code_challenge=code_challenge) + + +class TokenStorage(Protocol): + """Protocol for token storage implementations.""" + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + ... + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + ... + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + ... + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + ... + + +@dataclass +class OAuthContext: + """OAuth flow context.""" + + server_url: str + client_metadata: OAuthClientMetadata + storage: TokenStorage + redirect_handler: Callable[[str], Awaitable[None]] | None + callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None + client_metadata_url: str | None = None + + # Discovered metadata + protected_resource_metadata: ProtectedResourceMetadata | None = None + oauth_metadata: OAuthMetadata | None = None + auth_server_url: str | None = None + protocol_version: str | None = None + + # Client registration + client_info: OAuthClientInformationFull | None = None + + # Token management + current_tokens: OAuthToken | None = None + token_expiry_time: float | None = None + + # State + lock: anyio.Lock = field(default_factory=anyio.Lock) + + def get_authorization_base_url(self, server_url: str) -> str: + """Extract base URL by removing path component.""" + parsed = urlparse(server_url) + return f"{parsed.scheme}://{parsed.netloc}" + + def update_token_expiry(self, token: OAuthToken) -> None: + """Update token expiry time using shared util function.""" + self.token_expiry_time = calculate_token_expiry(token.expires_in) + + def is_token_valid(self) -> bool: + """Check if current token is valid.""" + return bool( + self.current_tokens + and self.current_tokens.access_token + and (not self.token_expiry_time or time.time() <= self.token_expiry_time) + ) + + def can_refresh_token(self) -> bool: + """Check if token can be refreshed.""" + return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info) + + def clear_tokens(self) -> None: + """Clear current tokens.""" + self.current_tokens = None + self.token_expiry_time = None + + def get_resource_url(self) -> str: + """Get resource URL for RFC 8707. + + Uses PRM resource if it's a valid parent, otherwise uses canonical server URL. + """ + resource = resource_url_from_server_url(self.server_url) + + # If PRM provides a resource that's a valid parent, use it + if self.protected_resource_metadata and self.protected_resource_metadata.resource: + prm_resource = str(self.protected_resource_metadata.resource) + if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource): + resource = prm_resource + + return resource + + def should_include_resource_param(self, protocol_version: str | None = None) -> bool: + """Determine if the resource parameter should be included in OAuth requests. + + Returns True if: + - Protected resource metadata is available, OR + - MCP-Protocol-Version header is 2025-06-18 or later + """ + # If we have protected resource metadata, include the resource param + if self.protected_resource_metadata is not None: + return True + + # If no protocol version provided, don't include resource param + if not protocol_version: + return False + + return is_version_at_least(protocol_version, "2025-06-18") + + def prepare_token_auth( + self, data: dict[str, str], headers: dict[str, str] | None = None + ) -> tuple[dict[str, str], dict[str, str]]: + """Prepare authentication for token requests. + + Args: + data: The form data to send + headers: Optional headers dict to update + + Returns: + Tuple of (updated_data, updated_headers) + + Raises: + OAuthTokenError: The client record carries a `token_endpoint_auth_method` this + client does not know. A dynamic registration assigning an unusable method is + rejected earlier, by `check_registration_usable`; this fires for a stored or + pre-registered record that reaches a token request with such a method. + """ + if headers is None: + headers = {} # pragma: no cover + + if not self.client_info: + return data, headers + + auth_method = self.client_info.token_endpoint_auth_method + + if auth_method == "client_secret_basic" and self.client_info.client_secret: + # URL-encode client ID and secret per RFC 6749 Section 2.3.1 + encoded_id = quote(self.client_info.client_id, safe="") + encoded_secret = quote(self.client_info.client_secret, safe="") + credentials = f"{encoded_id}:{encoded_secret}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers["Authorization"] = f"Basic {encoded_credentials}" + # Don't include client_secret in body for basic auth + data = {k: v for k, v in data.items() if k != "client_secret"} + elif auth_method == "client_secret_post" and self.client_info.client_secret: + # Include client_id and client_secret in request body (RFC 6749 ยง2.3.1) + data["client_id"] = self.client_info.client_id + data["client_secret"] = self.client_info.client_secret + elif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: + raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}") + # For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its + # assertion in the provider that implements it, not here. + + return data, headers + + +class OAuthClientProvider(httpx2.Auth): + """OAuth2 authentication for httpx2. + + Handles OAuth flow with automatic client registration and token storage. + """ + + requires_response_body = True + + def __init__( + self, + server_url: str, + client_metadata: OAuthClientMetadata, + storage: TokenStorage, + redirect_handler: Callable[[str], Awaitable[None]] | None = None, + callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None, + client_metadata_url: str | None = None, + validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None, + ): + """Initialize OAuth2 authentication. + + Args: + server_url: The MCP server URL. + client_metadata: OAuth client metadata for registration. + storage: Token storage implementation. + redirect_handler: Handler for authorization redirects. + callback_handler: Handler for authorization callbacks. + client_metadata_url: URL-based client ID. When provided and the server + advertises client_id_metadata_document_supported=True, this URL will be + used as the client_id instead of performing dynamic client registration. + Must be a valid HTTPS URL with a non-root pathname. + validate_resource_url: Optional callback to override resource URL validation. + Called with (server_url, prm_resource) where prm_resource is the resource + from Protected Resource Metadata (or None if not present). If not provided, + default validation rejects mismatched resources per RFC 8707. + + Raises: + ValueError: If client_metadata_url is provided but not a valid HTTPS URL + with a non-root pathname. + """ + # Validate client_metadata_url if provided + if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url): + raise ValueError( + f"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {client_metadata_url}" + ) + + self.context = OAuthContext( + server_url=server_url, + client_metadata=client_metadata, + storage=storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + client_metadata_url=client_metadata_url, + ) + self._validate_resource_url_callback = validate_resource_url + self._initialized = False + + async def _handle_protected_resource_response(self, response: httpx2.Response) -> bool: + """Handle protected resource metadata discovery response. + + Per SEP-985, supports fallback when discovery fails at one URL. + + Returns: + True if metadata was successfully discovered, False if we should try next URL + """ + if response.status_code == 200: + try: + content = await response.aread() + metadata = ProtectedResourceMetadata.model_validate_json(content) + self.context.protected_resource_metadata = metadata + if metadata.authorization_servers: # pragma: no branch + self.context.auth_server_url = str(metadata.authorization_servers[0]) + return True + + except ValidationError: # pragma: no cover + # Invalid metadata - try next URL + logger.warning(f"Invalid protected resource metadata at {response.request.url}") + return False + elif response.status_code == 404: # pragma: no cover + # Not found - try next URL in fallback chain + logger.debug(f"Protected resource metadata not found at {response.request.url}, trying next URL") + return False + else: + # Other error - fail immediately + raise OAuthFlowError( + f"Protected Resource Metadata request failed: {response.status_code}" + ) # pragma: no cover + + async def _perform_authorization(self) -> httpx2.Request: + """Perform the authorization flow.""" + auth_code, code_verifier = await self._perform_authorization_code_grant() + token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) + return token_request + + async def _perform_authorization_code_grant(self) -> tuple[str, str]: + """Perform the authorization redirect and get auth code.""" + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover + if not self.context.redirect_handler: + raise OAuthFlowError("No redirect handler provided for authorization code grant") # pragma: no cover + if not self.context.callback_handler: + raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover + + if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint: + auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) + else: + auth_base_url = self.context.get_authorization_base_url(self.context.server_url) + auth_endpoint = urljoin(auth_base_url, "/authorize") + + if not self.context.client_info: + raise OAuthFlowError("No client info available for authorization") # pragma: no cover + + # Generate PKCE parameters + pkce_params = PKCEParameters.generate() + state = secrets.token_urlsafe(32) + + auth_params = { + "response_type": "code", + "client_id": self.context.client_info.client_id, + "redirect_uri": str(self.context.client_metadata.redirect_uris[0]), + "state": state, + "code_challenge": pkce_params.code_challenge, + "code_challenge_method": "S256", + } + + # Only include resource param if conditions are met + if self.context.should_include_resource_param(self.context.protocol_version): + auth_params["resource"] = self.context.get_resource_url() # RFC 8707 + + if self.context.client_metadata.scope: # pragma: no branch + auth_params["scope"] = self.context.client_metadata.scope + + # OIDC requires prompt=consent when offline_access is requested + # https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + if "offline_access" in self.context.client_metadata.scope.split(): + auth_params["prompt"] = "consent" + + authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}" + await self.context.redirect_handler(authorization_url) + + # Wait for callback + result = await self.context.callback_handler() + + if result.state is None or not secrets.compare_digest(result.state, state): + raise OAuthFlowError(f"State parameter mismatch: {result.state} != {state}") + + # RFC 9207: validate the authorization-response issuer + validate_authorization_response_iss(result.iss, self.context.oauth_metadata) + + if not result.code: + raise OAuthFlowError("No authorization code received") + + # Return auth code and code verifier for token exchange + return result.code, pkce_params.code_verifier + + def _get_token_endpoint(self) -> str: + if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: + token_url = str(self.context.oauth_metadata.token_endpoint) + else: + auth_base_url = self.context.get_authorization_base_url(self.context.server_url) + token_url = urljoin(auth_base_url, "/token") + return token_url + + async def _exchange_token_authorization_code(self, auth_code: str, code_verifier: str) -> httpx2.Request: + """Build token exchange request for authorization_code flow.""" + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover + if not self.context.client_info: + raise OAuthFlowError("Missing client info") # pragma: no cover + + token_url = self._get_token_endpoint() + token_data: dict[str, Any] = { + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": str(self.context.client_metadata.redirect_uris[0]), + "client_id": self.context.client_info.client_id, + "code_verifier": code_verifier, + } + + # Only include resource param if conditions are met + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() # RFC 8707 + + # Prepare authentication based on preferred method + headers = {"Content-Type": "application/x-www-form-urlencoded"} + token_data, headers = self.context.prepare_token_auth(token_data, headers) + + return httpx2.Request("POST", token_url, data=token_data, headers=headers) + + async def _handle_token_response(self, response: httpx2.Response) -> None: + """Handle token exchange response.""" + if response.status_code not in {200, 201}: + body = await response.aread() + body_text = body.decode("utf-8") + raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") + + # Parse and validate response with scope validation + token_response = await handle_token_response_scopes(response) + + # RFC 6749 ยง5.1: an omitted scope means the granted scope equals the requested + # scope. Record it explicitly so the persisted token is self-describing โ€” the + # SEP-2350 step-up union reads it after a restart, when client_metadata.scope + # has reverted to its constructor value. + if token_response.scope is None: + token_response.scope = self.context.client_metadata.scope + + # Store tokens in context + self.context.current_tokens = token_response + self.context.update_token_expiry(token_response) + await self.context.storage.set_tokens(token_response) + + async def _refresh_token(self) -> httpx2.Request: + """Build token refresh request.""" + if not self.context.current_tokens or not self.context.current_tokens.refresh_token: + raise OAuthTokenError("No refresh token available") # pragma: no cover + + if not self.context.client_info or not self.context.client_info.client_id: + raise OAuthTokenError("No client info available") # pragma: no cover + + if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: + token_url = str(self.context.oauth_metadata.token_endpoint) + else: + auth_base_url = self.context.get_authorization_base_url(self.context.server_url) + token_url = urljoin(auth_base_url, "/token") + + refresh_data: dict[str, str] = { + "grant_type": "refresh_token", + "refresh_token": self.context.current_tokens.refresh_token, + "client_id": self.context.client_info.client_id, + } + + # Only include resource param if conditions are met + if self.context.should_include_resource_param(self.context.protocol_version): + refresh_data["resource"] = self.context.get_resource_url() # RFC 8707 + + # Prepare authentication based on preferred method + headers = {"Content-Type": "application/x-www-form-urlencoded"} + refresh_data, headers = self.context.prepare_token_auth(refresh_data, headers) + + return httpx2.Request("POST", token_url, data=refresh_data, headers=headers) + + async def _handle_refresh_response(self, response: httpx2.Response) -> bool: + """Handle token refresh response. Returns True if successful.""" + if response.status_code != 200: + logger.warning(f"Token refresh failed: {response.status_code}") + self.context.clear_tokens() + return False + + try: + content = await response.aread() + token_response = OAuthToken.model_validate_json(content) + + # RFC 6749 ยง6: a refresh response may omit scope (unchanged) and refresh_token + # (the AS does not rotate). Carry both forward so the persisted token stays + # self-describing for the SEP-2350 step-up union and the next expiry can + # still refresh instead of forcing a full re-authorization. + prior = self.context.current_tokens + if token_response.scope is None and prior is not None: + token_response.scope = prior.scope + if token_response.refresh_token is None and prior is not None: + token_response.refresh_token = prior.refresh_token + + self.context.current_tokens = token_response + self.context.update_token_expiry(token_response) + await self.context.storage.set_tokens(token_response) + + return True + except ValidationError: # pragma: no cover + logger.exception("Invalid refresh response") + self.context.clear_tokens() + return False + + async def _initialize(self) -> None: + """Load stored tokens and client info.""" + self.context.current_tokens = await self.context.storage.get_tokens() + self.context.client_info = await self.context.storage.get_client_info() + self._initialized = True + + def _add_auth_header(self, request: httpx2.Request) -> None: + """Add authorization header to request if we have valid tokens.""" + if self.context.current_tokens and self.context.current_tokens.access_token: # pragma: no branch + request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}" + + async def _handle_oauth_metadata_response(self, response: httpx2.Response) -> None: + content = await response.aread() + metadata = OAuthMetadata.model_validate_json(content) + self.context.oauth_metadata = metadata + + async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: + """Validate that PRM resource matches the server URL per RFC 8707.""" + prm_resource = str(prm.resource) if prm.resource else None + + if self._validate_resource_url_callback is not None: + await self._validate_resource_url_callback(self.context.server_url, prm_resource) + return + + if not prm_resource: + return # pragma: no cover + default_resource = resource_url_from_server_url(self.context.server_url) + if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource): + raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}") + + async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + """httpx2 auth flow integration.""" + async with self.context.lock: + if not self._initialized: + await self._initialize() + + # Capture protocol version from request headers + self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER) + + if not self.context.is_token_valid() and self.context.can_refresh_token(): + # Try to refresh token + refresh_request = await self._refresh_token() + refresh_response = yield refresh_request + + if not await self._handle_refresh_response(refresh_response): + # Refresh failed, need full re-authentication + self._initialized = False + + if self.context.is_token_valid(): + self._add_auth_header(request) + + response = yield request + + if response.status_code == 401: + # Perform full OAuth flow + try: + # OAuth flow must be inline due to generator constraints + www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response) + + # Step 1: Discover protected resource metadata (SEP-985 with fallback support) + prm_discovery_urls = build_protected_resource_metadata_discovery_urls( + www_auth_resource_metadata_url, self.context.server_url + ) + + for url in prm_discovery_urls: # pragma: no branch + discovery_request = create_oauth_metadata_request(url) + + discovery_response = yield discovery_request # sending request + + prm = await handle_protected_resource_response(discovery_response) + if prm: + # Validate PRM resource matches server URL (RFC 8707) + await self._validate_resource_match(prm) + self.context.protected_resource_metadata = prm + + # todo: try all authorization_servers to find the OASM + assert ( + len(prm.authorization_servers) > 0 + ) # this is always true as authorization_servers has a min length of 1 + + self.context.auth_server_url = str(prm.authorization_servers[0]) + break + else: + logger.debug(f"Protected resource metadata discovery failed: {url}") + + # SEP-2352: stored credentials are bound to the issuer that registered them. + # If the authorization server changed, drop them (and the old tokens) so the + # flow re-registers instead of presenting another server's credentials. + if ( + self.context.client_info is not None + and self.context.auth_server_url is not None + and not credentials_match_issuer( + self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url + ) + ): + logger.debug("Authorization server changed; discarding bound credentials and re-registering") + self.context.client_info = None + self.context.clear_tokens() + # Any cached AS metadata is for the old server; drop it so a failed + # rediscovery cannot leak the old registration/token endpoints into Step 4. + self.context.oauth_metadata = None + + asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + self.context.auth_server_url, self.context.server_url + ) + + # Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers) + for url in asm_discovery_urls: # pragma: no branch + oauth_metadata_request = create_oauth_metadata_request(url) + oauth_metadata_response = yield oauth_metadata_request + + ok, asm = await handle_auth_metadata_response(oauth_metadata_response) + if not ok: + break + if ok and asm: + # SEP-2468: metadata issuer must match the discovery issuer + if self.context.auth_server_url is not None: + validate_metadata_issuer(asm, self.context.auth_server_url) + self.context.oauth_metadata = asm + break + else: + logger.debug(f"OAuth metadata discovery failed: {url}") + + # SEP-2352: on the legacy no-PRM path the issuer is only known after ASM + # discovery, so re-evaluate the binding here using the discovered metadata + # issuer (mirroring the bound_issuer fallback in Step 4). + if ( + self.context.client_info is not None + and self.context.auth_server_url is None + and self.context.oauth_metadata is not None + and not credentials_match_issuer( + self.context.client_info, + str(self.context.oauth_metadata.issuer), + self.context.client_metadata_url, + ) + ): + logger.debug("Authorization server changed; discarding bound credentials and re-registering") + self.context.client_info = None + self.context.clear_tokens() + + # Step 3: Apply scope selection strategy + self.context.client_metadata.scope = get_client_metadata_scopes( + extract_scope_from_www_auth(response), + self.context.protected_resource_metadata, + self.context.oauth_metadata, + self.context.client_metadata.grant_types, + ) + + # Step 4: Register client or use URL-based client ID (CIMD) + if not self.context.client_info: + # SEP-2352: the issuer to bind these credentials to, when known. + discovered_issuer: str | None = None + if self.context.oauth_metadata is not None: + discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer) + + if should_use_client_metadata_url( + self.context.oauth_metadata, self.context.client_metadata_url + ): + # Use URL-based client ID (CIMD). CIMD records are portable across + # authorization servers, so the issuer stamp is informational. + logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}") + client_information = create_client_info_from_metadata_url( + self.context.client_metadata_url, # type: ignore[arg-type] + redirect_uris=self.context.client_metadata.redirect_uris, + ) + client_information.issuer = discovered_issuer + self.context.client_info = client_information + await self.context.storage.set_client_info(client_information) + else: + # Fallback to Dynamic Client Registration + fallback_base = self.context.get_authorization_base_url(self.context.server_url) + registration_request = create_client_registration_request( + self.context.oauth_metadata, self.context.client_metadata, fallback_base + ) + registration_response = yield registration_request + client_information = await handle_registration_response(registration_response) + check_registration_usable(client_information) + # Only record the issuer when the registration above actually targeted + # the discovered AS โ€” either via its published registration_endpoint, + # or because the resource-origin /register fallback is on the issuer's + # own host (legacy same-origin embedded AS). Otherwise the fallback hit + # a different server and recording a binding to the PRM-advertised AS + # would persist a binding that was never established. + if ( + self.context.oauth_metadata is not None + and discovered_issuer is not None + and ( + self.context.oauth_metadata.registration_endpoint is not None + or self.context.get_authorization_base_url(discovered_issuer) == fallback_base + ) + ): + client_information.issuer = discovered_issuer + self.context.client_info = client_information + await self.context.storage.set_client_info(client_information) + + # Step 5: Perform authorization and complete token exchange + token_response = yield await self._perform_authorization() + await self._handle_token_response(token_response) + except Exception: + logger.exception("OAuth flow error") + raise + + # Retry with new tokens + self._add_auth_header(request) + yield request + elif response.status_code == 403: + # Step 1: Extract error field from WWW-Authenticate header + error = extract_field_from_www_auth(response, "error") + + # Step 2: Check if we need to step-up authorization + if error == "insufficient_scope": # pragma: no branch + try: + # Step 2a: Union previously requested scopes with the newly challenged + # scopes (SEP-2350) so escalating one operation keeps the others' grants. + # Fold in the stored token's scope too: on a restart the token is reloaded + # but client_metadata.scope is not, so it would otherwise be the only basis. + challenged_scope = get_client_metadata_scopes( + extract_scope_from_www_auth(response), + self.context.protected_resource_metadata, + self.context.oauth_metadata, + self.context.client_metadata.grant_types, + ) + granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None + prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope) + self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope) + + # Step 2b: Perform (re-)authorization and token exchange + token_response = yield await self._perform_authorization() + await self._handle_token_response(token_response) + except Exception: # pragma: no cover + logger.exception("OAuth flow error") + raise + + # Retry with new tokens + self._add_auth_header(request) + yield request diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/utils.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..31e2e5cade17345b87641c29e0efc3eecdb2691d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/utils.py @@ -0,0 +1,427 @@ +import re +from typing import Any, cast +from urllib.parse import urljoin, urlparse + +from httpx2 import Request, Response +from mcp_types import LATEST_PROTOCOL_VERSION +from pydantic import AnyUrl, ValidationError +from pydantic_core import from_json + +from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, + ProtectedResourceMetadata, +) +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER + + +def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: + """Extract field from WWW-Authenticate header. + + Returns: + Field value if found in WWW-Authenticate header, None otherwise + """ + www_auth_header = response.headers.get("WWW-Authenticate") + if not www_auth_header: + return None + + # Pattern matches: field_name="value" or field_name=value (unquoted) + pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))' + match = re.search(pattern, www_auth_header) + + if match: + # Return quoted value if present, otherwise unquoted value + return match.group(1) or match.group(2) + + return None + + +def extract_scope_from_www_auth(response: Response) -> str | None: + """Extract scope parameter from WWW-Authenticate header as per RFC 6750. + + Returns: + Scope string if found in WWW-Authenticate header, None otherwise + """ + return extract_field_from_www_auth(response, "scope") + + +def extract_resource_metadata_from_www_auth(response: Response) -> str | None: + """Extract protected resource metadata URL from WWW-Authenticate header as per RFC 9728. + + Returns: + Resource metadata URL if found in WWW-Authenticate header, None otherwise + """ + if not response or response.status_code != 401: + return None # pragma: no cover + + return extract_field_from_www_auth(response, "resource_metadata") + + +def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: + """Build ordered list of URLs to try for protected resource metadata discovery. + + Per SEP-985, the client MUST: + 1. Try resource_metadata from WWW-Authenticate header (if present) + 2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path} + 3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource + + Args: + www_auth_url: Optional resource_metadata URL extracted from the WWW-Authenticate header + server_url: Server URL + + Returns: + Ordered list of URLs to try for discovery + """ + urls: list[str] = [] + + # Priority 1: WWW-Authenticate header with resource_metadata parameter + if www_auth_url: + urls.append(www_auth_url) + + # Priority 2-3: Well-known URIs (RFC 9728) + parsed = urlparse(server_url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + + # Priority 2: Path-based well-known URI (if server has a path component) + if parsed.path and parsed.path != "/": + path_based_url = urljoin(base_url, f"/.well-known/oauth-protected-resource{parsed.path}") + urls.append(path_based_url) + + # Priority 3: Root-based well-known URI + root_based_url = urljoin(base_url, "/.well-known/oauth-protected-resource") + urls.append(root_based_url) + + return urls + + +def get_client_metadata_scopes( + www_authenticate_scope: str | None, + protected_resource_metadata: ProtectedResourceMetadata | None, + authorization_server_metadata: OAuthMetadata | None = None, + client_grant_types: list[str] | None = None, +) -> str | None: + """Select effective scopes and augment for refresh token support.""" + selected_scope: str | None = None + + # MCP spec scope selection priority: + # 1. WWW-Authenticate header scope + # 2. PRM scopes_supported + # 3. AS scopes_supported (SDK fallback) + # 4. Omit scope parameter + if www_authenticate_scope is not None: + selected_scope = www_authenticate_scope + elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None: + selected_scope = " ".join(protected_resource_metadata.scopes_supported) + elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None: + selected_scope = " ".join(authorization_server_metadata.scopes_supported) + + # SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens + if ( + selected_scope is not None + and authorization_server_metadata is not None + and authorization_server_metadata.scopes_supported is not None + and "offline_access" in authorization_server_metadata.scopes_supported + and client_grant_types is not None + and "refresh_token" in client_grant_types + and "offline_access" not in selected_scope.split() + ): + selected_scope = f"{selected_scope} offline_access" + + return selected_scope + + +def union_scopes(previous_scope: str | None, new_scope: str | None) -> str | None: + """Merge two space-delimited scope strings, preserving order and dropping duplicates. + + SEP-2350: on step-up re-authorization the client requests the union of previously requested + scopes and the newly challenged scopes, so escalating one operation does not drop the + permissions granted for another. Previously requested scopes come first; new scopes are + appended in order. + """ + if not previous_scope: + return new_scope + if not new_scope: + return previous_scope + + merged = previous_scope.split() + seen = set(merged) + for scope in new_scope.split(): + if scope not in seen: + merged.append(scope) + seen.add(scope) + return " ".join(merged) + + +def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]: + """Generate an ordered list of URLs for authorization server metadata discovery. + + Args: + auth_server_url: OAuth Authorization Server Metadata URL if found, otherwise None + server_url: URL for the MCP server, used as a fallback if auth_server_url is None + """ + + if not auth_server_url: + # Legacy path using the 2025-03-26 spec: + # link: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization + parsed = urlparse(server_url) + return [f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-authorization-server"] + + urls: list[str] = [] + parsed = urlparse(auth_server_url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + + # RFC 8414: Path-aware OAuth discovery + if parsed.path and parsed.path != "/": + oauth_path = f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}" + urls.append(urljoin(base_url, oauth_path)) + + # RFC 8414 section 5: Path-aware OIDC discovery + # See https://www.rfc-editor.org/rfc/rfc8414.html#section-5 + oidc_path = f"/.well-known/openid-configuration{parsed.path.rstrip('/')}" + urls.append(urljoin(base_url, oidc_path)) + + # https://openid.net/specs/openid-connect-discovery-1_0.html + oidc_path = f"{parsed.path.rstrip('/')}/.well-known/openid-configuration" + urls.append(urljoin(base_url, oidc_path)) + return urls + + # OAuth root + urls.append(urljoin(base_url, "/.well-known/oauth-authorization-server")) + + # OIDC 1.0 fallback (appends to full URL per OIDC spec) + # https://openid.net/specs/openid-connect-discovery-1_0.html + urls.append(urljoin(base_url, "/.well-known/openid-configuration")) + + return urls + + +async def handle_protected_resource_response( + response: Response, +) -> ProtectedResourceMetadata | None: + """Handle protected resource metadata discovery response. + + Per SEP-985, supports fallback when discovery fails at one URL. + + Returns: + ProtectedResourceMetadata if successfully discovered, None if we should try next URL + """ + if response.status_code == 200: + try: + content = await response.aread() + metadata = ProtectedResourceMetadata.model_validate_json(content) + return metadata + + except ValidationError: # pragma: no cover + # Invalid metadata - try next URL + return None + else: + # Not found - try next URL in fallback chain + return None + + +async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuthMetadata | None]: + if response.status_code == 200: + try: + content = await response.aread() + asm = OAuthMetadata.model_validate_json(content) + return True, asm + except ValidationError: # pragma: no cover + return True, None + elif response.status_code < 400 or response.status_code >= 500: + return False, None # Non-4XX error, stop trying + return True, None + + +def validate_authorization_response_iss(iss: str | None, oauth_metadata: OAuthMetadata | None) -> None: + """Validate the RFC 9207 `iss` authorization-response parameter. + + Per RFC 9207 section 2.4, the client compares `iss` against the issuer of the + authorization server the request was sent to, using simple string comparison + (RFC 3986 section 6.2.1, i.e. without URL normalization), and rejects on mismatch. + A response that omits `iss` is rejected only when the server advertised support via + `authorization_response_iss_parameter_supported`. + + Raises: + OAuthFlowError: If `iss` is present and does not match, or is absent when the + authorization server advertised support. + """ + expected = str(oauth_metadata.issuer) if oauth_metadata else None + + if iss is not None: + if iss != expected: + raise OAuthFlowError(f"Authorization response iss mismatch: {iss} != {expected}") + return + + if oauth_metadata is not None and oauth_metadata.authorization_response_iss_parameter_supported: + raise OAuthFlowError("Authorization response missing iss parameter advertised by the authorization server") + + +def validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str) -> None: + """Validate that authorization server metadata `issuer` matches the discovery issuer. + + Per RFC 8414 section 3.3 / SEP-2468, the `issuer` in the metadata must match the issuer + used to construct the well-known URL, compared as a simple string (RFC 3986 section 6.2.1). + + Raises: + OAuthFlowError: If the metadata issuer does not match `expected_issuer`. + """ + if str(oauth_metadata.issuer) != expected_issuer: + raise OAuthFlowError( + f"Authorization server metadata issuer mismatch: {oauth_metadata.issuer} != {expected_issuer}" + ) + + +def create_oauth_metadata_request(url: str) -> Request: + return Request("GET", url, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION}) + + +def create_client_registration_request( + auth_server_metadata: OAuthMetadata | None, client_metadata: OAuthClientMetadata, auth_base_url: str +) -> Request: + """Build a client registration request.""" + + if auth_server_metadata and auth_server_metadata.registration_endpoint: + registration_url = str(auth_server_metadata.registration_endpoint) + else: + registration_url = urljoin(auth_base_url, "/register") + + registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True) + + return Request("POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}) + + +async def handle_registration_response(response: Response) -> OAuthClientInformationFull: + """Handle registration response.""" + if response.status_code not in (200, 201): + await response.aread() + raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}") + + try: + content = await response.aread() + body = from_json(content) + # `issuer` is the SDK's own binding of these credentials to the server they were + # registered with (SEP-2352), stamped by the auth flow - never sourced from the + # wire, so it is dropped before the body is parsed rather than trusted or cleared. + if isinstance(body, dict): + cast(dict[str, Any], body).pop("issuer", None) + return OAuthClientInformationFull.model_validate(body) + except ValueError as e: + # `from_json` reports malformed bytes/JSON as ValueError, and pydantic's + # ValidationError is itself a ValueError, so both parse layers surface here. + raise OAuthRegistrationError(f"Invalid registration response: {e}") from e + + +def is_valid_client_metadata_url(url: str | None) -> bool: + """Validate that a URL is suitable for use as a client_id (CIMD). + + The URL must be HTTPS with a non-root pathname. + + Args: + url: The URL to validate + + Returns: + True if the URL is a valid HTTPS URL with a non-root pathname + """ + if not url: + return False + try: + parsed = urlparse(url) + return parsed.scheme == "https" and parsed.path not in ("", "/") + except Exception: + return False + + +def credentials_match_issuer( + client_info: OAuthClientInformationFull, issuer: str, client_metadata_url: str | None +) -> bool: + """Whether stored client credentials may be reused against `issuer` (SEP-2352). + + A URL-based client ID (CIMD) is portable across authorization servers โ€” the same self-hosted + document is resolved by whichever server is in use โ€” so it always matches; CIMD is identified + by the client ID being the configured `client_metadata_url`, not by URL shape (a registration + server may also issue URL-shaped IDs that are bound to it). Credentials with a recorded issuer + match only when it equals `issuer` (simple string comparison). Credentials with no recorded + issuer (pre-registered, or stored before issuer binding existed) carry no binding to enforce + and are left as-is. + """ + if client_metadata_url is not None and client_info.client_id == client_metadata_url: + return True + if client_info.issuer is None: + return True + return client_info.issuer == issuer + + +def should_use_client_metadata_url( + oauth_metadata: OAuthMetadata | None, + client_metadata_url: str | None, +) -> bool: + """Determine if URL-based client ID (CIMD) should be used instead of DCR. + + URL-based client IDs should be used when: + 1. The server advertises client_id_metadata_document_supported=True + 2. The client has a valid client_metadata_url configured + + Args: + oauth_metadata: OAuth authorization server metadata + client_metadata_url: URL-based client ID (already validated) + + Returns: + True if CIMD should be used, False if DCR should be used + """ + if not client_metadata_url: + return False + + if not oauth_metadata: + return False + + return oauth_metadata.client_id_metadata_document_supported is True + + +def create_client_info_from_metadata_url( + client_metadata_url: str, redirect_uris: list[AnyUrl] | None = None +) -> OAuthClientInformationFull: + """Create client information using a URL-based client ID (CIMD). + + When using URL-based client IDs, the URL itself becomes the client_id + and no client_secret is used (token_endpoint_auth_method="none"). + + Args: + client_metadata_url: The URL to use as the client_id + redirect_uris: The redirect URIs from the client metadata, recorded on the client + information alongside the client_id + + Returns: + OAuthClientInformationFull with the URL as client_id + """ + return OAuthClientInformationFull( + client_id=client_metadata_url, + token_endpoint_auth_method="none", + redirect_uris=redirect_uris, + ) + + +async def handle_token_response_scopes( + response: Response, +) -> OAuthToken: + """Parse and validate a token response. + + Parses token response JSON. Callers should check response.status_code before calling. + + Args: + response: HTTP response from token endpoint (status already checked by caller) + + Returns: + Validated OAuthToken model + + Raises: + OAuthTokenError: If response JSON is invalid + """ + try: + content = await response.aread() + token_response = OAuthToken.model_validate_json(content) + return token_response + except ValidationError as e: # pragma: no cover + raise OAuthTokenError(f"Invalid token response: {e}") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/caching.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/caching.py new file mode 100644 index 0000000000000000000000000000000000000000..a464accd164f4e404620cd26266272b1d5637a54 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/caching.py @@ -0,0 +1,387 @@ +"""Client-side response caching primitives (SEP-2549, protocol revision 2026-07-28).""" + +from __future__ import annotations + +import json +import logging +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final, Literal, Protocol + +import anyio +import anyio.lowlevel +from mcp_types import ( + CacheableResult, + PromptListChangedNotification, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ServerNotification, + ToolListChangedNotification, +) +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +__all__ = [ + "MAX_TTL_MS", + "CacheConfig", + "CacheEntry", + "CacheKey", + "CacheMode", + "InMemoryResponseCacheStore", + "ResponseCacheStore", +] + +logger = logging.getLogger(__name__) + +CacheMode = Literal["use", "refresh", "bypass"] +"""Per-call cache behavior: `"use"` serves and stores, `"refresh"` stores +without serving, `"bypass"` skips the cache entirely.""" + +MAX_TTL_MS: Final[int] = 24 * 60 * 60 * 1000 +"""Cap on any entry's time-to-live (24 hours, in milliseconds); larger `ttlMs` values are clamped down.""" + + +@dataclass(frozen=True, slots=True) +class CacheKey: + """Identity of one cached response; compare as the field tuple, never a flattened string (collision hazard).""" + + method: str + + params_key: str = "" + """Result-affecting params discriminator: the uri for `resources/read`, `""` for the list methods.""" + + partition: str = "" + """Coordinator-computed arm identifier; opaque to stores.""" + + +@dataclass(frozen=True, slots=True) +class CacheEntry: + """One cached response with its freshness and sharing metadata.""" + + value: Any + """The cached result; the SDK deep-copies on write and on serve, so a store may hold it as-is.""" + + scope: Literal["public", "private"] + """Server-asserted `cacheScope`: only `"public"` entries may be shared across authorization contexts.""" + + expires_at: float | None + """Epoch seconds after which the entry is stale; `None` is never fresh.""" + + +class ResponseCacheStore(Protocol): + """Storage contract for the client response cache. + + Each `Client` calls its store from a single event loop; per-operation + atomicity is the implementation's responsibility. Operations may raise - + the SDK degrades to a miss rather than failing the call. A serializing + store must round-trip `value` back to the result model object (a + wrong-shape entry is a miss, never an error). A lookup may issue two + sequential `get` calls (private arm, then public). + """ + + async def get(self, key: CacheKey) -> CacheEntry | None: ... + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: ... + + async def delete(self, key: CacheKey) -> None: ... + + async def clear(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class CacheConfig: + """Configuration for a `Client`'s response cache. + + Raises: + ValueError: On a custom `store` without `partition`, an empty `target_id`, or a negative `default_ttl_ms`. + """ + + store: ResponseCacheStore | None = None + """Backing store; `None` means a per-client `InMemoryResponseCacheStore`. + A custom store requires an explicit `partition`.""" + + partition: str = "" + """Authorization-context identifier isolating `"private"`-scoped entries + within a shared store. Derive it from a verified credential - never from + request-supplied data or the server URL. Fixed for the `Client`'s + lifetime: construct a new `Client` when the principal changes.""" + + target_id: str | None = None + """Server-identity override for custom transports and proxies where the + SDK cannot derive one from a URL; must be non-empty when provided.""" + + default_ttl_ms: int = 0 + """TTL in milliseconds for results carrying no `ttlMs` hint; the default `0` leaves them uncached.""" + + clock: Callable[[], float] = time.time + """Wall-clock source returning epoch seconds; injectable for expiry tests.""" + + share_public: bool = False + """Serve server-marked `"public"` entries across every partition in the store. + + WARNING: this trusts the server's `"public"` classification for every + principal sharing the store - a mislabeled response leaks across tenants. + Constructor-level only: the per-call `cache_mode` can never widen sharing.""" + + def __post_init__(self) -> None: + if self.store is not None and not self.partition: + raise ValueError("a custom store requires an explicit partition") + if self.target_id == "": + raise ValueError("target_id must be a non-empty string or omitted") + if self.default_ttl_ms < 0: + raise ValueError(f"default_ttl_ms must be >= 0, got {self.default_ttl_ms}") + + +class InMemoryResponseCacheStore: + """Default in-process `ResponseCacheStore`. + + Method bodies are synchronous, so concurrent tasks never observe a torn + write. `max_entries` caps the whole store, evicting least-recently-used + at the cap (`0` disables it); `get` and `set` both refresh recency, so a + hot entry survives churn from other keys. + + Raises: + ValueError: If `max_entries` is negative. + """ + + def __init__(self, *, max_entries: int = 1024) -> None: + if max_entries < 0: + raise ValueError(f"max_entries must be >= 0, got {max_entries}") + self._max_entries = max_entries + self._entries: dict[CacheKey, CacheEntry] = {} + + async def get(self, key: CacheKey) -> CacheEntry | None: + entry = self._entries.get(key) + if entry is not None: + # Pop-and-reinsert moves the key to the back: the dict's insertion order is the LRU ledger. + self._entries[key] = self._entries.pop(key) + return entry + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: + self._entries.pop(key, None) + self._entries[key] = entry + if self._max_entries and len(self._entries) > self._max_entries: + del self._entries[next(iter(self._entries))] + + async def delete(self, key: CacheKey) -> None: + self._entries.pop(key, None) + + async def clear(self) -> None: + self._entries.clear() + + +_GENERATION_MAP_CAP: Final[int] = 4096 +"""Cap on the generation map; at the cap the oldest key's eviction-race guard is dropped (FIFO).""" + +_STORE_CLEANUP_TIMEOUT: Final[float] = 5 +"""Bound for must-complete store cleanup deletes (mirrors the dispatcher's final-write bound); +a wedged store delete must not hold client teardown uncancellably.""" + + +class ClientResponseCache: + """Coordinates the `Client` caching verbs with a `ResponseCacheStore`: keys, era gate, TTL/scope, eviction.""" + + def __init__( + self, + *, + store: ResponseCacheStore, + partition: str, + arm_id: str, + default_ttl_ms: int, + clock: Callable[[], float], + share_public: bool, + negotiated_version: Callable[[], str | None], + generation_map_cap: int = _GENERATION_MAP_CAP, + store_cleanup_timeout: float = _STORE_CLEANUP_TIMEOUT, + ) -> None: + self._store = store + self._partition = partition + self._arm_id = arm_id + self._share_public = share_public + self._default_ttl_ms = default_ttl_ms + self._clock = clock + self._negotiated_version = negotiated_version + # A key is eviction-race-guarded iff registered here. + self._generations: dict[tuple[str, str], int] = {} + self._generation_map_cap = generation_map_cap + self._store_cleanup_timeout = store_cleanup_timeout + self._warned_store_ops: set[str] = set() + + def _arm(self, scope: Literal["public", "private"]) -> str: + # JSON arrays so crafted arm_id/partition values cannot collide across field boundaries. + # The negotiated version era-scopes every arm: a session never serves an entry written + # under a different protocol era (its content differs - sieve-stripped fields, header + # filtering). Every caller runs post-connect; were that ever untrue, the supplier's + # None still partitions harmlessly. + fields: list[str | None] = [scope, self._negotiated_version(), self._arm_id] + if scope == "private" or not self._share_public: + fields.append(self._partition) + return json.dumps(fields) + + async def read(self, method: str, params_key: str) -> CacheableResult | None: + """Serve a fresh entry for the key, or `None`; the served result is a deep copy.""" + # A hit completes without any other yielding await, so checkpoint here: a poll + # loop over a fresh entry must not starve spawned tasks (eviction dispatch). + await anyio.lowlevel.checkpoint() + # A wrong-shape entry raises as late as the copy, so the boundary wraps the whole read path. + try: + entry = await self._get_fresh(CacheKey(method, params_key, self._arm("private"))) + if entry is None: + # After a scope flip, a stale private entry must not shadow a fresh public one. + entry = await self._get_fresh(CacheKey(method, params_key, self._arm("public"))) + if entry is not None and entry.scope != "public": + # Never serve an entry the server scoped "private" out of the shared arm. + entry = None + copied: CacheableResult | None = None if entry is None else entry.value.model_copy(deep=True) + except Exception: # boundary around user store code: any read-path failure is a miss, never a failed call + self._warn_store_failure("get") + return None + self._warned_store_ops.discard("get") + return copied + + async def _get_fresh(self, key: CacheKey) -> CacheEntry | None: + entry = await self._store.get(key) + if entry is None or entry.expires_at is None or entry.expires_at <= self._clock(): + return None + return entry + + def capture(self, method: str, params_key: str) -> int: + """Register the key for eviction-race detection before the fetch; `write` takes the returned generation.""" + gen_key = (method, params_key) + if gen_key not in self._generations: + if len(self._generations) >= self._generation_map_cap: + # FIFO overflow: the dropped key's race guard degrades to the accepted co-tenant class. + del self._generations[next(iter(self._generations))] + self._generations[gen_key] = 0 + return self._generations[gen_key] + + async def write( + self, + method: str, + params_key: str, + result: CacheableResult, + gen_at_capture: int, + mode: Literal["use", "refresh"], + ) -> None: + """Store a fetched result under the arm its resolved scope selects.""" + gen_key = (method, params_key) + if self._generation_moved(gen_key, gen_at_capture): + return # the key was evicted while the fetch was in flight + ttl_ms, scope = self._resolve(result) + private_key = CacheKey(method, params_key, self._arm("private")) + public_key = CacheKey(method, params_key, self._arm("public")) + if ttl_ms <= 0: + if mode == "refresh": + # The refetch superseded the warm entry, which a cancellation must not leave serving. + await self._cleanup_delete(private_key, public_key) + return + own, opposite = (public_key, private_key) if scope == "public" else (private_key, public_key) + # Opposite arm first: a failed delete aborts before the set - never two arms answering for one key. + if not await self._delete(opposite): + # The own arm's entry is superseded too: best-effort delete, degrading to a full miss. + await self._cleanup_delete(own) + return + entry = CacheEntry(value=result.model_copy(deep=True), scope=scope, expires_at=self._clock() + ttl_ms / 1000) + try: + if not await self._set(own, entry): + # The fetch superseded any pre-existing own-arm entry, and the failed set + # left it in place: purge it (mirrors the opposite-arm-failure path). + await self._cleanup_delete(own) + finally: + # An eviction can land while the set commits - even when the await + # is cancelled - so re-check on every exit; the delete must complete + # so the pending cancellation cannot resurrect the evicted entry. + if self._generation_moved(gen_key, gen_at_capture): + await self._cleanup_delete(own) + + async def evict_method(self, method: str) -> None: + """Evict the method's cursor-less entry.""" + await self.evict_key(method, "") + + async def evict_key(self, method: str, params_key: str) -> None: + """Evict one key from both arms. + + Only the current era's arms are touched; other-era entries in a persistent store age out by TTL. + """ + gen_key = (method, params_key) + # Bump first so an in-flight fetch cannot write the evicted entry back. + # Unregistered keys skip the bump (uris must not grow the map) but not + # the deletes - a persistent store may hold uncaptured entries. + if gen_key in self._generations: + self._generations[gen_key] += 1 + # Must complete: a cancellation between the deletes would leave one arm serving the evicted entry. + await self._cleanup_delete( + CacheKey(method, params_key, self._arm("private")), + CacheKey(method, params_key, self._arm("public")), + ) + + async def evict_for_notification(self, notification: ServerNotification) -> None: + """Map a server notification to the entries it makes stale. + + Eviction is eventual (spawned-task dispatch): the generation bump closes + the write-back race; a racing read may briefly serve the old entry. + """ + match notification: + case ToolListChangedNotification(): + await self.evict_method("tools/list") + case PromptListChangedNotification(): + await self.evict_method("prompts/list") + case ResourceListChangedNotification(): + # Templates enumerate the same changed resource space. + await self.evict_method("resources/list") + await self.evict_method("resources/templates/list") + case ResourceUpdatedNotification(): + await self.evict_key("resources/read", notification.params.uri) + case _: + pass + + def _resolve(self, result: CacheableResult) -> tuple[int, Literal["public", "private"]]: + # A legacy peer can also put `ttlMs`/`cacheScope` keys on the wire, so + # wire presence is not a peer-era signal - hints count only when modern. + modern = self._negotiated_version() in MODERN_PROTOCOL_VERSIONS + if modern and "ttl_ms" in result.model_fields_set: + # An explicit `ttlMs: 0` stays 0, and negatives are unconstructible + # upstream (model ge=0, parse-seam floor) - only the cap applies. + ttl_ms = result.ttl_ms + else: + ttl_ms = self._default_ttl_ms + scope: Literal["public", "private"] = "public" if modern and result.cache_scope == "public" else "private" + return min(ttl_ms, MAX_TTL_MS), scope + + def _generation_moved(self, gen_key: tuple[str, str], gen_at_capture: int) -> bool: + # A FIFO-dropped key fails open (the accepted co-tenant race) rather than discarding the fetch. + return self._generations.get(gen_key, gen_at_capture) != gen_at_capture + + async def _set(self, key: CacheKey, entry: CacheEntry) -> bool: + try: + await self._store.set(key, entry) + except Exception: # boundary around user store code: nothing cached, the fetch already succeeded + self._warn_store_failure("set") + return False + self._warned_store_ops.discard("set") + return True + + async def _cleanup_delete(self, *keys: CacheKey) -> None: + # Must-complete cleanup: shielded so a pending cancellation cannot skip the deletes, + # bounded so a wedged store delete cannot hold client teardown uncancellably. + with anyio.move_on_after(self._store_cleanup_timeout, shield=True) as scope: + for key in keys: + await self._delete(key) + if scope.cancelled_caught: + logger.warning("Response cache store delete timed out; the entry will age out by TTL") + + async def _delete(self, key: CacheKey) -> bool: + try: + await self._store.delete(key) + except Exception: # boundary around user store code: callers decide whether a failed delete aborts + self._warn_store_failure("delete") + return False + self._warned_store_ops.discard("delete") + return True + + def _warn_store_failure(self, kind: Literal["get", "set", "delete"]) -> None: + # One warning per failure burst, per op kind; re-armed only when that + # same kind succeeds, so a healthy delete cannot re-arm a broken set. + if kind not in self._warned_store_ops: + self._warned_store_ops.add(kind) + logger.warning("Response cache store operation failed; continuing without the cache", exc_info=True) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/client.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/client.py new file mode 100644 index 0000000000000000000000000000000000000000..ed7c40f123bc131fa85823757785a02e6672c669 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/client.py @@ -0,0 +1,950 @@ +"""Unified MCP Client that wraps ClientSession with transport management.""" + +from __future__ import annotations + +import hashlib +import logging +import uuid +from collections.abc import Awaitable, Callable, Mapping, Sequence +from contextlib import AbstractAsyncContextManager, AsyncExitStack +from dataclasses import KW_ONLY, dataclass, field +from typing import Any, Literal, TypeVar, cast + +import anyio +import anyio.lowlevel +import mcp_types as types +from mcp_types import ( + INVALID_PARAMS, + CacheableResult, + CallToolResult, + CompleteResult, + EmptyResult, + ErrorData, + GetPromptResult, + Implementation, + InputRequest, + InputRequiredResult, + InputResponse, + InputResponses, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + LoggingLevel, + PaginatedRequestParams, + PromptReference, + ReadResourceResult, + RequestParamsMeta, + ResourceTemplateReference, + Result, + ServerCapabilities, +) +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS +from typing_extensions import deprecated + +from mcp.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver +from mcp.client._memory import InMemoryTransport +from mcp.client._probe import negotiate_auto +from mcp.client._transport import Transport +from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore +from mcp.client.extension import ClaimContext, ClientExtension, NotificationBinding, ResultClaim +from mcp.client.session import ( + ClientRequestContext, + ClientSession, + ElicitationFnT, + IncomingMessage, + ListRootsFnT, + LoggingFnT, + MessageHandlerFnT, + SamplingFnT, +) +from mcp.client.streamable_http import streamable_http_client +from mcp.client.subscriptions import ServerEvent, Subscription +from mcp.client.subscriptions import listen as _listen +from mcp.server import Server +from mcp.server.mcpserver import MCPServer +from mcp.server.runner import modern_on_request +from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair +from mcp.shared.dispatcher import Dispatcher, ProgressFnT +from mcp.shared.exceptions import MCPDeprecationWarning, MCPError +from mcp.shared.extension import validate_extension_identifier +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.subscriptions import event_to_notification + +logger = logging.getLogger(__name__) + +ConnectMode = Literal["legacy", "auto"] | str +"""``mode=`` value: ``"legacy"`` (initialize handshake), ``"auto"`` (discover, fall back to +initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for +forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction.""" + +_T = TypeVar("_T") +_ResultT = TypeVar("_ResultT") +_CacheableT = TypeVar("_CacheableT", bound=CacheableResult) + +_Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]] +"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources +are needed onto the exit stack and hand back the ``Dispatcher`` ``ClientSession`` will drive. +``mode`` and ``raise_exceptions`` are passed at call time so they're read at the same moment +``__aenter__`` reads them for the handshake step.""" + + +def _connect_transport(transport: Transport) -> _Connector: + """Connector for the stream-backed paths (URL, user-supplied ``Transport``).""" + + async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_exceptions: bool) -> Dispatcher[Any]: + read_stream, write_stream = await exit_stack.enter_async_context(transport) + return JSONRPCDispatcher(read_stream, write_stream) + + return connect + + +def _connect_inproc(server: Server[Any]) -> _Connector: + """Connector for an in-process ``Server``: legacy mode drives the stream loop via + ``InMemoryTransport``; any other mode drives the modern per-request path through a + ``DirectDispatcher`` peer pair (no streams, no JSON-RPC framing, no initialize handshake).""" + + async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exceptions: bool) -> Dispatcher[Any]: + if mode == "legacy": + transport = InMemoryTransport(server, raise_exceptions=raise_exceptions) + read_stream, write_stream = await exit_stack.enter_async_context(transport) + return JSONRPCDispatcher(read_stream, write_stream) + lifespan_state = await exit_stack.enter_async_context(server.lifespan(server)) + client_disp, server_disp = create_direct_dispatcher_pair(raise_handler_exceptions=raise_exceptions) + tg = await exit_stack.enter_async_context(anyio.create_task_group()) + exit_stack.callback(server_disp.close) + on_request = modern_on_request(server, lifespan_state) + await tg.start(server_disp.run, on_request, _no_inbound_client_notifications) + return client_disp + + return connect + + +def _connected(value: _T | None) -> _T: + """Narrow a post-handshake session attribute from ``T | None`` to ``T``. + + ``Client.__aenter__`` only assigns ``_session`` after the handshake succeeds, so inside + ``async with Client(...)`` these attributes are always populated; the ``.session`` gate + raises before this is reached otherwise. The guard exists for pyright, not runtime. + """ + if value is None: # pragma: no cover + raise RuntimeError("Client must be used within an async context manager") + return value + + +def _strip_userinfo(url: str) -> str: + """Drop any userinfo from the URL's authority component; byte-exact otherwise. + + Credentials must not enter cache-key material; any further normalization could merge distinct servers. + """ + # Pure text, no urlsplit: it strips embedded tab/CR/LF before parsing, which would misalign slices. + sep = url.find("//") + if sep == -1: + return url + start = sep + 2 + end = len(url) + for delimiter in "/?#": + if (found := url.find(delimiter, start)) != -1: + end = min(end, found) + authority = url[start:end] + if "@" not in authority: + return url + return url[:start] + authority.rpartition("@")[2] + url[end:] + + +def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT: + """Wrap the session message handler with cache eviction on server notifications.""" + + async def handler(message: IncomingMessage) -> None: + if isinstance(message, types.ServerNotification): + try: + await cache.evict_for_notification(message) + except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery + logger.exception("Response cache eviction failed; the notification is still delivered") + if user_handler is not None: + await user_handler(message) + else: + # Mirrors ClientSession's default handler (session._default_message_handler). + await anyio.lowlevel.checkpoint() + + return handler + + +def _synthesize_discover(protocol_version: str) -> types.DiscoverResult: + return types.DiscoverResult( + supported_versions=[protocol_version], + capabilities=types.ServerCapabilities(), + result_type="complete", + ttl_ms=0, + cache_scope="public", + ) + + +async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Mapping[str, Any] | None) -> None: + """Server-side inbound ``OnNotify`` for the modern in-process path โ€” receives nothing. + + At 2026-07-28 the spec defines no clientโ†’server notifications: ``initialized`` and + ``roots/list_changed`` are removed, and cancellation is structural (anyio scope cancel + through the direct await, not a notify). Serverโ†’client notifications (progress, log + messages) flow the other way via the per-request ``DispatchContext`` into the client's + callbacks, and are not seen here. + """ + + +@dataclass(frozen=True) +class _FoldedExtensions: + """`Client.extensions` instances folded into the shapes `ClientSession` consumes.""" + + ad: dict[str, dict[str, Any]] | None + claims: dict[str, tuple[ResultClaim[Any], ...]] | None + bindings: tuple[NotificationBinding[Any], ...] | None + by_model: Mapping[type[Result], ResultClaim[Any]] + + +def _fold_extensions(extensions: Sequence[ClientExtension] | None) -> _FoldedExtensions: + """Fold extension contributions at construction, naming both owners on duplicate tags or methods.""" + if isinstance(extensions, Mapping): + raise TypeError( + "extensions= takes a sequence of ClientExtension instances. The mapping form was " + "replaced: use advertise(identifier, settings) for advertise-only entries" + ) + if not extensions: + return _FoldedExtensions(ad=None, claims=None, bindings=None, by_model={}) + ad: dict[str, dict[str, Any]] = {} + claims: dict[str, tuple[ResultClaim[Any], ...]] = {} + bindings: list[NotificationBinding[Any]] = [] + by_model: dict[type[Result], ResultClaim[Any]] = {} + claim_owners: dict[str, str] = {} + binding_owners: dict[str, str] = {} + for extension in extensions: + identifier = getattr(extension, "identifier", None) + if identifier is None: + raise ValueError( + f"{type(extension).__name__} has no `identifier`; a ClientExtension must set the " + "`identifier` class attribute (or assign one in `__init__`) before it can be used" + ) + validate_extension_identifier(identifier, owner=type(extension).__name__) + if identifier in ad: + raise ValueError(f"extension identifier {identifier!r} is passed more than once") + ad[identifier] = extension.settings() + extension_claims = tuple(extension.claims()) + for claim in extension_claims: + tag = claim.result_type + if tag in claim_owners: + owner = claim_owners[tag] + both = ( + f"extension {identifier!r} claims" + if owner == identifier + else (f"extensions {owner!r} and {identifier!r} both claim") + ) + raise ValueError(f"{both} resultType {tag!r}; a wire tag can have only one resolver") + claim_owners[tag] = identifier + # Each model pins its result_type Literal to one tag, so this index cannot collide. + by_model[claim.model] = claim + if extension_claims: + claims[identifier] = extension_claims + for binding in extension.notifications(): + if binding.method in binding_owners: + owner = binding_owners[binding.method] + both = ( + f"extension {identifier!r} binds" + if owner == identifier + else (f"extensions {owner!r} and {identifier!r} both bind") + ) + raise ValueError(f"{both} notification method {binding.method!r}; a method can have only one observer") + binding_owners[binding.method] = identifier + bindings.append(binding) + return _FoldedExtensions(ad=ad, claims=claims or None, bindings=tuple(bindings) or None, by_model=by_model) + + +@dataclass +class Client: + """A high-level MCP client for connecting to MCP servers. + + Supports in-memory transport for testing (pass a Server or MCPServer instance), + Streamable HTTP transport (pass a URL string), or a custom Transport instance. + + Example: + ```python + from mcp.client import Client + from mcp.server.mcpserver import MCPServer + + server = MCPServer("test") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + async def main(): + async with Client(server) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + + asyncio.run(main()) + ``` + """ + + server: Server[Any] | MCPServer | Transport | str + """The MCP server to connect to. + + If the server is a `Server` or `MCPServer` instance, it will be connected in-process. + If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport. + If the server is a `Transport` instance, it will be used directly. + """ + + _: KW_ONLY + + # TODO(Marcelo): When do `raise_exceptions=True` actually raises? + raise_exceptions: bool = False + """Whether to raise exceptions from the server.""" + + read_timeout_seconds: float | None = None + """Timeout for read operations.""" + + sampling_callback: SamplingFnT | None = None + """Callback for handling sampling requests.""" + + sampling_capabilities: types.SamplingCapability | None = None + """Sampling sub-capabilities (e.g. tools) declared alongside `sampling_callback`; no effect without it.""" + + list_roots_callback: ListRootsFnT | None = None + """Callback for handling list roots requests.""" + + logging_callback: LoggingFnT | None = None + """Callback for handling logging notifications.""" + + log_level: LoggingLevel | None = None + """The log level to opt in to on 2026-07-28+ connections (deprecated logging feature, SEP-2577). + + Modern (2026-07-28+) servers send `notifications/message` only for requests that opt in by + carrying `io.modelcontextprotocol/logLevel` in `_meta`, and only at or above that level. Setting + this stamps that opt-in on every request; `None` (the default) means no opt-in, so no log + messages arrive - a `logging_callback` alone is not an opt-in. No effect on handshake-era + connections, where the deprecated `logging/setLevel` request governs delivery instead. A + per-request `_meta` entry with the same key overrides this default.""" + + # TODO(Marcelo): Why do we have both "callback" and "handler"? + message_handler: MessageHandlerFnT | None = None + """Callback for handling raw messages.""" + + client_info: Implementation | None = None + """Client implementation info to send to server.""" + + mode: ConnectMode = "auto" + """How to negotiate the protocol version. + + 'auto' (the default) probes `server/discover` and falls back to the initialize handshake on legacy servers; + for an in-process `Server`/`MCPServer` it dispatches directly without JSON-RPC framing. 'legacy' forces the + initialize handshake (byte-identical pre-2026 behavior). A modern protocol-version string (e.g. '2026-07-28') + adopts that version directly without a probe โ€” supply `prior_discover` to reuse a known DiscoverResult, or + omit it to synthesize a minimal one.""" + + prior_discover: types.DiscoverResult | None = None + """A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin. + Ignored when mode='legacy'.""" + + elicitation_callback: ElicitationFnT | None = None + """Callback for handling elicitation requests.""" + + input_required_max_rounds: int = DEFAULT_INPUT_REQUIRED_MAX_ROUNDS + """Cap on `InputRequiredResult` retry rounds before `call_tool` / `get_prompt` / + `read_resource` give up. Use `client.session.(..., allow_input_required=True)` + to drive the loop manually instead.""" + + extensions: Sequence[ClientExtension] | None = None + """Opt-in client extensions (SEP-2133). + + Each instance contributes its capability ad, its result claims (resolved + transparently by `call_tool`), and its notification bindings. For an + ad-only entry use `mcp.client.advertise(identifier, settings)`.""" + + cache: CacheConfig | None = field(default_factory=CacheConfig) + """Client-side response caching for the SEP-2549 cacheable methods (2026-07-28). + + The default `CacheConfig()` honors server `ttlMs`/`cacheScope` hints with a + per-client in-memory store; pass a customized `CacheConfig`, or `None` to + disable. The cacheable verbs take a per-call `cache_mode` (see `CacheMode`); + calls carrying `meta` always reach the server. A `CacheConfig` with a custom + `store` requires `target_id` when the server is not a URL (no identity can be + derived).""" + + _entered: bool = field(init=False, default=False) + _session: ClientSession | None = field(init=False, default=None) + _exit_stack: AsyncExitStack | None = field(init=False, default=None) + _connect: _Connector = field(init=False, repr=False, compare=False) + _response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False) + _folded_extensions: _FoldedExtensions = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS: + hint = ( + f" ({self.mode!r} is a handshake-era version; use mode='legacy')" + if self.mode in HANDSHAKE_PROTOCOL_VERSIONS + else "" + ) + raise ValueError( + f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}" + ) + + self._folded_extensions = _fold_extensions(self.extensions) + + srv = self.server + if isinstance(srv, MCPServer): + srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage] + if isinstance(srv, Server): + self._connect = _connect_inproc(srv) + elif isinstance(srv, str): + self._connect = _connect_transport(streamable_http_client(srv)) + else: + self._connect = _connect_transport(srv) + + if self.cache is not None: + config = self.cache + # Only the hash below leaves this scope - the raw identity may carry credentials; never log or store it. + target_id = config.target_id + if target_id is None and isinstance(self.server, str): + target_id = _strip_userinfo(self.server) + if target_id is None: + if config.store is not None: + raise ValueError( + "a custom cache store requires CacheConfig.target_id when the server is not a URL: " + "in-process servers and Transport instances get a random per-client identity, so " + "their entries in a shared store could never be served to another client" + ) + target_id = uuid.uuid4().hex + self._response_cache = ClientResponseCache( + store=config.store if config.store is not None else InMemoryResponseCacheStore(), + partition=config.partition, + arm_id=hashlib.sha256(target_id.encode()).hexdigest(), + default_ttl_ms=config.default_ttl_ms, + clock=config.clock, + share_public=config.share_public, + # Lazy: the negotiated version is unknown until __aenter__'s handshake. + negotiated_version=lambda: self._session.protocol_version if self._session is not None else None, + ) + + async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession: + """Enter the resolved connector and return an un-entered ClientSession.""" + dispatcher = await self._connect(exit_stack, self.mode, self.raise_exceptions) + message_handler = self.message_handler + if self._response_cache is not None: + message_handler = _evicting_message_handler(self._response_cache, self.message_handler) + return ClientSession( + dispatcher=dispatcher, + read_timeout_seconds=self.read_timeout_seconds, + sampling_callback=self.sampling_callback, + sampling_capabilities=self.sampling_capabilities, + list_roots_callback=self.list_roots_callback, + logging_callback=self.logging_callback, + log_level=self.log_level, + message_handler=message_handler, + client_info=self.client_info, + elicitation_callback=self.elicitation_callback, + extensions=self._folded_extensions.ad, + result_claims=self._folded_extensions.claims, + notification_bindings=self._folded_extensions.bindings, + ) + + async def __aenter__(self) -> Client: + """Enter the async context manager.""" + if self._entered: + raise RuntimeError("Client is already entered; cannot reenter") + self._entered = True + + async with AsyncExitStack() as exit_stack: + session = await self._build_session(exit_stack) + session = await exit_stack.enter_async_context(session) + + if self.mode == "legacy": + await session.initialize() + elif self.mode == "auto": + await negotiate_auto(session) + else: + session.adopt(self.prior_discover or _synthesize_discover(self.mode)) + + # Only publish the session after the handshake succeeds, so `_session is not None` + # implies the protocol_version/server_capabilities are populated (server_info + # stays optional: 2026-era servers may not identify themselves). If the + # handshake raised above, the local exit_stack unwinds the transport for us. + self._session = session + self._exit_stack = exit_stack.pop_all() + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: + """Exit the async context manager.""" + if self._exit_stack: # pragma: no branch + await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) + self._session = None + + @property + def session(self) -> ClientSession: + """Get the underlying ClientSession. + + This provides access to the full ClientSession API for advanced use cases. + + Raises: + RuntimeError: If accessed before entering the context manager. + """ + if self._session is None: + raise RuntimeError("Client must be used within an async context manager") + return self._session + + # TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view + # type whose protocol_version/server_capabilities are non-Optional fields, + # eliminating these guards (and the one in .session). Same family as resolving the + # transport/connector at __post_init__ so the Optional internal fields disappear. + # (server_info stays Optional even connected: the 2026-era stamp is optional.) + @property + def protocol_version(self) -> str: + """Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``).""" + return _connected(self.session.protocol_version) + + @property + def server_info(self) -> Implementation | None: + """Server name/version, or `None` when the server did not identify itself. + + Legacy connections always carry it (`InitializeResult.serverInfo` is + required); on 2026-era connections the `_meta` `serverInfo` stamp is + optional, so an anonymous server reads as `None`. + """ + return self.session.server_info + + @property + def server_capabilities(self) -> ServerCapabilities: + """Server capabilities (set by initialize/discover/adopt during ``__aenter__``).""" + return _connected(self.session.server_capabilities) + + @property + def instructions(self) -> str | None: + """Server-provided instructions text, if any.""" + return self.session.instructions + + @deprecated( + "ping is removed as of 2026-07-28; the method only works under mode='legacy'.", + category=MCPDeprecationWarning, + ) + async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult: + """Send a ping request to the server.""" + return await self.session.send_ping(meta=meta) + + @deprecated( + "Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.", + category=MCPDeprecationWarning, + ) + async def send_progress_notification( + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, + ) -> None: + """Send a progress notification to the server.""" + await self.session.send_progress_notification( # pyright: ignore[reportDeprecated] + progress_token=progress_token, + progress=progress, + total=total, + message=message, + ) + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def set_logging_level(self, level: LoggingLevel, *, meta: RequestParamsMeta | None = None) -> EmptyResult: + """Set the logging level on the server.""" + return await self.session.set_logging_level(level=level, meta=meta) # pyright: ignore[reportDeprecated] + + async def _cached_fetch( + self, + method: str, + *, + cursor: str | None, + meta: RequestParamsMeta | None, + cache_mode: CacheMode, + send: Callable[[], Awaitable[_CacheableT]], + absorb: Callable[[_CacheableT], _CacheableT] | None = None, + ) -> _CacheableT: + """Serve one of the four list verbs through the response cache. + + `absorb` (tools/list only) re-applies session-side derived state to a served cache hit. + """ + cache = self._response_cache + if cache is None or cache_mode == "bypass": + return await send() + # A closed (or never-entered) client must raise, never serve cached entries. + _ = self.session + if meta is not None and cache_mode == "use": + # meta (a progress token, tracing fields) expects a wire request; fetch and replace the entry. + cache_mode = "refresh" + if cursor is not None: + # Continuation pages skip the cache, but an expired cursor means the listing changed (spec SHOULD evict). + try: + return await send() + except MCPError as e: + if e.code == INVALID_PARAMS: + await cache.evict_method(method) + raise + if cache_mode == "use" and (hit := await cache.read(method, "")) is not None: + # The hit is a private deep copy, so absorption may mutate it freely. + served = cast(_CacheableT, hit) + return served if absorb is None else absorb(served) + gen = cache.capture(method, "") + result = await send() + await cache.write(method, "", result, gen, cache_mode) + return result + + async def list_resources( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", + ) -> ListResourcesResult: + """List available resources from the server.""" + return await self._cached_fetch( + "resources/list", + cursor=cursor, + meta=meta, + cache_mode=cache_mode, + send=lambda: self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta)), + ) + + async def list_resource_templates( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", + ) -> ListResourceTemplatesResult: + """List available resource templates from the server.""" + return await self._cached_fetch( + "resources/templates/list", + cursor=cursor, + meta=meta, + cache_mode=cache_mode, + send=lambda: self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta)), + ) + + async def read_resource( + self, + uri: str, + *, + input_responses: InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", + ) -> ReadResourceResult: + """Read a resource from the server. + + If the server returns an `InputRequiredResult`, the embedded input + requests are dispatched to this client's sampling / elicitation / roots + callbacks and the read is retried automatically (up to + `input_required_max_rounds`). + + Args: + uri: The URI of the resource to read. + input_responses: Responses to seed the first call with (e.g. when + resuming from a persisted `InputRequiredResult`). + request_state: Opaque state to seed the first call with. + meta: Additional metadata for the request. + cache_mode: Cache behavior for this call (see `CacheMode`); seeded + calls (`input_responses` or `request_state` set) ignore it. + + Returns: + The resource content. + + Raises: + InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. + MCPError: A callback returned `ErrorData` for an embedded input request. + pydantic.ValidationError: The server returned a result that does not + conform to the negotiated protocol version. + """ + + async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | InputRequiredResult: + return await self.session.read_resource( + uri, input_responses=r, request_state=s, meta=meta, allow_input_required=True + ) + + # Seeded calls resume a specific exchange and must never be cached (spec MUST). + seeded = input_responses is not None or request_state is not None + cache = None if seeded else self._response_cache + if cache is None or cache_mode == "bypass": + return await self._drive_input_required(await retry(input_responses, request_state), retry) + # A closed (or never-entered) client must raise, never serve cached entries. + _ = self.session + if meta is not None and cache_mode == "use": + # Calls carrying meta always reach the server (mirrors `_cached_fetch`). + cache_mode = "refresh" + if cache_mode == "use" and (hit := await cache.read("resources/read", uri)) is not None: + # Only terminal first-round results are stored, so a hit legitimately skips the driver. + return cast(ReadResourceResult, hit) + gen = cache.capture("resources/read", uri) + first = await retry(None, None) + if not isinstance(first, InputRequiredResult): + await cache.write("resources/read", uri, first, gen, cache_mode) + elif cache_mode == "refresh": + # The refresh superseded whatever was cached, but an input_required resolution + # cannot be stored: purge the warm entry so it cannot be served again. + await cache.evict_key("resources/read", uri) + # Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST). + return await self._drive_input_required(first, retry) + + def listen( + self, + *, + tools_list_changed: bool = False, + prompts_list_changed: bool = False, + resources_list_changed: bool = False, + resource_subscriptions: Sequence[str] = (), + ) -> AbstractAsyncContextManager[Subscription]: + """Open a `subscriptions/listen` stream of typed change events (2026-07-28 only). + + Keyword args mirror the wire `SubscriptionFilter`; entering waits for the ack (honored subset: `sub.honored`): + + async with client.listen(tools_list_changed=True) as sub: + async for event in sub: + tools = await client.list_tools() # refetch on change + + A graceful close ends the loop; an abrupt drop raises `SubscriptionLost`. No replay: re-listen and refetch. + + Raises: + ListenNotSupportedError: The negotiated protocol version predates 2026-07-28. + MCPError: The server rejected the request or the connection failed first. + SubscriptionLost: The stream ended before it was acknowledged. + TimeoutError: The read timeout elapsed before the acknowledgment. + """ + return _listen( + self.session, + tools_list_changed=tools_list_changed, + prompts_list_changed=prompts_list_changed, + resources_list_changed=resources_list_changed, + resource_subscriptions=resource_subscriptions, + on_event=self._evict_for_listen_event if self._response_cache is not None else None, + ) + + async def _evict_for_listen_event(self, event: ServerEvent) -> None: + """Finish response-cache eviction before a listen consumer can refetch. + + Without it the iterator wakes first and refetches a still-warm entry, with no + corrective wake (events are deduplicated level triggers). The tee path repeats + the eviction; deliberate: idempotent, and it covers non-iterating consumers. + """ + cache = self._response_cache + assert cache is not None # installed as the event barrier only when a cache exists + try: + await cache.evict_for_notification(event_to_notification(event, {})) + except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery + logger.exception("Response cache eviction failed; the event is still delivered") + + @deprecated( + "resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) + async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: + """Subscribe to resource updates (2025-era servers only).""" + return await self.session.subscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated] + + @deprecated( + "resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) + async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: + """Unsubscribe from resource updates (2025-era servers only).""" + return await self.session.unsubscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated] + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> CallToolResult: + """Call a tool on the server. + + If the server returns an `InputRequiredResult`, the embedded input + requests are dispatched to this client's sampling / elicitation / roots + callbacks and the call is retried automatically (up to + `input_required_max_rounds`). To drive the loop yourself โ€” e.g. to + persist `request_state` across process restarts โ€” use + `client.session.call_tool(..., allow_input_required=True)`. Persisted + state is still subject to the server's TTL, request binding, and key + lifetime; a server on the default process-local key rejects it after a restart. + + Result shapes claimed by this client's `extensions` are finished by the + owning claim's resolver, whose `CallToolResult` is returned; resolver + exceptions propagate as-is. To receive the claimed shape yourself, use + `client.session.call_tool(..., allow_claimed=True)`. + + Args: + name: The name of the tool to call. + arguments: Arguments to pass to the tool. + read_timeout_seconds: Timeout for each underlying `tools/call` round. + progress_callback: Callback for progress updates. + input_responses: Responses to seed the first call with (e.g. when + resuming from a persisted `InputRequiredResult`). + request_state: Opaque state to seed the first call with. + meta: Additional metadata for the request. + + Returns: + The tool result. + + Raises: + InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. + MCPError: A callback returned `ErrorData` for an embedded input request. + pydantic.ValidationError: The server returned a result that does not + conform to the negotiated protocol version. + """ + + async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result: + return await self.session.call_tool( + name, + arguments, + read_timeout_seconds=read_timeout_seconds, + progress_callback=progress_callback, + input_responses=r, + request_state=s, + meta=meta, + allow_input_required=True, + # Input rounds resolve before a claimed result, so a claim may end any round. + allow_claimed=True, + ) + + result = await self._drive_input_required(await retry(input_responses, request_state), retry) + if isinstance(result, CallToolResult): + return result + # Only claimed shapes reach this point, so the lookup is total. + claim = self._folded_extensions.by_model[type(result)] + final = await claim.resolve( + result, + ClaimContext(session=self.session, tool_name=name, read_timeout_seconds=read_timeout_seconds), + ) + if not final.is_error: + # Match the direct path: revalidate the output schema, but never for isError results. + await self.session.validate_tool_result(name, final) + return final + + async def list_prompts( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", + ) -> ListPromptsResult: + """List available prompts from the server.""" + return await self._cached_fetch( + "prompts/list", + cursor=cursor, + meta=meta, + cache_mode=cache_mode, + send=lambda: self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta)), + ) + + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + input_responses: InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> GetPromptResult: + """Get a prompt from the server. + + If the server returns an `InputRequiredResult`, the embedded input + requests are dispatched to this client's sampling / elicitation / roots + callbacks and the get is retried automatically (up to + `input_required_max_rounds`). + + Args: + name: The name of the prompt. + arguments: Arguments to pass to the prompt. + input_responses: Responses to seed the first call with (e.g. when + resuming from a persisted `InputRequiredResult`). + request_state: Opaque state to seed the first call with. + meta: Additional metadata for the request. + + Returns: + The prompt content. + + Raises: + InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. + MCPError: A callback returned `ErrorData` for an embedded input request. + pydantic.ValidationError: The server returned a result that does not + conform to the negotiated protocol version. + """ + + async def retry(r: InputResponses | None, s: str | None) -> GetPromptResult | InputRequiredResult: + return await self.session.get_prompt( + name, arguments, input_responses=r, request_state=s, meta=meta, allow_input_required=True + ) + + return await self._drive_input_required(await retry(input_responses, request_state), retry) + + async def _drive_input_required( + self, + first: _ResultT | InputRequiredResult, + retry: Callable[[InputResponses | None, str | None], Awaitable[_ResultT | InputRequiredResult]], + ) -> _ResultT: + """Hand an `InputRequiredResult` to the SEP-2322 driver, or pass a terminal result through. + + `dispatch` routes each embedded request through the same callback table + that serves legacy serverโ†’client RPCs, so the two paths stay + behaviourally identical by construction. + """ + if not isinstance(first, InputRequiredResult): + return first + session = self.session + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + ctx = ClientRequestContext(session=session, request_id=key, meta=req.params.meta if req.params else None) + return await session.dispatch_input_request(ctx, req) + + return await run_input_required_driver( + first, dispatch=dispatch, retry=retry, max_rounds=self.input_required_max_rounds + ) + + async def complete( + self, + ref: ResourceTemplateReference | PromptReference, + argument: dict[str, str], + context_arguments: dict[str, str] | None = None, + ) -> CompleteResult: + """Get completions for a prompt or resource template argument. + + Args: + ref: Reference to the prompt or resource template + argument: The argument to complete + context_arguments: Additional context arguments + + Returns: + Completion suggestions. + """ + return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments) + + async def list_tools( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", + ) -> ListToolsResult: + """List available tools from the server.""" + return await self._cached_fetch( + "tools/list", + cursor=cursor, + meta=meta, + cache_mode=cache_mode, + send=lambda: self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)), + # A cache hit skips session.list_tools, so the session re-absorbs the served + # listing to rebuild its derived per-tool state. Hits are cursorless, but a + # cached page 1 can carry next_cursor - never prune on a partial listing. + absorb=lambda hit: self.session._absorb_tool_listing( # pyright: ignore[reportPrivateUsage] + hit, complete=hit.next_cursor is None + ), + ) + + @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def send_roots_list_changed(self) -> None: + """Send a notification that the roots list has changed.""" + # TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support. + await self.session.send_roots_list_changed() # pyright: ignore[reportDeprecated] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/context.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/context.py new file mode 100644 index 0000000000000000000000000000000000000000..aecd29527fee5eb22d50ce9dec4de2d42804130d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/context.py @@ -0,0 +1,5 @@ +"""Request context for MCP client handlers.""" + +from mcp.client.session import ClientRequestContext + +__all__ = ["ClientRequestContext"] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/extension.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/extension.py new file mode 100644 index 0000000000000000000000000000000000000000..a813475e5e72785ac24716e34842bfa43720e630 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/extension.py @@ -0,0 +1,196 @@ +"""Opt-in extension interface for MCP clients. + +Subclass `ClientExtension`, set `identifier`, override the hooks you need, and +pass instances to `Client(extensions=[...])`. For an identifier-only +capability ad, use `advertise()`. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeVar, get_args + +from mcp_types import CORE_RESULT_TYPES, CallToolResult, InputRequiredResult, Result +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from pydantic import AliasChoices, AliasPath, BaseModel +from pydantic.fields import FieldInfo + +from mcp.shared.extension import validate_extension_identifier + +if TYPE_CHECKING: + from mcp.client.session import ClientSession + +__all__ = [ + "ClaimContext", + "ClientExtension", + "NotificationBinding", + "ResultClaim", + "UnexpectedClaimedResult", + "advertise", +] + +_CLAIM_METHODS: Final[frozenset[str]] = frozenset({"tools/call"}) +"""The closed set of verbs a claim may attach to; widen together with the `method` Literal.""" + +_RESERVED_WIRE_ALIASES: Final[frozenset[str]] = frozenset({"requestState", "inputRequests"}) +"""Typed optional fields of the core result surface that pre-validates every inbound result.""" + + +def _wire_keys(name: str, field: FieldInfo) -> frozenset[str]: + """Every top-level wire key this field can read from or write to.""" + keys = {field.alias or name} + if field.serialization_alias: + keys.add(field.serialization_alias) + validation_alias = field.validation_alias + choices = validation_alias.choices if isinstance(validation_alias, AliasChoices) else [validation_alias] + for choice in choices: + if isinstance(choice, AliasPath): + choice = choice.path[0] + if isinstance(choice, str): + keys.add(choice) + return frozenset(keys) + + +ClaimedT = TypeVar("ClaimedT", bound=Result) +NotifyParamsT = TypeVar("NotifyParamsT", bound=BaseModel) + + +@dataclass(frozen=True, kw_only=True) +class ClaimContext: + """Host-injected context for one `ResultClaim.resolve` call.""" + + session: ClientSession + tool_name: str + read_timeout_seconds: float | None + + +@dataclass(frozen=True, kw_only=True) +class ResultClaim(Generic[ClaimedT]): + """One extra result shape on one spec verb, keyed by the wire `resultType`. + + Active only while the declaring extension is constructed into the client and + the negotiated protocol version admits it. `resolve` finishes a claimed + result, may send follow-ups through `ctx.session`, and must return the + verb's ordinary result. All field constraints are enforced at construction. + """ + + result_type: str + model: type[ClaimedT] + resolve: Callable[[ClaimedT, ClaimContext], Awaitable[CallToolResult]] + method: Literal["tools/call"] = "tools/call" + protocol_versions: frozenset[str] | None = None + + def __post_init__(self) -> None: + if self.method not in _CLAIM_METHODS: + raise ValueError(f"claims attach to {sorted(_CLAIM_METHODS)} only; got method {self.method!r}") + if self.result_type in CORE_RESULT_TYPES: + raise ValueError(f"resultType {self.result_type!r} is core protocol vocabulary") + if Result not in self.model.__mro__: # runtime guard; the ClaimedT bound only constrains checked callers + raise ValueError(f"{self.model.__name__} must subclass mcp_types.Result") + if issubclass(self.model, CallToolResult | InputRequiredResult): + raise ValueError("claim models must not subclass core result types") + for name, model_field in self.model.model_fields.items(): + for clash in sorted(_wire_keys(name, model_field) & _RESERVED_WIRE_ALIASES): + raise ValueError( + f"{self.model.__name__}.{name} aliases {clash!r}, a typed field of the core " + "result surface; a colliding value would fail core validation before the " + "claim adapter runs" + ) + field = self.model.model_fields.get("result_type") + if field is None or get_args(field.annotation) != (self.result_type,): + raise ValueError(f"{self.model.__name__}.result_type must be Literal[{self.result_type!r}]") + if self.protocol_versions is not None and not self.protocol_versions: + raise ValueError("empty protocol_versions could never activate; use None for all") + if self.protocol_versions is not None and not self.protocol_versions.issubset(MODERN_PROTOCOL_VERSIONS): + unrecognized = sorted(self.protocol_versions.difference(MODERN_PROTOCOL_VERSIONS)) + raise ValueError( + f"protocol_versions {unrecognized} are not modern protocol revisions; claimed shapes " + "cannot be delivered on a legacy wire (None means every modern version)" + ) + + +class UnexpectedClaimedResult(RuntimeError): + """A claimed (extension) result arrived on a `call_tool` that did not opt in. + + The parsed value is carried as `result`; the server may already hold state it + references. Opt in via `Client(extensions=[...])` or `allow_claimed=True`. + """ + + def __init__(self, result: Result) -> None: + super().__init__( + f"Server returned a claimed result ({type(result).__name__}); pass the owning extension to " + "Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True " + "and handle the shape. The carried result may reference server-side state needing cleanup." + ) + self.result = result + + +@dataclass(frozen=True, kw_only=True) +class NotificationBinding(Generic[NotifyParamsT]): + """Deliver server notifications for `method` (the bare wire name) to `handler`. + + Observation-only: validated params arrive one at a time per binding, in + dispatch order, through a bounded queue that drops the oldest with a warning + on overflow. Stream transports dispatch each notification independently, so + near-simultaneous notifications may be dispatched out of wire order. Methods + the negotiated version's core tables handle are never delivered to bindings. + """ + + method: str + params_type: type[NotifyParamsT] + handler: Callable[[NotifyParamsT], Awaitable[None]] + + +class ClientExtension: + """Base class for an opt-in client extension; override only what you need. + + The surface is declarative, fixed at construction, and never receives the client. + """ + + #: Reverse-DNS extension identifier, advertised under `ClientCapabilities.extensions`. + identifier: str + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + # Per-instance identifiers (assigned in __init__) are validated at consumption instead. + if (identifier := cls.__dict__.get("identifier")) is not None: + validate_extension_identifier(identifier, owner=cls.__name__) + + def settings(self) -> dict[str, Any]: + """Per-extension settings advertised at `ClientCapabilities.extensions[identifier]`. + + Read once at `Client` construction. A claim-bearing extension is + advertised only at protocol versions where at least one of its claims + is active. + """ + return {} + + def claims(self) -> Sequence[ResultClaim[Any]]: + """Extra result shapes this extension claims, with their resolvers.""" + return () + + def notifications(self) -> Sequence[NotificationBinding[Any]]: + """Server notifications this extension observes.""" + return () + + +class _AdvertiseOnly(ClientExtension): + """Ad-only extension returned by `advertise()`.""" + + def __init__(self, identifier: str, settings: dict[str, Any]) -> None: + self.identifier = identifier + self._settings = settings + + def settings(self) -> dict[str, Any]: + return self._settings + + +def advertise(identifier: str, settings: dict[str, Any] | None = None) -> ClientExtension: + """Advertise an extension identifier (with optional settings) and nothing else. + + Advertising an extension you do not implement asserts wire support you do + not have; for behavioral extensions construct the real extension instead. + """ + validate_extension_identifier(identifier, owner="advertise") + return _AdvertiseOnly(identifier, {} if settings is None else settings) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/session.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/session.py new file mode 100644 index 0000000000000000000000000000000000000000..895339ca18120cfb6ddf7f6eb9d6afe6a9e8e669 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/session.py @@ -0,0 +1,1462 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from functools import reduce +from operator import or_ +from types import TracebackType +from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, overload + +import anyio +import anyio.abc +import anyio.lowlevel +import mcp_types as types +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + CONNECTION_CLOSED, + INTERNAL_ERROR, + LOG_LEVEL_META_KEY, + METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, + RequestId, + RequestParamsMeta, +) +from mcp_types import methods as _methods +from mcp_types.version import ( + HANDSHAKE_PROTOCOL_VERSIONS, + LATEST_HANDSHAKE_VERSION, + LATEST_MODERN_VERSION, + MODERN_PROTOCOL_VERSIONS, +) +from pydantic import BaseModel, Discriminator, Tag, TypeAdapter, ValidationError +from typing_extensions import Self, TypeVar, deprecated + +from mcp.client._transport import ReadStream, WriteStream +from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult +from mcp.client.subscriptions import ListenRoute +from mcp.shared._compat import resync_tracer +from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT, as_request_id +from mcp.shared.exceptions import MCPDeprecationWarning, MCPError +from mcp.shared.inbound import ( + MCP_METHOD_HEADER, + MCP_NAME_HEADER, + MCP_PROTOCOL_VERSION_HEADER, + NAME_BEARING_METHODS, + encode_header_value, + find_invalid_x_mcp_header, + mcp_param_headers, + x_mcp_header_map, +) +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, cancelled_request_id_from_params +from mcp.shared.message import ClientMessageMetadata, SessionMessage +from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire +from mcp.shared.transport_context import TransportContext + +if TYPE_CHECKING: + # `jsonschema` is imported lazily inside `validate_tool_result`: pulling it (and its + # `attrs`/`referencing` tree) in at module scope costs every client that never validates. + from jsonschema.protocols import Validator + +DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") +DISCOVER_TIMEOUT_SECONDS = 10.0 +_NOTIFICATION_QUEUE_SIZE: Final = 256 + +logger = logging.getLogger("client") + + +def _clamp_inbound_ttl(raw: dict[str, Any]) -> None: + """Floor a negative inbound `ttlMs` to 0 before `ge=0` validation fails the call (2026-07-28 caching SHOULD).""" + ttl = raw.get("ttlMs") + if isinstance(ttl, int | float) and not isinstance(ttl, bool) and ttl < 0: + raw["ttlMs"] = 0 + + +def _same_schema(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: + """JSON equality for two output schemas. + + Python `==` is not JSON equality: it conflates `True`/`1` and `False`/`0`, which JSON + Schema keeps distinct (`const: true` vs `const: 1`). Canonical serialization compares as + JSON does; where it is stricter (`1` vs `1.0`), erring toward "changed" only costs a + recompile, never a stale validator. + """ + return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True) + + +def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None: + # initialize/discover forbid cancellation; other pre-handshake requests (lowlevel + # ClientSession callers may skip the handshake entirely) keep the courtesy cancel. + if data["method"] in ("initialize", "server/discover"): + opts["cancel_on_abandon"] = False + + +def _parse_server_info_stamp(result: types.DiscoverResult) -> types.Implementation | None: + """The typed identity from a discover result's `_meta` serverInfo stamp. + + The stamp is display-only per the spec, so absent and malformed both read + as `None` rather than failing the connection. + """ + raw = (result.meta or {}).get(SERVER_INFO_META_KEY) + if raw is None: + return None + try: + return types.Implementation.model_validate(raw) + except ValidationError: + return None + + +def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]: + def stamp(data: dict[str, Any], opts: CallOptions) -> None: + opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version + + return stamp + + +def _make_modern_stamp( + protocol_version: str, + client_info: dict[str, Any], + capabilities: dict[str, Any], + resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]], + *, + log_level: types.LoggingLevel | None = None, +) -> Callable[[dict[str, Any], CallOptions], None]: + def stamp(data: dict[str, Any], opts: CallOptions) -> None: + params = data.setdefault("params", {}) + meta = params.setdefault("_meta", {}) + meta[PROTOCOL_VERSION_META_KEY] = protocol_version + meta[CLIENT_INFO_META_KEY] = client_info + meta[CLIENT_CAPABILITIES_META_KEY] = capabilities + # The per-request log-delivery opt-in (2026 logging is opt-in per + # request). A default the caller can override on any single call by + # supplying the key in that request's `_meta`, hence setdefault. + if log_level is not None: + meta.setdefault(LOG_LEVEL_META_KEY, log_level) + # `cancel_on_abandon` stays at the dispatcher default (True): the + # courtesy `notifications/cancelled` is the abandon signal. On the + # stream transports it is the 2026 wire's cancellation spelling; the + # streamable-HTTP transport translates it into aborting the request's + # own POST instead of writing it (the 2026 HTTP wire has no + # client-to-server notifications - closing the stream is the signal). + # The negotiation methods still opt out, mirroring `_preconnect_stamp`: + # the spec forbids cancelling them. + if data["method"] in ("initialize", "server/discover"): + opts["cancel_on_abandon"] = False + headers = opts.setdefault("headers", {}) + headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version + headers[MCP_METHOD_HEADER] = data["method"] + name_key = NAME_BEARING_METHODS.get(data["method"]) + if name_key is not None and isinstance(name := params.get(name_key), str): + headers[MCP_NAME_HEADER] = encode_header_value(name) + if data["method"] == "tools/call" and isinstance(name := params.get("name"), str): + headers.update(resolve_param_headers(name, params.get("arguments") or {})) + + return stamp + + +ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) + + +@dataclass(kw_only=True) +class ClientRequestContext: + """Context for a server-initiated request, passed to the sampling/elicitation/list-roots callbacks.""" + + session: ClientSession + request_id: RequestId + meta: RequestParamsMeta | None = None + + +class SamplingFnT(Protocol): + async def __call__( + self, + context: ClientRequestContext, + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: ... # pragma: no branch + + +class ElicitationFnT(Protocol): + async def __call__( + self, + context: ClientRequestContext, + params: types.ElicitRequestParams, + ) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch + + +class ListRootsFnT(Protocol): + async def __call__( + self, context: ClientRequestContext + ) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch + + +class LoggingFnT(Protocol): + async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch + + +IncomingMessage: TypeAlias = types.ServerNotification | Exception +"""What `message_handler` receives: the server notifications the session surfaces, plus transport-level exceptions. + +`notifications/cancelled` is applied by the dispatcher and never surfaced, and a +`notifications/subscriptions/acknowledged` for a live `listen()` stream is consumed by that +stream, so neither reaches the handler. +""" + + +class MessageHandlerFnT(Protocol): + async def __call__(self, message: IncomingMessage) -> None: ... # pragma: no branch + + +async def _default_message_handler(message: IncomingMessage) -> None: + await anyio.lowlevel.checkpoint() + + +async def _default_sampling_callback( + context: ClientRequestContext, + params: types.CreateMessageRequestParams, +) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Sampling not supported", + ) + + +async def _default_elicitation_callback( + context: ClientRequestContext, + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Elicitation not supported", + ) + + +async def _default_list_roots_callback( + context: ClientRequestContext, +) -> types.ListRootsResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="List roots not supported", + ) + + +async def _default_logging_callback( + params: types.LoggingMessageNotificationParams, +) -> None: + pass + + +ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) + +# Typed against the wide parse union so adopt-built claim adapters share this attribute type. +_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result] = TypeAdapter( + types.CallToolResult | types.InputRequiredResult +) +_GetPromptResultAdapter: TypeAdapter[types.GetPromptResult | types.InputRequiredResult] = TypeAdapter( + types.GetPromptResult | types.InputRequiredResult +) +_ReadResourceResultAdapter: TypeAdapter[types.ReadResourceResult | types.InputRequiredResult] = TypeAdapter( + types.ReadResourceResult | types.InputRequiredResult +) + + +def _claim_active(claim: ResultClaim[Any], version: str) -> bool: + """A claim is active at modern versions only, narrowed by its optional version subset.""" + return version in MODERN_PROTOCOL_VERSIONS and ( + claim.protocol_versions is None or version in claim.protocol_versions + ) + + +def _active_claims_at( + claims_by_extension: Mapping[str, tuple[ResultClaim[Any], ...]], version: str +) -> dict[str, ResultClaim[Any]]: + """Claims active at `version`, keyed by wire tag; empty at any legacy version.""" + return { + claim.result_type: claim + for claims in claims_by_extension.values() + for claim in claims + if _claim_active(claim, version) + } + + +def _build_call_tool_adapter( + active: Mapping[str, ResultClaim[Any]], +) -> TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result]: + """Build a discriminated tools/call adapter: a core arm plus one arm per active claim.""" + if not active: + return _CallToolResultAdapter + tags = frozenset(active) + core_arm = "core" + while core_arm in tags: # the routing sentinel must never collide with a claimed tag + core_arm += "-" + + def _route(value: Any) -> str: + # pydantic hands the discriminator either the raw dict or an already-built model. + # Unknown or non-string tags route to the core arm and fail core validation there. + if isinstance(value, dict): + tag = cast("dict[str, Any]", value).get("resultType") + else: + tag = getattr(value, "result_type", None) + return tag if isinstance(tag, str) and tag in tags else core_arm + + arms: list[Any] = [Annotated[types.CallToolResult | types.InputRequiredResult, Tag(core_arm)]] + arms += [Annotated[claim.model, Tag(tag)] for tag, claim in active.items()] + # reduce(or_) rather than Union star-unpack, which needs py3.11+. + return TypeAdapter(Annotated[reduce(or_, arms), Discriminator(_route)]) + + +def _index_claims( + result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None, + extensions: dict[str, dict[str, Any]] | None, +) -> dict[str, tuple[ResultClaim[Any], ...]]: + """Validate and copy the claims-by-extension mapping.""" + indexed: dict[str, tuple[ResultClaim[Any], ...]] = {} + seen: set[str] = set() + for identifier, claims in (result_claims or {}).items(): + if extensions is None or identifier not in extensions: + raise ValueError( + f"result_claims key {identifier!r} has no extensions entry; a claim is only " + "advertised through its extension's capability ad" + ) + if not claims: + raise ValueError( + f"result_claims[{identifier!r}] is empty and would drop the extension from " + "the capability ad at every version. Omit the key instead" + ) + for claim in claims: + if claim.result_type in seen: + raise ValueError(f"duplicate result claim for resultType {claim.result_type!r}") + seen.add(claim.result_type) + indexed[identifier] = tuple(claims) + return indexed + + +def _index_bindings( + notification_bindings: Sequence[NotificationBinding[Any]] | None, +) -> dict[str, NotificationBinding[Any]]: + """Index bindings by wire method, rejecting duplicates.""" + indexed: dict[str, NotificationBinding[Any]] = {} + for binding in notification_bindings or (): + if binding.method in indexed: + raise ValueError(f"duplicate notification binding for method {binding.method!r}") + indexed[binding.method] = binding + return indexed + + +def _input_required_unexpected(method: str) -> RuntimeError: + return RuntimeError( + "Server returned InputRequiredResult; pass allow_input_required=True to receive it " + f"and retry {method}(..., input_responses=..., request_state=result.request_state)." + ) + + +class ClientSession: + """Client half of an MCP connection, running on a `Dispatcher`. + + Construct it over a transport's stream pair (or pass a pre-built + `dispatcher=`), enter as an async context manager, then call + `initialize()`. The dispatcher owns the receive loop and request + correlation; this class owns the typed MCP layer and the constructor + callbacks. Transport `Exception` items reach `message_handler` on any + stream-backed dispatcher (`JSONRPCDispatcher`), whether built here from a + stream pair or supplied without a stream-exception hook of its own; an + in-process `DirectDispatcher` carries none. + + Extension `result_claims` fold into tools/call parsing at `adopt()`; + `notification_bindings` observe vendor notifications via bounded FIFOs. + """ + + def __init__( + self, + read_stream: ReadStream[SessionMessage | Exception] | None = None, + write_stream: WriteStream[SessionMessage] | None = None, + read_timeout_seconds: float | None = None, + sampling_callback: SamplingFnT | None = None, + elicitation_callback: ElicitationFnT | None = None, + list_roots_callback: ListRootsFnT | None = None, + logging_callback: LoggingFnT | None = None, + message_handler: MessageHandlerFnT | None = None, + client_info: types.Implementation | None = None, + *, + log_level: types.LoggingLevel | None = None, + sampling_capabilities: types.SamplingCapability | None = None, + extensions: dict[str, dict[str, Any]] | None = None, + result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None, + notification_bindings: Sequence[NotificationBinding[Any]] | None = None, + dispatcher: Dispatcher[Any] | None = None, + ) -> None: + self._session_read_timeout_seconds = read_timeout_seconds + self._client_info = client_info or DEFAULT_CLIENT_INFO + self._sampling_callback = sampling_callback or _default_sampling_callback + self._sampling_capabilities = sampling_capabilities + self._extensions = dict(extensions) if extensions is not None else None + self._result_claims = _index_claims(result_claims, extensions) + self._notification_bindings = _index_bindings(notification_bindings) + self._active_claims: dict[str, ResultClaim[Any]] = {} + self._call_tool_adapter = _CallToolResultAdapter + self._binding_queues: dict[ + str, tuple[MemoryObjectSendStream[BaseModel], MemoryObjectReceiveStream[BaseModel]] + ] = {} + self._elicitation_callback = elicitation_callback or _default_elicitation_callback + self._list_roots_callback = list_roots_callback or _default_list_roots_callback + self._logging_callback = logging_callback or _default_logging_callback + self._log_level: types.LoggingLevel | None = log_level + self._message_handler = message_handler or _default_message_handler + self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} + # Compiled output-schema validators, derived from `_tool_output_schemas` and owned by + # `_absorb_tool_listing`, which evicts a tool's entry whenever its schema changes. + self._tool_output_validators: dict[str, Validator] = {} + self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {} + self._initialize_result: types.InitializeResult | None = None + self._discover_result: types.DiscoverResult | None = None + self._discover_server_info: types.Implementation | None = None + self._negotiated_version: str | None = None + self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp + self._task_group: anyio.abc.TaskGroup | None = None + # subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered) + self._listen_routes: dict[RequestId, ListenRoute] = {} + if dispatcher is not None: + if read_stream is not None or write_stream is not None: + raise ValueError("pass read_stream/write_stream or dispatcher, not both") + self._dispatcher: Dispatcher[Any] = dispatcher + if isinstance(dispatcher, JSONRPCDispatcher) and dispatcher.on_stream_exception is None: + # Route transport-level Exception items into message_handler โ€” only + # stream-backed dispatchers carry these; DirectDispatcher has none. + # Don't clobber a caller-supplied hook. + # TODO(L78): this leaves a bound-method ref on the dispatcher after the + # session exits (memory pin) and a second wrap of the same dispatcher would + # skip install. The Transport-as-Dispatcher rework (L77) removes this seam. + dispatcher.on_stream_exception = self._on_stream_exception + else: + if read_stream is None or write_stream is None: + raise ValueError("read_stream and write_stream are required when no dispatcher is given") + # Built eagerly so notifications can be sent before entering the context manager. + self._dispatcher = JSONRPCDispatcher( + read_stream, write_stream, on_stream_exception=self._on_stream_exception + ) + + async def __aenter__(self) -> Self: + self._task_group = anyio.create_task_group() + await self._task_group.__aenter__() + try: + # Queues must exist before the dispatcher starts: _on_notify enqueues into this dict. + for binding in self._notification_bindings.values(): + send, receive = anyio.create_memory_object_stream[BaseModel](_NOTIFICATION_QUEUE_SIZE) + self._binding_queues[binding.method] = (send, receive) + await self._task_group.start( + self._dispatcher.run, self._on_request, self._on_notify, self._intercept_notification + ) + for binding in self._notification_bindings.values(): + _, receive = self._binding_queues[binding.method] + self._task_group.start_soon(self._deliver_bound_notifications, binding, receive) + except BaseException: + # Unwind the entered task group before propagating: a cancellation + # landing here (e.g. `move_on_after` around connect) would abandon + # it and anyio would later raise "exited non-innermost cancel scope". + task_group = self._task_group + self._task_group = None + task_group.cancel_scope.cancel() + # Shield the group's own scope (a new one would break LIFO exit) + # so a pending outer cancellation cannot re-fire inside __aexit__. + task_group.cancel_scope.shield = True + try: + await task_group.__aexit__(None, None, None) + finally: + self._close_binding_queues() + raise + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + # Exit must not block: cancel the dispatcher, binding consumers, and in-flight callbacks. + assert self._task_group is not None + self._task_group.cancel_scope.cancel() + try: + result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb) + finally: + self._close_binding_queues() + self._settle_listen_routes_closed() + await resync_tracer() + return result + + def _close_binding_queues(self) -> None: + # Unclosed memory object streams warn at garbage collection; close is idempotent. + for send, receive in self._binding_queues.values(): + send.close() + receive.close() + self._binding_queues.clear() + + async def _deliver_bound_notifications( + self, binding: NotificationBinding[Any], receive: MemoryObjectReceiveStream[BaseModel] + ) -> None: + """Consume one binding's FIFO, decoupled from the dispatcher so handlers can do session I/O.""" + while True: + params = await receive.receive() + try: + await binding.handler(params) + except Exception: + # A raising handler costs only that delivery, as in _on_notify. + logger.exception("notification binding handler for %r raised", binding.method) + + async def send_request( + self, + request: types.ClientRequest | types.Request[Any, Any], + result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT], + request_read_timeout_seconds: float | None = None, + metadata: ClientMessageMetadata | None = None, + progress_callback: ProgressFnT | None = None, + ) -> ReceiveResultT: + """Send a request and wait for its typed result. + + Args: + metadata: Streamable HTTP resumption hints. + + Raises: + MCPError: Error response, read timeout, or connection closed. + RuntimeError: Called before entering the context manager. + ValueError: The request declares `name_param` but its params carry no string name. + pydantic.ValidationError: The server returned a result that does not + conform to the negotiated protocol version. + """ + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + method: str = data["method"] + opts: CallOptions = {} + self._stamp(data, opts) + # The stamp runs first, so its NAME_BEARING_METHODS rows win; a missing name fails loud. + headers = opts.setdefault("headers", {}) + if (key := type(request).name_param) is not None and MCP_NAME_HEADER not in headers: + params_data: dict[str, Any] = data.get("params") or {} + name = params_data.get(key) + if not isinstance(name, str): + raise ValueError(f"{method} requires params[{key!r}] for Mcp-Name") + headers[MCP_NAME_HEADER] = encode_header_value(name) + timeout = ( + request_read_timeout_seconds + if request_read_timeout_seconds is not None + else self._session_read_timeout_seconds + ) + if timeout is not None: + opts["timeout"] = timeout + if progress_callback is not None: + opts["on_progress"] = progress_callback + if metadata is not None: + if metadata.resumption_token is not None: + opts["resumption_token"] = metadata.resumption_token + if metadata.on_resumption_token_update is not None: + opts["on_resumption_token"] = metadata.on_resumption_token_update + raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts) + _clamp_inbound_ttl(raw) + # Literal fallback covers pre-handshake and stateless; matches runner.py. + version = self._negotiated_version or "2025-11-25" + try: + _methods.validate_server_result(method, version, raw) + except KeyError: + pass + if isinstance(result_type, TypeAdapter): + return result_type.validate_python(raw, by_name=False) + return result_type.model_validate(raw, by_name=False) + + async def send_notification(self, notification: types.ClientNotification) -> None: + """Send a one-way notification. Usable before entering the context manager. + + Fire-and-forget: after the connection has closed, the notification is + dropped with a debug log instead of raising. + """ + data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {} + self._stamp(data, opts) + await self._dispatcher.notify(data["method"], data.get("params"), opts) + + def _build_capabilities(self, version: str) -> types.ClientCapabilities: + """Build the capability ad for a wire speaking `version`. + + Claim-bearing identifiers whose claims are all inactive at `version` drop, so + the client never advertises result shapes it would reject; claim-less + identifiers always advertise. + """ + extensions = self._extensions + if extensions is not None and self._result_claims: + extensions = { + identifier: settings + for identifier, settings in extensions.items() + if identifier not in self._result_claims + or any(_claim_active(claim, version) for claim in self._result_claims[identifier]) + } or None + sampling = ( + (self._sampling_capabilities or types.SamplingCapability()) + if self._sampling_callback is not _default_sampling_callback + else None + ) + elicitation = ( + types.ElicitationCapability(form=types.FormElicitationCapability(), url=types.UrlElicitationCapability()) + if self._elicitation_callback is not _default_elicitation_callback + else None + ) + roots = ( + # TODO: Should this be based on whether we + # _will_ send notifications, or only whether + # they're supported? + types.RootsCapability(list_changed=True) + if self._list_roots_callback is not _default_list_roots_callback + else None + ) + return types.ClientCapabilities( + sampling=sampling, elicitation=elicitation, experimental=None, extensions=extensions, roots=roots + ) + + async def initialize(self) -> types.InitializeResult: + if self._initialize_result is not None: + return self._initialize_result + result = await self.send_request( + types.InitializeRequest( + params=types.InitializeRequestParams( + protocol_version=LATEST_HANDSHAKE_VERSION, + # The handshake negotiates only legacy versions, where no claim is active. + capabilities=self._build_capabilities(LATEST_HANDSHAKE_VERSION), + client_info=self._client_info, + ), + ), + types.InitializeResult, + ) + + if result.protocol_version not in HANDSHAKE_PROTOCOL_VERSIONS: + raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}") + + self.adopt(result) + + await self.send_notification(types.InitializedNotification()) + + return result + + def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None: + """Install negotiated state from a result the caller already holds (no wire traffic). + + Clears the opposite slot, so at most one of `initialize_result` / + `discover_result` is ever non-None. + + Raises: + RuntimeError: `result` is a `DiscoverResult` whose `supported_versions` + shares nothing with this client's `MODERN_PROTOCOL_VERSIONS`. + """ + if isinstance(result, types.DiscoverResult): + # ordered oldestโ†’newest via MODERN_PROTOCOL_VERSIONS + mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in result.supported_versions] + if not mutual: + raise RuntimeError( + f"No mutually supported modern protocol version " + f"(server: {result.supported_versions}, client: {list(MODERN_PROTOCOL_VERSIONS)})" + ) + version = mutual[-1] + client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True) + capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True) + self._stamp = _make_modern_stamp( + version, client_info, capabilities, self._resolve_param_headers, log_level=self._log_level + ) + self._discover_result = result + self._discover_server_info = _parse_server_info_stamp(result) + self._initialize_result = None + else: + version = result.protocol_version + self._stamp = _make_handshake_stamp(version) + self._initialize_result = result + self._discover_result = None + self._discover_server_info = None + self._negotiated_version = version + # Both arms reach here, so re-adoption resets cleanly; legacy versions activate no claims. + # Core-vocabulary tags are unconstructible (ResultClaim.__post_init__), so no exclusion needed. + self._active_claims = _active_claims_at(self._result_claims, version) + self._call_tool_adapter = _build_call_tool_adapter(self._active_claims) + for method in self._notification_bindings: + # Bindings are consulted only for methods core does not know, so this one can never fire. + if (method, version) in _methods.SERVER_NOTIFICATIONS: + logger.warning( + "notification binding for %r will never fire at %s: the core protocol defines this method", + method, + version, + ) + + async def send_discover(self, version: str) -> dict[str, Any]: + """Send a single ``server/discover`` at ``version`` and return the raw result dict. + + No retry, no ``adopt()``. The ``_meta`` envelope and the + ``Mcp-Protocol-Version`` header are stamped at ``version`` so the + server-side era router sees a coherent probe. Used by ``discover()`` and + the connect-time auto-negotiation policy. + + Raises: + MCPError: The server returned a JSON-RPC error, or the transport + bounced the request at its own layer (a bare HTTP 4xx is + synthesized into a JSON-RPC error by the transport). + """ + client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True) + capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True) + request = types.DiscoverRequest( + params=types.RequestParams( + _meta={ + PROTOCOL_VERSION_META_KEY: version, + CLIENT_INFO_META_KEY: client_info, + CLIENT_CAPABILITIES_META_KEY: capabilities, + } + ) + ) + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = { + "timeout": DISCOVER_TIMEOUT_SECONDS, + "cancel_on_abandon": False, + "headers": {MCP_PROTOCOL_VERSION_HEADER: version, MCP_METHOD_HEADER: data["method"]}, + } + raw = await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts) + # Un-floored, a negative ttl fails the mode='auto' probe's validation and silently downgrades the handshake. + _clamp_inbound_ttl(raw) + return raw + + async def discover(self) -> types.DiscoverResult: + """Probe `server/discover` and adopt the result. + + Sends a single `server/discover` proposing the newest modern protocol + version. On `UNSUPPORTED_PROTOCOL_VERSION` (-32022) the server's + `supported` list is intersected with `MODERN_PROTOCOL_VERSIONS` and the + probe is retried once at the highest mutual version. Any other error โ€” + including `METHOD_NOT_FOUND` (-32601) and `REQUEST_TIMEOUT` (-32001) โ€” + propagates; the legacy `initialize()` fallback is the caller's policy. + + Raises: + MCPError: The server rejected `server/discover`, the probe timed + out, or the -32022 retry found no mutual version / failed again. + RuntimeError: `adopt()` found no mutual version in the returned + `supported_versions`. + """ + if self._discover_result is not None: + return self._discover_result + + try: + raw = await self.send_discover(LATEST_MODERN_VERSION) + except MCPError as e: + if e.code != UNSUPPORTED_PROTOCOL_VERSION: + raise + try: + data = types.UnsupportedProtocolVersionErrorData.model_validate(e.error.data) + except ValidationError: + raise e from None + # ordered oldestโ†’newest via MODERN_PROTOCOL_VERSIONS + mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in data.supported] + if not mutual: + raise + raw = await self.send_discover(mutual[-1]) + + result = types.DiscoverResult.model_validate(raw) + self.adopt(result) + return result + + @property + def initialize_result(self) -> types.InitializeResult | None: + """The server's InitializeResult. None unless `initialize()` ran (or was adopted).""" + return self._initialize_result + + @property + def discover_result(self) -> types.DiscoverResult | None: + """The server's DiscoverResult. None unless `discover()` ran (or was adopted). + + Retained intact (supported_versions, ttl_ms, cache_scope) so callers + can round-trip it as ``prior_discover=``. + """ + return self._discover_result + + @property + def protocol_version(self) -> str | None: + """Negotiated protocol version. None until `initialize()`, `discover()`, or `adopt()`.""" + return self._negotiated_version + + @property + def server_info(self) -> types.Implementation | None: + """Server name/version. None until `initialize()`, `discover()`, or `adopt()`. + + On 2026-era connections this is the discover result's optional `_meta` + `serverInfo` stamp, parsed once at adopt time; `None` when the server + did not identify itself. The stamp is display-only per the spec, so a + malformed value reads as absent rather than failing the connection. + """ + if self._discover_result is not None: + return self._discover_server_info + if self._initialize_result is not None: + return self._initialize_result.server_info + return None + + @property + def server_capabilities(self) -> types.ServerCapabilities | None: + """Server capabilities. None until `initialize()`, `discover()`, or `adopt()`.""" + if self._discover_result is not None: + return self._discover_result.capabilities + if self._initialize_result is not None: + return self._initialize_result.capabilities + return None + + @property + def instructions(self) -> str | None: + """Server-provided instructions text, if any.""" + if self._discover_result is not None: + return self._discover_result.instructions + if self._initialize_result is not None: + return self._initialize_result.instructions + return None + + async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: + """Send a ping request.""" + return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult) + + @deprecated( + "Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.", + category=MCPDeprecationWarning, + ) + async def send_progress_notification( + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, + *, + meta: RequestParamsMeta | None = None, + ) -> None: + """Send a progress notification.""" + await self.send_notification( + types.ProgressNotification( + params=types.ProgressNotificationParams( + progress_token=progress_token, + progress=progress, + total=total, + message=message, + _meta=meta, + ), + ) + ) + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def set_logging_level( + self, + level: types.LoggingLevel, + *, + meta: RequestParamsMeta | None = None, + ) -> types.EmptyResult: + """Send a logging/setLevel request.""" + return await self.send_request( + types.SetLevelRequest(params=types.SetLevelRequestParams(level=level, _meta=meta)), + types.EmptyResult, + ) + + async def list_resources(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListResourcesResult: + """Send a resources/list request. + + Args: + params: Full pagination parameters including cursor and any future fields + """ + return await self.send_request(types.ListResourcesRequest(params=params), types.ListResourcesResult) + + async def list_resource_templates( + self, *, params: types.PaginatedRequestParams | None = None + ) -> types.ListResourceTemplatesResult: + """Send a resources/templates/list request. + + Args: + params: Full pagination parameters including cursor and any future fields + """ + return await self.send_request( + types.ListResourceTemplatesRequest(params=params), + types.ListResourceTemplatesResult, + ) + + @overload + async def read_resource( + self, + uri: str, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: Literal[False] = False, + ) -> types.ReadResourceResult: ... + + @overload + async def read_resource( + self, + uri: str, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool, + ) -> types.ReadResourceResult | types.InputRequiredResult: ... + + async def read_resource( + self, + uri: str, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool = False, + ) -> types.ReadResourceResult | types.InputRequiredResult: + """Send a resources/read request. + + Args: + input_responses: Responses to a prior `InputRequiredResult.input_requests`. + request_state: Opaque state echoed from a prior `InputRequiredResult`. + allow_input_required: When `False` (default), an `InputRequiredResult` + from the server raises `RuntimeError`; when `True`, it is returned + so the caller can resolve the requests and retry. + + Raises: + RuntimeError: If the server returns an `InputRequiredResult` and + `allow_input_required` is `False`. + """ + result = await self.send_request( + types.ReadResourceRequest( + params=types.ReadResourceRequestParams( + uri=uri, + input_responses=input_responses, + request_state=request_state, + _meta=meta, + ), + ), + _ReadResourceResultAdapter, + ) + if isinstance(result, types.InputRequiredResult) and not allow_input_required: + raise _input_required_unexpected("read_resource") + return result + + @deprecated( + "resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) + async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: + """Send a resources/subscribe request (2025-era servers only).""" + return await self.send_request( + types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)), + types.EmptyResult, + ) + + @deprecated( + "resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) + async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: + """Send a resources/unsubscribe request (2025-era servers only).""" + return await self.send_request( + types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)), + types.EmptyResult, + ) + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: Literal[False] = False, + allow_claimed: Literal[False] = False, + ) -> types.CallToolResult: ... + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool, + allow_claimed: Literal[False] = False, + ) -> types.CallToolResult | types.InputRequiredResult: ... + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: Literal[False] = False, + allow_claimed: bool, + ) -> types.CallToolResult | types.Result: ... + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool, + allow_claimed: bool, + ) -> types.CallToolResult | types.InputRequiredResult | types.Result: ... + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool = False, + allow_claimed: bool = False, + ) -> types.CallToolResult | types.InputRequiredResult | types.Result: + """Send a tools/call request with optional progress callback support. + + On a modern (2026-07-28) connection, arguments annotated with `x-mcp-header` + in the tool's input schema are mirrored into `Mcp-Param-*` request headers. + The annotations are read from the tool's last `list_tools` entry, so list + the tool before calling it to enable header emission. + + Args: + input_responses: Responses to a prior `InputRequiredResult.input_requests`. + request_state: Opaque state echoed from a prior `InputRequiredResult`. + allow_input_required: When ``False`` (default), an `InputRequiredResult` + from the server raises `RuntimeError`; when ``True``, it is returned + so the caller can resolve the requests and retry. + allow_claimed: When `False` (default), a claimed extension result raises + `UnexpectedClaimedResult`; when `True`, the parsed claim model is returned. + + Raises: + RuntimeError: If the server returns an `InputRequiredResult` and + ``allow_input_required`` is ``False``. + UnexpectedClaimedResult: Claimed result with `allow_claimed` False; carries the parsed value. + """ + result = await self.send_request( + types.CallToolRequest( + params=types.CallToolRequestParams( + name=name, + arguments=arguments, + input_responses=input_responses, + request_state=request_state, + _meta=meta, + ), + ), + self._call_tool_adapter, + request_read_timeout_seconds=read_timeout_seconds, + progress_callback=progress_callback, + ) + + if isinstance(result, types.CallToolResult) and not result.is_error: + await self.validate_tool_result(name, result) + + # The input_required arm stays first; a claimed shape is terminal for the multi-round-trip driver. + if isinstance(result, types.InputRequiredResult) and not allow_input_required: + raise _input_required_unexpected("call_tool") + if not isinstance(result, types.CallToolResult | types.InputRequiredResult) and not allow_claimed: + raise UnexpectedClaimedResult(result) + return result + + def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]: + """`Mcp-Param-*` headers for a `tools/call`, or empty when the tool was never listed.""" + header_map = self._x_mcp_header_maps.get(name) + if header_map is None: + return {} + return mcp_param_headers(header_map, arguments) + + async def validate_tool_result(self, name: str, result: types.CallToolResult) -> None: + """Revalidate a `CallToolResult` against the tool's declared output schema. + + Raises: + RuntimeError: Structured content is missing or does not conform to the schema. + """ + if name not in self._tool_output_schemas: + # refresh output schema cache + await self.list_tools() + + output_schema = None + if name in self._tool_output_schemas: + output_schema = self._tool_output_schemas.get(name) + else: + logger.warning(f"Tool {name} not listed by server, cannot validate any structured content") + + if output_schema is not None: + from jsonschema import exceptions as jsonschema_exceptions + + if result.structured_content is None: + raise RuntimeError(f"Tool {name} has an output schema but did not return structured content") + validator = self._output_schema_validator(name, output_schema) + # `best_match` picks the same error the previous `jsonschema.validate()` call raised, + # so the message a caller sees is unchanged. It is untyped upstream. + errors = validator.iter_errors(result.structured_content) + error = cast( + "Exception | None", + jsonschema_exceptions.best_match(errors), # pyright: ignore[reportUnknownMemberType] + ) + if error is not None: + raise RuntimeError(f"Invalid structured content returned by tool {name}: {error}") from error + + def _output_schema_validator(self, name: str, output_schema: dict[str, Any]) -> Validator: + """Compiled validator for the tool's cached output schema, built once per schema value. + + Compiling is ~60x the cost of validating, so a one-shot `jsonschema.validate()` per + result dominates `call_tool`; the compiled validator is cached instead. It stays valid + because `_absorb_tool_listing` evicts a tool's validator whenever it absorbs a different + schema for that tool, so a cached entry always matches `output_schema`. + + Raises: + RuntimeError: The schema is not a valid JSON Schema. Raised on every call, since a + failed compile is never cached. + """ + from jsonschema import SchemaError + from jsonschema.validators import validator_for + + if (validator := self._tool_output_validators.get(name)) is not None: + return validator + + validator_cls = validator_for(output_schema) + try: + validator_cls.check_schema(output_schema) + except SchemaError as e: + raise RuntimeError(f"Invalid schema for tool {name}: {e}") + # jsonschema ships no `py.typed`, so pyright reads typeshed's stub, which declares + # `registry` as required (concrete validators default it); cast to a schema-only ctor. + validator = cast("Callable[[dict[str, Any]], Validator]", validator_cls)(output_schema) + self._tool_output_validators[name] = validator + return validator + + async def list_prompts(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListPromptsResult: + """Send a prompts/list request. + + Args: + params: Full pagination parameters including cursor and any future fields + """ + return await self.send_request(types.ListPromptsRequest(params=params), types.ListPromptsResult) + + @overload + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: Literal[False] = False, + ) -> types.GetPromptResult: ... + + @overload + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool, + ) -> types.GetPromptResult | types.InputRequiredResult: ... + + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool = False, + ) -> types.GetPromptResult | types.InputRequiredResult: + """Send a prompts/get request. + + Args: + input_responses: Responses to a prior `InputRequiredResult.input_requests`. + request_state: Opaque state echoed from a prior `InputRequiredResult`. + allow_input_required: When `False` (default), an `InputRequiredResult` + from the server raises `RuntimeError`; when `True`, it is returned + so the caller can resolve the requests and retry. + + Raises: + RuntimeError: If the server returns an `InputRequiredResult` and + `allow_input_required` is `False`. + """ + result = await self.send_request( + types.GetPromptRequest( + params=types.GetPromptRequestParams( + name=name, + arguments=arguments, + input_responses=input_responses, + request_state=request_state, + _meta=meta, + ), + ), + _GetPromptResultAdapter, + ) + if isinstance(result, types.InputRequiredResult) and not allow_input_required: + raise _input_required_unexpected("get_prompt") + return result + + async def complete( + self, + ref: types.ResourceTemplateReference | types.PromptReference, + argument: dict[str, str], + context_arguments: dict[str, str] | None = None, + ) -> types.CompleteResult: + """Send a completion/complete request.""" + context = None + if context_arguments is not None: + context = types.CompletionContext(arguments=context_arguments) + + return await self.send_request( + types.CompleteRequest( + params=types.CompleteRequestParams( + ref=ref, + argument=types.CompletionArgument(**argument), + context=context, + ), + ), + types.CompleteResult, + ) + + async def list_tools(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListToolsResult: + """Send a tools/list request. + + Args: + params: Full pagination parameters including cursor and any future fields + """ + result = await self.send_request( + types.ListToolsRequest(params=params), + types.ListToolsResult, + ) + complete = (params is None or params.cursor is None) and result.next_cursor is None + return self._absorb_tool_listing(result, complete=complete) + + def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool) -> types.ListToolsResult: + """Filter the listing per the 2026 x-mcp-header MUST and rebuild derived per-tool state, in place. + + Idempotent: cached values are already post-filter, so the response cache can re-absorb a served listing. + `complete` (an uncursored single-page listing) prunes per-tool state down to the listing's tools. + """ + if self._negotiated_version in MODERN_PROTOCOL_VERSIONS: + # 2026-07-28: clients MUST drop tools whose x-mcp-header annotations are invalid. + kept: list[types.Tool] = [] + for tool in result.tools: + if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None: + logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason) + # Evict any map cached from a prior valid listing so a stale entry can't + # mirror headers for a tool this listing dropped. + self._x_mcp_header_maps.pop(tool.name, None) + continue + # Cache the argโ†’header map so a later tools/call mirrors it into Mcp-Param-* headers. + self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema) + kept.append(tool) + result.tools = kept + + # Cache tool output schemas for future validation; cursor pages only ever add. A + # changed schema evicts its compiled validator; an unchanged one (a re-listing, or the + # response cache re-absorbing a served hit) keeps it. Only validated tools pay the check. + for tool in result.tools: + if tool.name in self._tool_output_validators and not _same_schema( + self._tool_output_schemas.get(tool.name), tool.output_schema + ): + del self._tool_output_validators[tool.name] + self._tool_output_schemas[tool.name] = tool.output_schema + + if complete: + # The listing is the full tool universe, so state for unlisted tools is stale + # (the server dropped them, or a shared-cache writer's filter did). + names = {tool.name for tool in result.tools} + self._x_mcp_header_maps = {k: v for k, v in self._x_mcp_header_maps.items() if k in names} + self._tool_output_schemas = {k: v for k, v in self._tool_output_schemas.items() if k in names} + self._tool_output_validators = {k: v for k, v in self._tool_output_validators.items() if k in names} + + return result + + @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def send_roots_list_changed(self) -> None: + """Send a roots/list_changed notification.""" + await self.send_notification(types.RootsListChangedNotification()) + + async def _on_request( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + """Answer a server-initiated request via the registered callbacks.""" + # Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize + # handshake (which only exists at <=2025) and stateless until the header + # is plumbed; its meaning is fixed regardless of LATEST bumps. + version = self._negotiated_version or "2025-11-25" + try: + request = cast(types.ServerRequest, _methods.parse_server_request(method, version, params)) + except KeyError: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None + + response: types.ClientResult | types.ErrorData + if isinstance(request, types.PingRequest): + # Answered without a context: ping has no callback that would need one. + response = types.EmptyResult() + else: + assert dctx.request_id is not None # the callback-driving dispatchers always assign ids + ctx = ClientRequestContext( + session=self, request_id=dctx.request_id, meta=request.params.meta if request.params else None + ) + response = await self.dispatch_input_request(ctx, request) + client_response = ClientResponse.validate_python(response) + if isinstance(client_response, types.ErrorData): + raise MCPError.from_error_data(client_response) + dumped = client_response.model_dump(by_alias=True, mode="json", exclude_none=True) + try: + _methods.validate_client_result(method, version, dumped) + except ValidationError: + logger.exception("client callback for %r returned an invalid result", method) + raise MCPError(code=INTERNAL_ERROR, message="Client callback returned an invalid result") from None + return dumped + + async def dispatch_input_request( + self, ctx: ClientRequestContext, request: types.InputRequest + ) -> types.InputResponse | types.ErrorData: + """Route an input request through the client's callback table. + + Shared by the legacy serverโ†’client RPC path (`_on_request`) and the + 2026-07-28 multi-round-trip driver, which dispatches the embedded + `InputRequiredResult.input_requests` through the same callbacks. + + Returns the callback's `InputResponse`, or `ErrorData` when the callback declines. + """ + match request: + case types.CreateMessageRequest(params=p): + return await self._sampling_callback(ctx, p) + case types.ElicitRequest(params=p): + return await self._elicitation_callback(ctx, p) + case types.ListRootsRequest(): # pragma: no branch + return await self._list_roots_callback(ctx) + + def _register_listen_route(self, request_id: RequestId) -> ListenRoute: + """Create the demux route for a listen request id; the caller registers BEFORE sending.""" + route = ListenRoute() + self._listen_routes[request_id] = route + return route + + def _unregister_listen_route(self, request_id: RequestId) -> None: + """Drop a listen route; the handle owns membership, so a missing key is a no-op.""" + self._listen_routes.pop(request_id, None) + + def _settle_listen_routes_closed(self) -> None: + """Settle all open listen routes as lost on session exit; cancelled driver tasks cannot.""" + closed = MCPError(code=CONNECTION_CLOSED, message="Connection closed") + for route in self._listen_routes.values(): + route.settle("lost", error=closed) + self._listen_routes.clear() + + def _intercept_notification(self, method: str, params: Mapping[str, Any] | None) -> bool: + """Wire-order listen demux, run synchronously on the dispatcher's receive path. + + Bookkeeping must advance in receive order with the listen result (resolved on + this same path); the spawned `_on_notify` path would race it and drop events. + Returns True to consume the frame: a live route's ack is driver state, never surfaced. + """ + if not self._listen_routes: + return False + if method == "notifications/cancelled": + request_id = cancelled_request_id_from_params(params) + if request_id is not None and (listen_route := self._listen_routes.get(request_id)) is not None: + # a server-sent cancel naming a listen request is that stream's teardown signal + listen_route.settle("lost") + return False # _on_notify swallows every cancelled either way (v1 parity) + if params is None: + return False + meta = params.get("_meta") + if not isinstance(meta, Mapping): + return False + # as_request_id is not a tripwire: raw wire _meta can carry a non-id (even unhashable) value + subscription_id = as_request_id(cast("Mapping[str, Any]", meta).get(SUBSCRIPTION_ID_META_KEY)) + if subscription_id is None or (listen_route := self._listen_routes.get(subscription_id)) is None: + return False + if method == "notifications/subscriptions/acknowledged": + raw_filter = params.get("notifications") + if raw_filter is None: + # malformed, not an empty filter: leave it to the spawned path's validation warning + return False + try: + honored = types.SubscriptionFilter.model_validate(raw_filter) + except ValidationError: + return False + listen_route.set_acked(honored) + return True + if (event := event_from_wire(method, params)) is not None: + listen_route.deliver(event) + return False # events (and any other stamped frame) still tee as usual + + async def _on_notify( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> None: + """Route a server notification: validate, run the typed callback, tee to message_handler.""" + # Same fallback as `_on_request`: covers pre-handshake and stateless. + version = self._negotiated_version or "2025-11-25" + try: + notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params)) + except KeyError: + # Only methods unknown to the negotiated version's core tables reach the bindings. + binding = self._notification_bindings.get(method) + if binding is None: + logger.debug("dropped %r: not defined at %s", method, version) + return + try: + bound_params = binding.params_type.model_validate(params or {}) + except ValidationError: + logger.warning("Failed to validate notification: %s", method, exc_info=True) + return + send, receive = self._binding_queues[method] + try: + # Must not await: DirectDispatcher calls _on_notify inline; blocking deadlocks in-process servers. + send.send_nowait(bound_params) + except anyio.WouldBlock: + # Evict the oldest event; no checkpoint since the failed send, + # so the buffer is still full and the retry cannot block. + receive.receive_nowait() + logger.warning("notification queue for %r is full; dropped the oldest event", method) + send.send_nowait(bound_params) + return + except ValidationError: + logger.warning("Failed to validate notification: %s", method, exc_info=True) + return + if isinstance(notification, types.CancelledNotification): + # Never surfaced (v1 parity): the dispatcher already applied it; listen cancels settled by the intercept. + return + try: + if isinstance(notification, types.LoggingMessageNotification): + await self._logging_callback(notification.params) + await self._message_handler(notification) + except Exception: + # Contain here, not in the dispatcher: DirectDispatcher awaits this + # handler inline in the peer's notify() call, so a raising callback + # would otherwise fail the peer's send. A raising logging_callback + # skips the message_handler tee for that notification (v1 parity). + logger.exception("notification callback for %r raised", method) + + async def _on_stream_exception(self, exc: Exception) -> None: + """Deliver a transport-level fault to message_handler via a spawned task. + + Running the handler inline would park the dispatcher's read loop and + deadlock handlers that await session I/O. + """ + assert self._task_group is not None + self._task_group.start_soon(self._deliver_stream_exception, exc) + + async def _deliver_stream_exception(self, exc: Exception) -> None: + try: + await self._message_handler(exc) + except Exception: + logger.exception("message_handler raised on transport exception") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/session_group.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/session_group.py new file mode 100644 index 0000000000000000000000000000000000000000..a544cecbe8cc0c7b89c85ab59cea1cee781b8c2a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/session_group.py @@ -0,0 +1,449 @@ +"""SessionGroup concurrently manages multiple MCP session connections. + +Tools, resources, and prompts are aggregated across servers. Servers may +be connected to or disconnected from at any point after initialization. + +This abstraction can handle naming collisions using a custom user-provided hook. +""" + +import contextlib +import logging +from collections.abc import Callable +from dataclasses import dataclass +from types import TracebackType +from typing import Any, Literal, TypeAlias, overload + +import anyio +import httpx2 +import mcp_types as types +from pydantic import BaseModel, Field +from typing_extensions import Self + +import mcp +from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT +from mcp.client.sse import sse_client +from mcp.client.stdio import StdioServerParameters +from mcp.client.streamable_http import streamable_http_client +from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared.dispatcher import ProgressFnT +from mcp.shared.exceptions import MCPError + + +class SseServerParameters(BaseModel): + """Parameters for initializing an sse_client.""" + + # The endpoint URL. + url: str + + # Optional headers to include in requests. + headers: dict[str, Any] | None = None + + # HTTP timeout for regular operations (in seconds). + timeout: float = 5.0 + + # Timeout for SSE read operations (in seconds). + sse_read_timeout: float = 300.0 + + +class StreamableHttpParameters(BaseModel): + """Parameters for initializing a streamable_http_client.""" + + # The endpoint URL. + url: str + + # Optional headers to include in requests. + headers: dict[str, Any] | None = None + + # HTTP timeout for regular operations (in seconds). + timeout: float = 30.0 + + # Timeout for SSE read operations (in seconds). + sse_read_timeout: float = 300.0 + + # Close the client session when the transport closes. + terminate_on_close: bool = True + + +ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters + + +# Use dataclass instead of Pydantic BaseModel +# because Pydantic BaseModel cannot handle Protocol fields. +@dataclass +class ClientSessionParameters: + """Parameters for establishing a client session to an MCP server.""" + + read_timeout_seconds: float | None = None + sampling_callback: SamplingFnT | None = None + elicitation_callback: ElicitationFnT | None = None + list_roots_callback: ListRootsFnT | None = None + logging_callback: LoggingFnT | None = None + message_handler: MessageHandlerFnT | None = None + client_info: types.Implementation | None = None + + +class ClientSessionGroup: + """Client for managing connections to multiple MCP servers. + + This class is responsible for encapsulating management of server connections. + It aggregates tools, resources, and prompts from all connected servers. + + For auxiliary handlers, such as resource subscription, this is delegated to + the client and can be accessed via the session. + + Example: + ```python + name_fn = lambda name, server_info: f"{(server_info.name)}_{name}" + async with ClientSessionGroup(component_name_hook=name_fn) as group: + for server_param in server_params: + await group.connect_to_server(server_param) + ... + ``` + """ + + class _ComponentNames(BaseModel): + """Used for reverse index to find components.""" + + prompts: set[str] = Field(default_factory=set) + resources: set[str] = Field(default_factory=set) + tools: set[str] = Field(default_factory=set) + + # Standard MCP components. + _prompts: dict[str, types.Prompt] + _resources: dict[str, types.Resource] + _tools: dict[str, types.Tool] + + # Client-server connection management. + _sessions: dict[mcp.ClientSession, _ComponentNames] + _tool_to_session: dict[str, mcp.ClientSession] + _exit_stack: contextlib.AsyncExitStack + _session_exit_stacks: dict[mcp.ClientSession, contextlib.AsyncExitStack] + + # Optional fn consuming (component_name, server_info) for custom names. + # This is to provide a means to mitigate naming conflicts across servers. + # Example: (tool_name, server_info) => "{result.server_info.name}.{tool_name}" + _ComponentNameHook: TypeAlias = Callable[[str, types.Implementation], str] + _component_name_hook: _ComponentNameHook | None + + def __init__( + self, + exit_stack: contextlib.AsyncExitStack | None = None, + component_name_hook: _ComponentNameHook | None = None, + ) -> None: + """Initializes the MCP client.""" + + self._tools = {} + self._resources = {} + self._prompts = {} + + self._sessions = {} + self._tool_to_session = {} + if exit_stack is None: + self._exit_stack = contextlib.AsyncExitStack() + self._owns_exit_stack = True + else: + self._exit_stack = exit_stack + self._owns_exit_stack = False + self._session_exit_stacks = {} + self._component_name_hook = component_name_hook + + async def __aenter__(self) -> Self: # pragma: no cover + # Enter the exit stack only if we created it ourselves + if self._owns_exit_stack: + await self._exit_stack.__aenter__() + return self + + async def __aexit__( + self, + _exc_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: TracebackType | None, + ) -> bool | None: # pragma: no cover + """Closes session exit stacks and main exit stack upon completion.""" + + # Only close the main exit stack if we created it + if self._owns_exit_stack: + await self._exit_stack.aclose() + + # Concurrently close session stacks. + async with anyio.create_task_group() as tg: + for exit_stack in self._session_exit_stacks.values(): + tg.start_soon(exit_stack.aclose) + + @property + def sessions(self) -> list[mcp.ClientSession]: + """Returns the list of sessions being managed.""" + return list(self._sessions.keys()) # pragma: no cover + + @property + def prompts(self) -> dict[str, types.Prompt]: + """Returns the prompts as a dictionary of names to prompts.""" + return self._prompts + + @property + def resources(self) -> dict[str, types.Resource]: + """Returns the resources as a dictionary of names to resources.""" + return self._resources + + @property + def tools(self) -> dict[str, types.Tool]: + """Returns the tools as a dictionary of names to tools.""" + return self._tools + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: types.RequestParamsMeta | None = None, + allow_input_required: Literal[False] = False, + ) -> types.CallToolResult: ... + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: types.RequestParamsMeta | None = None, + allow_input_required: bool, + ) -> types.CallToolResult | types.InputRequiredResult: ... + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: types.RequestParamsMeta | None = None, + allow_input_required: bool = False, + ) -> types.CallToolResult | types.InputRequiredResult: + """Executes a tool given its name and arguments. + + Raises: + RuntimeError: If the server returns an `InputRequiredResult` and + ``allow_input_required`` is ``False``. + """ + session = self._tool_to_session[name] + session_tool_name = self.tools[name].name + return await session.call_tool( + session_tool_name, + arguments=arguments, + read_timeout_seconds=read_timeout_seconds, + progress_callback=progress_callback, + input_responses=input_responses, + request_state=request_state, + meta=meta, + allow_input_required=allow_input_required, + ) + + async def disconnect_from_server(self, session: mcp.ClientSession) -> None: + """Disconnects from a single MCP server.""" + + session_known_for_components = session in self._sessions + session_known_for_stack = session in self._session_exit_stacks + + if not session_known_for_components and not session_known_for_stack: + raise MCPError( + code=types.INVALID_PARAMS, + message="Provided session is not managed or already disconnected.", + ) + + if session_known_for_components: # pragma: no branch + component_names = self._sessions.pop(session) # Pop from _sessions tracking + + # Remove prompts associated with the session. + for name in component_names.prompts: + if name in self._prompts: # pragma: no branch + del self._prompts[name] + # Remove resources associated with the session. + for name in component_names.resources: + if name in self._resources: # pragma: no branch + del self._resources[name] + # Remove tools associated with the session. + for name in component_names.tools: + if name in self._tools: # pragma: no branch + del self._tools[name] + if name in self._tool_to_session: # pragma: no branch + del self._tool_to_session[name] + + # Clean up the session's resources via its dedicated exit stack + if session_known_for_stack: + session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: no cover + await session_stack_to_close.aclose() # pragma: no cover + + async def connect_with_session( + self, server_info: types.Implementation, session: mcp.ClientSession + ) -> mcp.ClientSession: + """Connects to a single MCP server.""" + await self._aggregate_components(server_info, session) + return session + + async def connect_to_server( + self, + server_params: ServerParameters, + session_params: ClientSessionParameters | None = None, + ) -> mcp.ClientSession: + """Connects to a single MCP server.""" + server_info, session = await self._establish_session(server_params, session_params or ClientSessionParameters()) + return await self.connect_with_session(server_info, session) + + async def _establish_session( + self, + server_params: ServerParameters, + session_params: ClientSessionParameters, + ) -> tuple[types.Implementation, mcp.ClientSession]: + """Establish a client session to an MCP server.""" + + session_stack = contextlib.AsyncExitStack() + try: + # Create read and write streams that facilitate io with the server. + if isinstance(server_params, StdioServerParameters): + client = mcp.stdio_client(server_params) + read, write = await session_stack.enter_async_context(client) + elif isinstance(server_params, SseServerParameters): + client = sse_client( + url=server_params.url, + headers=server_params.headers, + timeout=server_params.timeout, + sse_read_timeout=server_params.sse_read_timeout, + ) + read, write = await session_stack.enter_async_context(client) + else: + httpx_client = create_mcp_http_client( + headers=server_params.headers, + timeout=httpx2.Timeout( + server_params.timeout, + read=server_params.sse_read_timeout, + ), + ) + await session_stack.enter_async_context(httpx_client) + + client = streamable_http_client( + url=server_params.url, + http_client=httpx_client, + terminate_on_close=server_params.terminate_on_close, + ) + read, write = await session_stack.enter_async_context(client) + + session = await session_stack.enter_async_context( + mcp.ClientSession( + read, + write, + read_timeout_seconds=session_params.read_timeout_seconds, + sampling_callback=session_params.sampling_callback, + elicitation_callback=session_params.elicitation_callback, + list_roots_callback=session_params.list_roots_callback, + logging_callback=session_params.logging_callback, + message_handler=session_params.message_handler, + client_info=session_params.client_info, + ) + ) + + result = await session.initialize() + + # Session successfully initialized. + # Store its stack and register the stack with the main group stack. + self._session_exit_stacks[session] = session_stack + # session_stack itself becomes a resource managed by the + # main _exit_stack. + await self._exit_stack.enter_async_context(session_stack) + + return result.server_info, session + except Exception: # pragma: no cover + # If anything during this setup fails, ensure the session-specific + # stack is closed. + await session_stack.aclose() + raise + + async def _aggregate_components(self, server_info: types.Implementation, session: mcp.ClientSession) -> None: + """Aggregates prompts, resources, and tools from a given session.""" + + # Create a reverse index so we can find all prompts, resources, and + # tools belonging to this session. Used for removing components from + # the session group via self.disconnect_from_server. + component_names = self._ComponentNames() + + # Temporary components dicts. We do not want to modify the aggregate + # lists in case of an intermediate failure. + prompts_temp: dict[str, types.Prompt] = {} + resources_temp: dict[str, types.Resource] = {} + tools_temp: dict[str, types.Tool] = {} + tool_to_session_temp: dict[str, mcp.ClientSession] = {} + + # Query the server for its prompts and aggregate to list. + try: + prompts = (await session.list_prompts()).prompts + for prompt in prompts: + name = self._component_name(prompt.name, server_info) + prompts_temp[name] = prompt + component_names.prompts.add(name) + except MCPError as err: # pragma: no cover + logging.warning(f"Could not fetch prompts: {err}") + + # Query the server for its resources and aggregate to list. + try: + resources = (await session.list_resources()).resources + for resource in resources: + name = self._component_name(resource.name, server_info) + resources_temp[name] = resource + component_names.resources.add(name) + except MCPError as err: # pragma: no cover + logging.warning(f"Could not fetch resources: {err}") + + # Query the server for its tools and aggregate to list. + try: + tools = (await session.list_tools()).tools + for tool in tools: + name = self._component_name(tool.name, server_info) + tools_temp[name] = tool + tool_to_session_temp[name] = session + component_names.tools.add(name) + except MCPError as err: # pragma: no cover + logging.warning(f"Could not fetch tools: {err}") + + # Clean up exit stack for session if we couldn't retrieve anything + # from the server. + if not any((prompts_temp, resources_temp, tools_temp)): + del self._session_exit_stacks[session] # pragma: no cover + + # Check for duplicates. + matching_prompts = prompts_temp.keys() & self._prompts.keys() + if matching_prompts: + raise MCPError( # pragma: no cover + code=types.INVALID_PARAMS, + message=f"{matching_prompts} already exist in group prompts.", + ) + matching_resources = resources_temp.keys() & self._resources.keys() + if matching_resources: + raise MCPError( # pragma: no cover + code=types.INVALID_PARAMS, + message=f"{matching_resources} already exist in group resources.", + ) + matching_tools = tools_temp.keys() & self._tools.keys() + if matching_tools: + raise MCPError(code=types.INVALID_PARAMS, message=f"{matching_tools} already exist in group tools.") + + # Aggregate components. + self._sessions[session] = component_names + self._prompts.update(prompts_temp) + self._resources.update(resources_temp) + self._tools.update(tools_temp) + self._tool_to_session.update(tool_to_session_temp) + + def _component_name(self, name: str, server_info: types.Implementation) -> str: + if self._component_name_hook: + return self._component_name_hook(name, server_info) + return name diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/sse.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/sse.py new file mode 100644 index 0000000000000000000000000000000000000000..31d0f353915b1589ae7f152035f93e62db34900c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/sse.py @@ -0,0 +1,161 @@ +import logging +from collections.abc import Callable +from contextlib import asynccontextmanager +from typing import Any +from urllib.parse import parse_qs, urljoin, urlparse + +import anyio +import httpx2 +import mcp_types as types +from anyio.abc import TaskStatus +from httpx2 import SSEError + +from mcp.shared._compat import resync_tracer +from mcp.shared._context_streams import create_context_streams +from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client +from mcp.shared.message import SessionMessage + +logger = logging.getLogger(__name__) + + +def remove_request_params(url: str) -> str: + return urljoin(url, urlparse(url).path) + + +def _extract_session_id_from_endpoint(endpoint_url: str) -> str | None: + query_params = parse_qs(urlparse(endpoint_url).query) + return query_params.get("sessionId", [None])[0] or query_params.get("session_id", [None])[0] + + +@asynccontextmanager +async def sse_client( + url: str, + headers: dict[str, Any] | None = None, + timeout: float = 5.0, + sse_read_timeout: float = 300.0, + httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, + auth: httpx2.Auth | None = None, + on_session_created: Callable[[str], None] | None = None, +): + """Client transport for SSE. + + `sse_read_timeout` determines how long (in seconds) the client will wait for a new + event before disconnecting. All other HTTP operations are controlled by `timeout`. + + Args: + url: The SSE endpoint URL. + headers: Optional headers to include in requests. + timeout: HTTP timeout for regular operations (in seconds). + sse_read_timeout: Timeout for SSE read operations (in seconds). + httpx_client_factory: Factory function for creating the httpx2 client. + auth: Optional httpx2 authentication handler. + on_session_created: Optional callback invoked with the session ID when received. + """ + logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}") + async with httpx_client_factory( + headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout) + ) as client: + async with client.sse(url) as event_source: + event_source.response.raise_for_status() + logger.debug("SSE connection established") + + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) + + async def sse_reader(task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED): + try: + async for sse in event_source: # pragma: no branch + logger.debug(f"Received SSE event: {sse.event}") + match sse.event: + case "endpoint": + endpoint_url = urljoin(url, sse.data) + logger.debug(f"Received endpoint URL: {endpoint_url}") + + url_parsed = urlparse(url) + endpoint_parsed = urlparse(endpoint_url) + if ( # pragma: no cover + url_parsed.netloc != endpoint_parsed.netloc + or url_parsed.scheme != endpoint_parsed.scheme + ): + error_msg = ( # pragma: no cover + f"Endpoint origin does not match connection origin: {endpoint_url}" + ) + logger.error(error_msg) # pragma: no cover + raise ValueError(error_msg) # pragma: no cover + + if on_session_created: + session_id = _extract_session_id_from_endpoint(endpoint_url) + if session_id: + on_session_created(session_id) + + task_status.started(endpoint_url) + + case "message": + # Skip empty data (keep-alive pings) + if not sse.data: + continue + try: + message = types.jsonrpc_message_adapter.validate_json(sse.data, by_name=False) + logger.debug(f"Received server message: {message}") + except Exception as exc: # pragma: no cover + logger.exception("Error parsing server message") # pragma: no cover + await read_stream_writer.send(exc) # pragma: no cover + continue # pragma: no cover + + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + case _: # pragma: no cover + logger.warning(f"Unknown SSE event: {sse.event}") # pragma: no cover + except SSEError as sse_exc: # pragma: lax no cover + logger.exception("Encountered SSE exception") + raise sse_exc + except Exception as exc: # pragma: lax no cover + logger.exception("Error in sse_reader") + await read_stream_writer.send(exc) + finally: + await read_stream_writer.aclose() + + async def post_writer(endpoint_url: str): + try: + async with write_stream_reader, write_stream: + + async def _send_message(session_message: SessionMessage) -> None: + logger.debug(f"Sending client message: {session_message}") + response = await client.post( + endpoint_url, + json=session_message.message.model_dump( + by_alias=True, + mode="json", + exclude_unset=True, + ), + ) + response.raise_for_status() + logger.debug(f"Client message sent successfully: {response.status_code}") + + async for session_message in write_stream_reader: + sender_ctx = write_stream_reader.last_context + if sender_ctx is not None: + async with anyio.create_task_group() as tg: + sender_ctx.run(tg.start_soon, _send_message, session_message) + else: + await _send_message(session_message) # pragma: no cover + except Exception: # pragma: lax no cover + logger.exception("Error in post_writer") + + # On Python 3.14, coverage.py reports a phantom branch arc on this + # line (->yield) when nested two async-with levels deep. The branch + # is the unreachable "did __aexit__ suppress?" arm for memory streams. + async with ( # pragma: no branch + read_stream_writer, + read_stream, + write_stream, + write_stream_reader, + anyio.create_task_group() as tg, + ): + endpoint_url = await tg.start(sse_reader) + logger.debug(f"Starting post writer with endpoint URL: {endpoint_url}") + tg.start_soon(post_writer, endpoint_url) + + yield read_stream, write_stream + tg.cancel_scope.cancel() + await resync_tracer() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/stdio.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/stdio.py new file mode 100644 index 0000000000000000000000000000000000000000..3e03eef9efe45e3c7d9fa6e80fe9649a8c1a43c8 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/stdio.py @@ -0,0 +1,354 @@ +"""stdio client transport. + +Runs an MCP server as a subprocess and exchanges newline-delimited JSON-RPC +messages with it over stdin/stdout. Two pipe tasks bridge the server's pipes +to the session's in-memory streams; shutdown follows the MCP spec sequence +(close stdin, wait, then kill the process tree) inside a cancellation shield +with every wait bounded, so a cancelled caller can neither leak a live server +process nor hang on one. +""" + +import logging +import os +import sys +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager, suppress +from pathlib import Path +from typing import Literal, TextIO + +import anyio +import anyio.lowlevel +import mcp_types as types +from anyio.abc import AsyncResource, Process +from anyio.streams.text import TextReceiveStream +from pydantic import BaseModel, Field + +from mcp.client._transport import TransportStreams +from mcp.os.posix.utilities import terminate_posix_process_tree +from mcp.os.win32.utilities import ( + ServerProcess, + close_process_job, + create_windows_process, + get_windows_executable_command, + terminate_windows_process_tree, +) +from mcp.shared.message import SessionMessage + +logger = logging.getLogger(__name__) + +# Environment variables to inherit by default +DEFAULT_INHERITED_ENV_VARS = ( + [ + "APPDATA", + "HOMEDRIVE", + "HOMEPATH", + "LOCALAPPDATA", + "PATH", + "PATHEXT", + "PROCESSOR_ARCHITECTURE", + "SYSTEMDRIVE", + "SYSTEMROOT", + "TEMP", + "USERNAME", + "USERPROFILE", + ] + if sys.platform == "win32" + else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"] +) + +# Grace period for the server to exit on its own after its stdin closes. +PROCESS_TERMINATION_TIMEOUT = 2.0 + +# Extra time after SIGTERM before SIGKILL; POSIX only (Windows kills hard). +FORCE_KILL_TIMEOUT = 2.0 + +# Time for the event loop to observe a kill; only an unkillable process runs this out. +_KILL_REAP_TIMEOUT = 2.0 + +# Time for the writer to flush accepted messages before stdin closes. +_WRITER_FLUSH_TIMEOUT = 0.5 + +# How often to poll returncode while waiting for the process to die. +_EXIT_POLL_INTERVAL = 0.01 + + +def get_default_environment() -> dict[str, str]: + """Returns only the environment variables that are safe to inherit.""" + env: dict[str, str] = {} + + for key in DEFAULT_INHERITED_ENV_VARS: + value = os.environ.get(key) + if value is None: # pragma: lax no cover + continue + + if value.startswith("()"): # pragma: no cover + # Skip functions, which are a security risk + continue # pragma: no cover + + env[key] = value + + return env + + +class StdioServerParameters(BaseModel): + command: str + """The executable to run to start the server.""" + + args: list[str] = Field(default_factory=list) + """Command line arguments to pass to the executable.""" + + env: dict[str, str] | None = None + """Extra environment variables, merged over get_default_environment().""" + + cwd: str | Path | None = None + """The working directory to use when spawning the process.""" + + encoding: str = "utf-8" + """Text encoding for messages to and from the server.""" + + encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict" + """Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers.""" + + +@asynccontextmanager +async def stdio_client( + server: StdioServerParameters, errlog: TextIO = sys.stderr +) -> AsyncGenerator[TransportStreams, None]: + """Spawns an MCP server subprocess and connects to it over stdin/stdout. + + Raises: + OSError: If the server process cannot be spawned. + ValueError: If the spawn parameters are invalid (embedded NUL bytes). + """ + command = _get_executable_command(server.command) + + process = await _create_platform_compatible_process( + command=command, + args=server.args, + env=get_default_environment() | (server.env or {}), + errlog=errlog, + cwd=server.cwd, + ) + + # The spawn succeeded; no awaits until the task group is entered, or a + # cancellation delivered in the gap would leak the live process. + read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0) + write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0) + + shutting_down = False + writer_done = anyio.Event() + + async def stdout_reader() -> None: + assert process.stdout, "Opened process is missing stdout" + + stdout = TextReceiveStream(process.stdout, encoding=server.encoding, errors=server.encoding_error_handler) + try: + async with read_stream_writer: + try: + # One line at a time; no read-ahead while a delivery is blocked. + buffer = "" + async for chunk in stdout: + lines = (buffer + chunk).split("\n") + buffer = lines.pop() + for line in lines: + try: + await read_stream_writer.send(_parse_line(line)) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + return # the session is gone; only the drain below remains + finally: + await _drain_stdout(process) + except anyio.ClosedResourceError: + pass # our own shutdown closed the stdout stream under the read + except (anyio.BrokenResourceError, ConnectionError): + # Teardown noise during shutdown, a real failure otherwise; either way + # the session sees clean closure when the read stream closes. + if not shutting_down: + logger.exception("Reading from the MCP server's stdout failed mid-session") + + async def stdin_writer() -> None: + assert process.stdin, "Opened process is missing stdin" + + try: + async with write_stream_reader: + async for session_message in write_stream_reader: + json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) + data = (json + "\n").encode(encoding=server.encoding, errors=server.encoding_error_handler) + await process.stdin.send(data) + except (anyio.ClosedResourceError, anyio.BrokenResourceError, OSError): + # The server may still be alive: close the read stream so the session + # sees the connection end instead of a request hanging forever. + await read_stream_writer.aclose() + finally: + writer_done.set() + + async def shutdown() -> None: + """Winds the transport down: stop traffic, flush, stop the server, release the streams.""" + # Unblock the reader into its drain: a server stuck writing stdout cannot + # read its stdin, so draining is what lets the flush below complete. + read_stream.close() + # Bounded window for the writer to flush already-accepted messages. + write_stream.close() + with anyio.move_on_after(_WRITER_FLUSH_TIMEOUT) as flush_scope: + await writer_done.wait() + if flush_scope.cancelled_caught: + await anyio.lowlevel.cancel_shielded_checkpoint() # resync coverage on 3.11 (gh-106749) + await _stop_server_process(process) + await _aclose_all(read_stream, write_stream, read_stream_writer, write_stream_reader) + # One pass so unblocked tasks exit via their except paths before the cancel. + await anyio.lowlevel.checkpoint() + + async with anyio.create_task_group() as tg: + tg.start_soon(stdout_reader) + tg.start_soon(stdin_writer) + try: + yield read_stream, write_stream + finally: + shutting_down = True + # Shutdown must finish even under caller cancellation, or the server + # process would leak; every wait inside is bounded. (Native + # task.cancel() and the fallback's worker threads can still defeat it.) + with anyio.CancelScope(shield=True): + await shutdown() + # Unstick pipe tasks a kill survivor's open pipe end could still block. + tg.cancel_scope.cancel() + # The cancel lands via throw(); one yield resyncs 3.11 coverage (gh-106749). + await anyio.lowlevel.cancel_shielded_checkpoint() + + +def _parse_line(line: str) -> SessionMessage | Exception: + """Parses one stdout line, returning parse errors as values for the session to surface.""" + try: + message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) + except ValueError as exc: + logger.exception("Failed to parse JSONRPC message from server") + return exc + return SessionMessage(message) + + +async def _drain_stdout(process: ServerProcess) -> None: + """Consumes and discards the server's remaining stdout. + + Keeps a server flushing buffered output from blocking on a full pipe and + missing its chance to exit; shielded, raw bytes, ends when shutdown closes + the pipe. + """ + assert process.stdout + with anyio.CancelScope(shield=True): + with suppress( + anyio.EndOfStream, + anyio.ClosedResourceError, + anyio.BrokenResourceError, + ConnectionError, + OSError, + ): + while True: + await process.stdout.receive() + + +async def _stop_server_process(process: ServerProcess) -> None: + """Closes stdin, waits out the grace period, then kills the whole tree. + + The escalation order is spec text; timeouts and tree-wide scope are SDK policy: + https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#shutdown + """ + assert process.stdin and process.stdout, "server process is spawned with pipes" + + await _close_pipe(process.stdin) + if not await _wait_for_process_exit(process, PROCESS_TERMINATION_TIMEOUT): + await _terminate_process_tree(process) + # Until the event loop observes the death, the transport cannot close. + if not await _wait_for_process_exit(process, _KILL_REAP_TIMEOUT): + logger.warning("MCP server process %d is still alive after the kill escalation; abandoning it", process.pid) + + # Reaps surviving Windows job members now, not at GC; no-op on POSIX. + close_process_job(process) + # A kill survivor can hold the stdout pipe open; poison the reader anyway. + await _close_pipe(process.stdout) + _close_subprocess_transport(process) + + +async def _close_pipe(stream: AsyncResource) -> None: + """Closes a pipe stream, tolerating one already closed, broken, or contended.""" + with suppress(OSError, anyio.BrokenResourceError, anyio.ClosedResourceError): + await stream.aclose() + + +async def _wait_for_process_exit(process: ServerProcess, timeout: float) -> bool: + """Returns whether the process died within the timeout, by polling returncode. + + Not process.wait(): on asyncio 3.11+ it also waits for pipe EOF, and a + child that inherited the pipes makes an exited server look hung. + """ + deadline = anyio.current_time() + timeout + while process.returncode is None: + if anyio.current_time() >= deadline: + return False + await anyio.sleep(_EXIT_POLL_INTERVAL) + return True + + +async def _terminate_process_tree(process: ServerProcess) -> None: + """Kills the process and all its descendants. + + POSIX: SIGTERM to the process group, SIGKILL after FORCE_KILL_TIMEOUT. + Windows: immediate Job Object termination (already a hard kill). + """ + if sys.platform == "win32": # pragma: no cover + await terminate_windows_process_tree(process) + else: # pragma: lax no cover + # The Windows-only FallbackProcess never reaches the POSIX path. + assert isinstance(process, Process) + await terminate_posix_process_tree(process, FORCE_KILL_TIMEOUT) + + +def _close_subprocess_transport(process: ServerProcess) -> None: + """Closes the asyncio subprocess transport, if there is one. + + The transport otherwise stays open (and warns at GC) while a surviving + descendant holds a pipe end; nothing public exposes it, hence the attribute + walk. No-op on trio and the Windows fallback. + """ + transport = getattr(getattr(process, "_process", None), "_transport", None) + # Duck-typed: uvloop's UVProcessTransport is not an asyncio.SubprocessTransport. + close = getattr(transport, "close", None) + if callable(close): + # close() on <=3.12 can raise PermissionError re-killing a setuid child. + with suppress(PermissionError): + close() + + +def _get_executable_command(command: str) -> str: + """Normalizes the command for the current platform.""" + if sys.platform == "win32": # pragma: no cover + return get_windows_executable_command(command) + else: # pragma: lax no cover + return command + + +async def _create_platform_compatible_process( + command: str, + args: list[str], + env: dict[str, str] | None = None, + errlog: TextIO = sys.stderr, + cwd: Path | str | None = None, +) -> ServerProcess: + """Spawns the server in its own kill scope. + + A new session/process group on POSIX, a Job Object on Windows. + """ + if sys.platform == "win32": # pragma: no cover + return await create_windows_process(command, args, env, errlog, cwd) + else: # pragma: lax no cover + return await anyio.open_process( + [command, *args], + env=env, + stderr=errlog, + cwd=cwd, + start_new_session=True, + ) + + +async def _aclose_all(*streams: AsyncResource) -> None: + """Closes every given stream.""" + for stream in streams: + await stream.aclose() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/streamable_http.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/streamable_http.py new file mode 100644 index 0000000000000000000000000000000000000000..226b0fecf900b1e11968688496d9fac3648f165b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/streamable_http.py @@ -0,0 +1,710 @@ +"""Implements StreamableHTTP transport for MCP clients.""" + +from __future__ import annotations as _annotations + +import contextlib +import logging +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass + +import anyio +import httpx2 +from anyio.abc import TaskGroup +from httpx2 import EventSource, ServerSentEvent +from mcp_types import ( + CONNECTION_CLOSED, + INTERNAL_ERROR, + INVALID_REQUEST, + METHOD_NOT_FOUND, + PARSE_ERROR, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + RequestId, + jsonrpc_message_adapter, +) +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from pydantic import ValidationError + +from mcp.client._transport import TransportStreams +from mcp.shared._compat import resync_tracer +from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams +from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params +from mcp.shared.message import ClientMessageMetadata, SessionMessage + +logger = logging.getLogger(__name__) + + +# TODO(Marcelo): Put the TransportStreams in a module under shared, so we can import here. +SessionMessageOrError = SessionMessage | Exception +StreamWriter = ContextSendStream[SessionMessageOrError] +StreamReader = ContextReceiveStream[SessionMessage] + +MCP_SESSION_ID = "mcp-session-id" +LAST_EVENT_ID = "last-event-id" + +# Reconnection defaults +DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry +MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up + + +class StreamableHTTPError(Exception): + """Base exception for StreamableHTTP transport errors.""" + + +class ResumptionError(StreamableHTTPError): + """Raised when resumption request is invalid.""" + + +@dataclass +class RequestContext: + """Context for a request operation.""" + + client: httpx2.AsyncClient + session_id: str | None + session_message: SessionMessage + metadata: ClientMessageMetadata | None + read_stream_writer: StreamWriter + + +@dataclass(slots=True) +class _InFlightPost: + """A request POST in flight: its abort scope and the era it was sent under. + + `modern` is the negotiated-version cache as of this request's dequeue, so a + later cancel frame is interpreted under the era the request actually ran + with, not whatever the cache says by then. + """ + + scope: anyio.CancelScope + modern: bool + + +class StreamableHTTPTransport: + """StreamableHTTP client transport implementation.""" + + def __init__(self, url: str) -> None: + """Initialize the StreamableHTTP transport. + + Args: + url: The endpoint URL. + """ + self.url = url + self.session_id: str | None = None + # Captured from each stamped message's metadata, synchronously in the + # post_writer loop so the cache always reflects wire order (a POST task's + # scheduling is arbitrary). Reused on outbound HTTP that carries no + # per-message header (transport-internal GET/DELETE, and dispatcher-written + # response/error POSTs that bypass the session's stamp), and consulted by + # `_consume_modern_cancellation`. Cleared when an `initialize` message is + # dequeued so a probe-stamped value cannot leak onto the handshake. + self._protocol_version_header: str | None = None + # Every request's POST runs inside one of these so an outbound + # `notifications/cancelled` at 2026 can abort it; see + # `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1). + self._in_flight_posts: dict[RequestId, _InFlightPost] = {} + + def _prepare_headers(self) -> dict[str, str]: + """Build MCP-specific request headers for any outbound HTTP request. + + These are merged with the ``httpx2.AsyncClient`` defaults (these take + precedence). The cached ``MCP-Protocol-Version`` is included whenever + present so messages that don't pass through the session's stamp โ€” + response/error POSTs, legacy cancel frames, transport-internal + GET/DELETE โ€” still carry the negotiated version. Per-message headers + are layered on top by the caller. + """ + headers: dict[str, str] = { + "accept": "application/json, text/event-stream", + "content-type": "application/json", + } + if self.session_id: + headers[MCP_SESSION_ID] = self.session_id + if self._protocol_version_header: + headers[MCP_PROTOCOL_VERSION_HEADER] = self._protocol_version_header + return headers + + def _is_initialization_request(self, message: JSONRPCMessage) -> bool: + """Check if the message is an initialization request.""" + return isinstance(message, JSONRPCRequest) and message.method == "initialize" + + def _is_initialized_notification(self, message: JSONRPCMessage) -> bool: + """Check if the message is an initialized notification.""" + return isinstance(message, JSONRPCNotification) and message.method == "notifications/initialized" + + def _maybe_extract_session_id_from_response(self, response: httpx2.Response) -> None: + """Extract and store session ID from response headers.""" + new_session_id = response.headers.get(MCP_SESSION_ID) + if new_session_id: + self.session_id = new_session_id + logger.info(f"Received session ID: {self.session_id}") + + async def _handle_sse_event( + self, + sse: ServerSentEvent, + read_stream_writer: StreamWriter, + original_request_id: RequestId | None = None, + resumption_callback: Callable[[str], Awaitable[None]] | None = None, + ) -> bool: + """Handle an SSE event, returning True if the response is complete.""" + if sse.event == "message": + # Handle priming events (empty data with ID) for resumability + if not sse.data: + # Call resumption callback for priming events that have an ID + if sse.id and resumption_callback: + await resumption_callback(sse.id) + return False + try: + message = jsonrpc_message_adapter.validate_json(sse.data, by_name=False) + logger.debug(f"SSE message: {message}") + + # If this is a response and we have original_request_id, replace it + if original_request_id is not None and isinstance(message, JSONRPCResponse | JSONRPCError): + message.id = original_request_id + + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + + # Call resumption token callback if we have an ID + if sse.id and resumption_callback: + await resumption_callback(sse.id) + + # If this is a response or error return True indicating completion + # Otherwise, return False to continue listening + return isinstance(message, JSONRPCResponse | JSONRPCError) + + # Forwarding to a closed read stream lands here when the caller cancels mid-SSE + # (BrokenResourceError, not a parse failure); coverage is timing-dependent in the + # streaming story's modern HTTP cancellation leg. + except Exception as exc: # pragma: lax no cover + logger.exception("Error parsing SSE message") + if original_request_id is not None: + error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse SSE message: {exc}") + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data)) + await read_stream_writer.send(error_msg) + return True + await read_stream_writer.send(exc) + return False + else: # pragma: no cover + logger.warning(f"Unknown SSE event: {sse.event}") + return False + + async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer: StreamWriter) -> None: + """Handle GET stream for server-initiated messages with auto-reconnect.""" + last_event_id: str | None = None + retry_interval_ms: int | None = None + attempt: int = 0 + + while attempt < MAX_RECONNECTION_ATTEMPTS: # pragma: no branch + try: + if not self.session_id: + return + + headers = self._prepare_headers() + if last_event_id: + headers[LAST_EVENT_ID] = last_event_id + + async with client.sse(self.url, headers=headers) as event_source: + event_source.response.raise_for_status() + logger.debug("GET SSE connection established") + + async for sse in event_source: + # Track last event ID for reconnection + if sse.id: + last_event_id = sse.id + # Track retry interval from server + if sse.retry is not None: + retry_interval_ms = sse.retry + + await self._handle_sse_event(sse, read_stream_writer) + + # Stream ended normally (server closed) - reset attempt counter + attempt = 0 + + except Exception: + logger.debug("GET stream error", exc_info=True) + attempt += 1 + + if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover + logger.debug(f"GET stream max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") + return + + # Wait before reconnecting + delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS + logger.info(f"GET stream disconnected, reconnecting in {delay_ms}ms...") + await anyio.sleep(delay_ms / 1000.0) + + async def _handle_resumption_request(self, ctx: RequestContext) -> None: + """Handle a resumption request using GET with SSE.""" + headers = self._prepare_headers() + if ctx.metadata and ctx.metadata.resumption_token: + headers[LAST_EVENT_ID] = ctx.metadata.resumption_token + else: + raise ResumptionError("Resumption request requires a resumption token") # pragma: no cover + + # Extract original request ID to map responses + original_request_id = None + if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch + original_request_id = ctx.session_message.message.id + + async with ctx.client.sse(self.url, headers=headers) as event_source: + event_source.response.raise_for_status() + logger.debug("Resumption GET SSE connection established") + + async for sse in event_source: # pragma: no branch + is_complete = await self._handle_sse_event( + sse, + ctx.read_stream_writer, + original_request_id, + ctx.metadata.on_resumption_token_update if ctx.metadata else None, + ) + if is_complete: + await event_source.response.aclose() + break + + def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool: + """Translate an outbound `notifications/cancelled` at 2026; True means "do not POST". + + The 2026 wire defines no client-to-server notifications over streamable + HTTP: closing a request's response stream IS its cancellation signal. + The dispatcher still emits the courtesy frame as its abandon signal + (every outbound cancel names one of our own request ids - the spec + forbids cancelling a request the sender did not issue), so this + transport translates it: when the named request's POST is in flight, + that POST's own recorded era decides - abort-and-swallow at 2026, POST + the frame below it (where the frame is the signal and a disconnect + explicitly is not). With no POST to consult, the cached negotiated + version decides; at 2026 the frame is swallowed even unmatched, so a + late cancel racing the response cannot leak onto the wire. + """ + message = session_message.message + if not (isinstance(message, JSONRPCNotification) and message.method == "notifications/cancelled"): + return False + request_id = cancelled_request_id_from_params(message.params) + post = self._in_flight_posts.get(request_id) if request_id is not None else None + if post is not None: + if not post.modern: + return False + logger.debug("aborting in-flight POST for cancelled request %r", request_id) + post.scope.cancel() + return True + return self._protocol_version_header in MODERN_PROTOCOL_VERSIONS + + async def _run_request_post( + self, + post_fn: Callable[[], Awaitable[None]], + post: _InFlightPost, + request_id: RequestId, + ) -> None: + """Run one request's POST inside its abort scope (see `_consume_modern_cancellation`).""" + try: + with post.scope: + await post_fn() + finally: + # Identity-guarded: a reused id may already have a successor + # registered while this task unwinds - popping by key alone would + # evict the live entry and leave the new POST unabortable. + if self._in_flight_posts.get(request_id) is post: + del self._in_flight_posts[request_id] + + async def _handle_post_request(self, ctx: RequestContext) -> None: + """Handle a POST request with response processing.""" + message = ctx.session_message.message + headers = self._prepare_headers() + if ctx.metadata is not None and ctx.metadata.headers is not None: + headers.update(ctx.metadata.headers) + + async with ctx.client.stream( + "POST", + self.url, + json=message.model_dump(by_alias=True, mode="json", exclude_unset=True), + headers=headers, + ) as response: + if response.status_code == 202: + logger.debug("Received 202 Accepted") + if isinstance(message, JSONRPCRequest): + # A request's response arrives on this POST's body; 202 says + # none will follow. Resolve rather than park the caller forever. + await self._resolve_abandoned_request( + ctx.read_stream_writer, + message.id, + "server answered a request with 202 Accepted", + code=INVALID_REQUEST, + ) + return + + if response.status_code >= 400: + if isinstance(message, JSONRPCRequest): + # A spec-correct server may return the JSON-RPC error in the + # body at a non-2xx status (e.g. 400 for INVALID_PARAMS, 404 + # for METHOD_NOT_FOUND). Surface that error rather than the + # status-derived stand-in below. + if response.headers.get("content-type", "").lower().startswith("application/json"): + try: + body = await response.aread() + parsed = jsonrpc_message_adapter.validate_json(body, by_name=False) + if isinstance(parsed, JSONRPCError): + # The server may have set `id: null` (request rejected before its + # id was parsed); use this request's id so correlation works. + reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error) + await ctx.read_stream_writer.send(SessionMessage(reply)) + return + except (httpx2.StreamError, ValidationError): + pass + logger.debug("Non-2xx body was not a JSON-RPC error; using fallback") + if response.status_code == 404: + if self.session_id is None: + # No session yet โ†’ 404 is the HTTP-level spelling of + # METHOD_NOT_FOUND (gateway / legacy server doesn't know + # this method); "Session terminated" would be a lie here. + error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found") + else: + error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated") + else: + error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response") + session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) + await ctx.read_stream_writer.send(session_message) + return + + if self._is_initialization_request(message): + self._maybe_extract_session_id_from_response(response) + + # Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications: + # The server MUST NOT send a response to notifications. + if isinstance(message, JSONRPCRequest): + content_type = response.headers.get("content-type", "").lower() + if content_type.startswith("application/json"): + await self._handle_json_response(response, ctx.read_stream_writer, request_id=message.id) + elif content_type.startswith("text/event-stream"): + await self._handle_sse_response(response, ctx) + else: + logger.error(f"Unexpected content type: {content_type}") + error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}") + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) + await ctx.read_stream_writer.send(error_msg) + + async def _handle_json_response( + self, + response: httpx2.Response, + read_stream_writer: StreamWriter, + *, + request_id: RequestId, + ) -> None: + """Handle JSON response from the server.""" + try: + content = await response.aread() + message = jsonrpc_message_adapter.validate_json(content, by_name=False) + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + except (httpx2.StreamError, ValidationError) as exc: + logger.exception("Error parsing JSON response") + error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse JSON response: {exc}") + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data)) + await read_stream_writer.send(error_msg) + + async def _handle_sse_response( + self, + response: httpx2.Response, + ctx: RequestContext, + ) -> None: + """Handle SSE response from the server.""" + last_event_id: str | None = None + retry_interval_ms: int | None = None + + # The caller (_handle_post_request) only reaches here inside + # isinstance(message, JSONRPCRequest), so this is always a JSONRPCRequest. + assert isinstance(ctx.session_message.message, JSONRPCRequest) + original_request_id = ctx.session_message.message.id + + try: + event_source = EventSource(response) + async for sse in event_source: # pragma: no branch + # Track last event ID for potential reconnection + if sse.id: + last_event_id = sse.id + + # Track retry interval from server + if sse.retry is not None: + retry_interval_ms = sse.retry + + is_complete = await self._handle_sse_event( + sse, + ctx.read_stream_writer, + original_request_id=original_request_id, + resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None), + ) + # If the SSE event indicates completion, like returning response/error + # break the loop + if is_complete: + await response.aclose() + return # Normal completion, no reconnect needed + except Exception: + logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover + + # Stream ended without response - reconnect if we received an event with ID + if last_event_id is not None: + logger.info("SSE stream disconnected, reconnecting...") + await self._handle_reconnection(ctx, last_event_id, retry_interval_ms) + else: + # Not resumable: resolve the waiter, else a listen stream's consumer + # would hang forever instead of learning the subscription is lost. + await self._resolve_abandoned_request( + ctx.read_stream_writer, original_request_id, "SSE stream ended without a response" + ) + + async def _resolve_abandoned_request( + self, read_stream_writer: StreamWriter, request_id: RequestId, message: str, *, code: int = CONNECTION_CLOSED + ) -> None: + """Resolve a request whose response can never arrive with a synthesized error. + + Best-effort: a closed read stream means the session is tearing down. + """ + error_data = ErrorData(code=code, message=message) + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data)) + try: + await read_stream_writer.send(error_msg) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("read stream closed before request %r could be resolved", request_id) + + async def _handle_reconnection( + self, + ctx: RequestContext, + last_event_id: str, + retry_interval_ms: int | None = None, + attempt: int = 0, + ) -> None: + """Reconnect with Last-Event-ID to resume stream after server disconnect.""" + # Only requests reconnect: every caller arrives from a request's response stream. + assert isinstance(ctx.session_message.message, JSONRPCRequest) + original_request_id = ctx.session_message.message.id + + if attempt >= MAX_RECONNECTION_ATTEMPTS: + # Resolve on give-up: a request with no read timeout (a listen + # stream) would otherwise hang its caller forever. + logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") + await self._resolve_abandoned_request( + ctx.read_stream_writer, original_request_id, "SSE stream ended and reconnection attempts were exhausted" + ) + return + + # Always wait - use server value or default + delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS + await anyio.sleep(delay_ms / 1000.0) + + headers = self._prepare_headers() + headers[LAST_EVENT_ID] = last_event_id + + try: + async with ctx.client.sse(self.url, headers=headers) as event_source: + event_source.response.raise_for_status() + logger.info("Reconnected to SSE stream") + + # Track for potential further reconnection + reconnect_last_event_id: str = last_event_id + reconnect_retry_ms = retry_interval_ms + + async for sse in event_source: + if sse.id: # pragma: no branch + reconnect_last_event_id = sse.id + if sse.retry is not None: + reconnect_retry_ms = sse.retry + + is_complete = await self._handle_sse_event( + sse, + ctx.read_stream_writer, + original_request_id, + ctx.metadata.on_resumption_token_update if ctx.metadata else None, + ) + if is_complete: + await event_source.response.aclose() + return + + # Stream ended again without response - reconnect again (reset attempt counter) + logger.info("SSE stream disconnected, reconnecting...") + await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0) + except Exception as e: # pragma: no cover + logger.debug(f"Reconnection failed: {e}") + # Try to reconnect again if we still have an event ID + await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1) + + async def post_writer( + self, + client: httpx2.AsyncClient, + write_stream_reader: StreamReader, + read_stream_writer: StreamWriter, + write_stream: ContextSendStream[SessionMessage], + start_get_stream: Callable[[], None], + tg: TaskGroup, + ) -> None: + """Handle writing requests to the server.""" + try: + async with write_stream_reader, read_stream_writer, write_stream: + + async def _handle_message(session_message: SessionMessage) -> None: + message = session_message.message + if self._consume_modern_cancellation(session_message): + return + metadata = ( + session_message.metadata + if isinstance(session_message.metadata, ClientMessageMetadata) + else None + ) + + # Check if this is a resumption request + is_resumption = bool(metadata and metadata.resumption_token) + + logger.debug(f"Sending client message: {message}") + + # Handle initialized notification + if self._is_initialized_notification(message): + start_get_stream() + + if self._is_initialization_request(message): + # `initialize` is the negotiation, not a "subsequent request" โ€” discard any + # probe-stamped value so the discoverโ†’fallback path can't leak it onto the handshake. + self._protocol_version_header = None + elif metadata is not None and metadata.headers is not None: + stamped_version = metadata.headers.get(MCP_PROTOCOL_VERSION_HEADER) + if stamped_version is not None: + self._protocol_version_header = stamped_version + + ctx = RequestContext( + client=client, + session_id=self.session_id, + session_message=session_message, + metadata=metadata, + read_stream_writer=read_stream_writer, + ) + + async def handle_request_async(): + if is_resumption: + await self._handle_resumption_request(ctx) + else: + await self._handle_post_request(ctx) + + # If this is a request, start a new task to handle it + if isinstance(message, JSONRPCRequest): + # Register the abort scope before the spawn: the next + # message through this loop can already be the abandon + # signal for this id, ahead of the task ever running. + post = _InFlightPost( + scope=anyio.CancelScope(), + modern=self._protocol_version_header in MODERN_PROTOCOL_VERSIONS, + ) + superseded = self._in_flight_posts.get(message.id) + if superseded is not None: + # A reused id means the waiter belongs to this attempt now: + # sever the old POST so its zombie stream cannot answer, + # fail, or resolve the successor's request. + superseded.scope.cancel() + self._in_flight_posts[message.id] = post + tg.start_soon(self._run_request_post, handle_request_async, post, message.id) + else: + await handle_request_async() + + async for session_message in write_stream_reader: + sender_ctx = write_stream_reader.last_context + if sender_ctx is not None: + async with anyio.create_task_group() as tg_local: + sender_ctx.run(tg_local.start_soon, _handle_message, session_message) + else: + await _handle_message(session_message) # pragma: no cover + + except Exception: # pragma: lax no cover + logger.exception("Error in post_writer") + + async def terminate_session(self, client: httpx2.AsyncClient) -> None: + """Terminate the session by sending a DELETE request.""" + if not self.session_id: + return # pragma: no cover + + try: + headers = self._prepare_headers() + response = await client.delete(self.url, headers=headers) + + if response.status_code == 405: + logger.debug("Server does not allow session termination") + elif response.status_code not in (200, 204): + logger.warning(f"Session termination failed: {response.status_code}") # pragma: no cover + except Exception as exc: # pragma: no cover + logger.warning(f"Session termination failed: {exc}") + + +@asynccontextmanager +async def streamable_http_client( + url: str, + *, + http_client: httpx2.AsyncClient | None = None, + terminate_on_close: bool = True, +) -> AsyncGenerator[TransportStreams, None]: + """Client transport for StreamableHTTP. + + Args: + url: The MCP server endpoint URL. + http_client: Optional pre-configured httpx2.AsyncClient. If None, a default + client with recommended MCP timeouts will be created. To configure headers, + authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here. + terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. + + Yields: + Tuple containing: + - read_stream: Stream for reading messages from the server + - write_stream: Stream for sending messages to the server + + Example: + See examples/snippets/clients/ for usage patterns. + """ + # Determine if we need to create and manage the client + client_provided = http_client is not None + client = http_client + + if client is None: + # Create default client with recommended MCP timeouts + client = create_mcp_http_client() + + transport = StreamableHTTPTransport(url) + + logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") + + async with contextlib.AsyncExitStack() as stack: + # Only manage client lifecycle if we created it + if not client_provided: + await stack.enter_async_context(client) + + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) + + async with ( + read_stream_writer, + read_stream, + write_stream, + write_stream_reader, + anyio.create_task_group() as tg, + ): + + def start_get_stream() -> None: + tg.start_soon(transport.handle_get_stream, client, read_stream_writer) + + tg.start_soon( + transport.post_writer, + client, + write_stream_reader, + read_stream_writer, + write_stream, + start_get_stream, + tg, + ) + + try: + yield read_stream, write_stream + finally: + if transport.session_id and terminate_on_close: + await transport.terminate_session(client) + tg.cancel_scope.cancel() + await resync_tracer() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/client/subscriptions.py b/.mcp-venv/lib/python3.13/site-packages/mcp/client/subscriptions.py new file mode 100644 index 0000000000000000000000000000000000000000..27283909be1591ef796aba5a9df190d7780033e6 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/client/subscriptions.py @@ -0,0 +1,282 @@ +"""Client-side `subscriptions/listen` driver (2026-07-28, SEP-2575). + +`listen()` opens the stream as an async context manager: entering waits for +the server's acknowledgment, iteration yields typed change events, a graceful +server close ends the loop, and an abrupt drop raises `SubscriptionLost`. +There is no replay and no automatic re-listen: a client that re-opens a +subscription refetches what it depends on. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from contextlib import asynccontextmanager +from itertools import count +from typing import TYPE_CHECKING, Literal + +import anyio +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp.shared.dispatcher import CallOptions +from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ( + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, + event_matches, +) + +if TYPE_CHECKING: + from mcp.client.session import ClientSession + +__all__ = [ + "ListenNotSupportedError", + "OnEvent", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "Subscription", + "SubscriptionLost", + "ToolsListChanged", + "listen", +] + +_listen_ids = count(1) +"""Process-wide `listen-N` sequence: string ids can never collide with a dispatcher's minted ints.""" + +_MAX_PENDING_EVENTS = 1024 +"""Backlog backstop: the spec allows sub-resource URIs, so distinct pending +`ResourceUpdated` events are unbounded; overflowing this cap settles the +subscription lost rather than growing client memory.""" + +_SubscriptionEnd = Literal["graceful", "lost", "local"] + + +class ListenNotSupportedError(RuntimeError): + """`subscriptions/listen` requires a 2026-07-28 connection.""" + + def __init__(self, negotiated_version: str | None) -> None: + self.negotiated_version = negotiated_version + super().__init__( + f"subscriptions/listen is not available at protocol version {negotiated_version!r}; it requires " + "2026-07-28. On earlier versions use subscribe_resource() and the change notifications delivered " + "through message_handler." + ) + + +class SubscriptionLost(RuntimeError): + """The stream ended without the server's graceful close; re-listen and refetch.""" + + +class ListenRoute: + """Package-internal demux state for one listen stream, fed synchronously in receive order by the session.""" + + def __init__(self) -> None: + self.honored: types.SubscriptionFilter | None = None + self.acked = anyio.Event() + self.error: MCPError | None = None + self.end: _SubscriptionEnd | None = None + self._honored_uris: frozenset[str] = frozenset() + self._pending: dict[ServerEvent, None] = {} + self._wake = anyio.Event() + + def set_acked(self, honored: types.SubscriptionFilter) -> None: + """Record the acknowledged filter; the first ack wins.""" + if not self.acked.is_set(): + self.honored = honored + self._honored_uris = frozenset(honored.resource_subscriptions or ()) + self.acked.set() + + def deliver(self, event: ServerEvent) -> None: + """Queue an event within the honored filter, deduplicated against the backlog. + + Any `ResourceUpdated` is admitted once URI subscriptions were honored at + all: the spec allows the stamped URI to be a sub-resource of a subscribed one. + """ + if self.end is not None or self.honored is None: + return + if isinstance(event, ResourceUpdated): + admitted = bool(self._honored_uris) + else: + admitted = event_matches(self.honored, self._honored_uris, event) + if not admitted or event in self._pending: + return + if len(self._pending) >= _MAX_PENDING_EVENTS: + self.settle( + "lost", + error=MCPError( + types.INTERNAL_ERROR, + f"subscription backlog exceeded {_MAX_PENDING_EVENTS} unconsumed events; re-listen and refetch", + ), + ) + return + self._pending[event] = None + self._wake.set() + + def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None: + """Record the stream's end; the first reason wins and wakes both waiters.""" + if self.end is None: + self.end = end + self.error = error + self.acked.set() + self._wake.set() + + async def next_event(self) -> ServerEvent | _SubscriptionEnd: + """Peek the next pending event, or the stream's end once the backlog drains. + + A "local" end short-circuits the backlog; the other endings drain it first, + so a graceful close never swallows events that preceded it. + """ + while True: + # Snapshot the wake event before checking state so a deliver landing after the checks cannot be missed. + wake = self._wake + if self.end == "local": + return self.end + if self._pending: + return next(iter(self._pending)) + if self.end is not None: + return self.end + await wake.wait() + self._wake = anyio.Event() + + def consume(self, event: ServerEvent) -> None: + """Remove a peeked event from the backlog.""" + self._pending.pop(event, None) + + +OnEvent = Callable[[ServerEvent], Awaitable[None]] +"""Per-event barrier awaited before a `Subscription` returns each event to its consumer.""" + + +class Subscription: + """One open `subscriptions/listen` stream: an async iterator of typed events. + + Produced by `listen()` / `Client.listen()`, not constructed directly. + """ + + def __init__( + self, + route: ListenRoute, + subscription_id: types.RequestId, + honored: types.SubscriptionFilter, + on_event: OnEvent | None = None, + ): + self._route = route + self._on_event = on_event + self.subscription_id = subscription_id + """The listen request's JSON-RPC id, stamped into every frame's `_meta`.""" + self.honored = honored + """The subset of the requested filter the server agreed to deliver.""" + + def __aiter__(self) -> Subscription: + return self + + async def __anext__(self) -> ServerEvent: + """Yield the next change event; the loop ends when the stream does. + + Raises: + SubscriptionLost: the stream dropped without the server's graceful close. + """ + outcome = await self._route.next_event() + if isinstance(outcome, str): + if outcome == "lost": + raise SubscriptionLost( + f"subscription {self.subscription_id!r} ended without the server's graceful close;" + " re-listen and refetch" + ) from self._route.error + raise StopAsyncIteration + if self._on_event is not None: + # The event stays pending while the barrier runs: a cancellation or a + # raising barrier leaves it for the next anext instead of dropping it. + await self._on_event(outcome) + self._route.consume(outcome) + return outcome + + +@asynccontextmanager +async def listen( + session: ClientSession, + *, + tools_list_changed: bool = False, + prompts_list_changed: bool = False, + resources_list_changed: bool = False, + resource_subscriptions: Sequence[str] = (), + on_event: OnEvent | None = None, +) -> AsyncIterator[Subscription]: + """Open one `subscriptions/listen` stream on `session` (2026-07-28 only). + + Entering sends the request and returns once the server's acknowledgment + arrives; exiting ends the subscription. `on_event` is awaited before each + event is returned - the seam `Client.listen` uses to finish cache eviction + before the consumer can refetch. + + Raises: + ListenNotSupportedError: negotiated version predates 2026-07-28. + MCPError: the server rejected the request, or the connection failed pre-ack. + SubscriptionLost: the stream ended before it was acknowledged. + TimeoutError: the session's read timeout elapsed before the acknowledgment. + """ + if session.protocol_version not in MODERN_PROTOCOL_VERSIONS: + raise ListenNotSupportedError(session.protocol_version) + if isinstance(resource_subscriptions, str): + raise TypeError("resource_subscriptions takes a sequence of URIs, not a bare string") + request = types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams( + notifications=types.SubscriptionFilter( + tools_list_changed=tools_list_changed or None, + prompts_list_changed=prompts_list_changed or None, + resources_list_changed=resources_list_changed or None, + resource_subscriptions=list(resource_subscriptions) or None, + ) + ) + ) + task_group = session._task_group # pyright: ignore[reportPrivateUsage] + if task_group is None: + raise RuntimeError("listen() requires an entered session") + request_id: types.RequestId = f"listen-{next(_listen_ids)}" + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {"request_id": request_id} + session._stamp(data, opts) # pyright: ignore[reportPrivateUsage] + driver_scope = anyio.CancelScope() + + async def drive() -> None: + # Deliberately no result timeout: the response arrives when the stream ends. + with driver_scope: + try: + await session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] + data["method"], data.get("params"), opts + ) + except MCPError as error: + route.settle("lost", error=error) + return + except ValueError as error: + # A raw request id collided with our minted listen id: fail this subscription + # and release the route in this same slice, so it cannot consume the raw caller's ack. + session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] + route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error))) + return + # A result, whatever its body, is the spec's graceful close; with no prior ack + # it opens the subscription already closed. + route.set_acked(types.SubscriptionFilter()) + route.settle("graceful") + + # Register the demux route before the request is written so the ack cannot race it. + route = session._register_listen_route(request_id) # pyright: ignore[reportPrivateUsage] + try: + task_group.start_soon(drive) + with anyio.fail_after(session._session_read_timeout_seconds): # pyright: ignore[reportPrivateUsage] + await route.acked.wait() + if route.honored is None: + # Only reachable on failure paths: a graceful no-ack result acked an empty filter in drive(). + if route.error is not None: + raise route.error + raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged") + yield Subscription(route, request_id, route.honored, on_event) + finally: + route.settle("local") + driver_scope.cancel() + session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/os/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/os/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fa5dbc809c114248f9fbd29fdc7402ddfcaf3fe3 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/os/__init__.py @@ -0,0 +1 @@ +"""Platform-specific utilities for MCP.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/os/posix/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/os/posix/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..23aff8bb02a0fbc4decbc8604d80cfef99d59c86 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/os/posix/__init__.py @@ -0,0 +1 @@ +"""POSIX-specific utilities for MCP.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/os/posix/utilities.py b/.mcp-venv/lib/python3.13/site-packages/mcp/os/posix/utilities.py new file mode 100644 index 0000000000000000000000000000000000000000..d15be17194d6ddc971f9eef2557ef8303bc427ff --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/os/posix/utilities.py @@ -0,0 +1,63 @@ +"""POSIX-specific functionality for stdio client operations.""" + +import logging +import os +import signal +from contextlib import suppress + +import anyio +from anyio.abc import Process + +logger = logging.getLogger(__name__) + +# How often to probe for surviving group members between SIGTERM and SIGKILL. +_GROUP_POLL_INTERVAL = 0.01 + + +async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None: + """Terminates a process and all its descendants on POSIX. + + SIGTERMs the process group, waits up to timeout_seconds for it to + disappear, then SIGKILLs whatever remains. killpg reaches every descendant + atomically, even ones whose parent already exited; daemonizers that left + the group escape by design. A group only disappears once every member is + dead and reaped, so a client running as PID 1 should reap orphans (e.g. + docker run --init) or the wait below runs its full timeout. + """ + # The leader's pid is the pgid (start_new_session). Never use getpgid(): + # it fails once the leader is reaped, even with live members left. + pgid = process.pid + + try: + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + return # the whole group is already gone + except PermissionError: + # EPERM never proves the group is gone (macOS raises it for zombie or + # foreign-euid members), so keep waiting and escalating. + logger.warning( + "No permission to signal some of process group %d; waiting for it to exit anyway", pgid, exc_info=True + ) + + with anyio.move_on_after(timeout_seconds): + while _group_alive(pgid): + # Reading returncode reaps the leader on trio; a zombie leader would + # otherwise keep the group alive for the full timeout. + _ = process.returncode + await anyio.sleep(_GROUP_POLL_INTERVAL) + return + + # ESRCH: died since the last probe. EPERM: we killed what we were allowed to. + with suppress(ProcessLookupError, PermissionError): + os.killpg(pgid, signal.SIGKILL) + + +def _group_alive(pgid: int) -> bool: + """Probes the group with signal 0; only ESRCH proves it is gone.""" + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return False + except PermissionError: + pass # unsignalable survivors or unreaped zombies; EPERM is ambiguous + return True diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/os/win32/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/os/win32/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f1ebab98df81ae72aead1b5fde2ed095472fae30 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/os/win32/__init__.py @@ -0,0 +1 @@ +"""Windows-specific utilities for MCP.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/os/win32/utilities.py b/.mcp-venv/lib/python3.13/site-packages/mcp/os/win32/utilities.py new file mode 100644 index 0000000000000000000000000000000000000000..321fda8a667f8c979232c17f1f1049c4c4e731fb --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/os/win32/utilities.py @@ -0,0 +1,292 @@ +"""Windows-specific functionality for stdio transport operations.""" + +import logging +import shutil +import subprocess +import sys +import weakref +from contextlib import suppress +from pathlib import Path +from typing import BinaryIO, TextIO, TypeAlias, cast + +import anyio +from anyio.abc import Process +from anyio.streams.file import FileReadStream, FileWriteStream + +logger = logging.getLogger(__name__) + +# Windows-specific imports for Job Objects +if sys.platform == "win32": + import msvcrt + + import pywintypes + import win32api + import win32con + import win32job +else: + # Type stubs for non-Windows platforms + win32api = None + win32con = None + msvcrt = None + win32job = None + pywintypes = None + + +def rebind_std_handle_to_fd(fd: int) -> None: + """Points the Win32 standard-handle slot for fd 0, 1, or 2 at fd's current OS handle. + + os.dup2 updates only the CRT descriptor table; subprocess handle inheritance + reads the Win32 slot, so it must be repointed too. + + Raises: + OSError: The slot could not be set. + """ + if sys.platform != "win32" or not win32api or not msvcrt or not pywintypes: + return + std_ids = {0: win32api.STD_INPUT_HANDLE, 1: win32api.STD_OUTPUT_HANDLE, 2: win32api.STD_ERROR_HANDLE} + try: + win32api.SetStdHandle(std_ids[fd], msvcrt.get_osfhandle(fd)) + except pywintypes.error as exc: + # Normalized so callers' OSError-based best-effort handling covers it. + raise OSError(f"SetStdHandle failed for fd {fd}") from exc + + +# How often FallbackProcess polls the underlying Popen for exit. +_EXIT_POLL_INTERVAL = 0.01 + +# Job Object handle per spawned process, for tree termination at shutdown. +# Values stay pywin32 PyHANDLEs: if no pop site ever runs, the dying weak entry +# drops the last reference and the PyHANDLE destructor closes the handle, which +# is what makes KILL_ON_JOB_CLOSE reap an abandoned tree. +_process_jobs: "weakref.WeakKeyDictionary[Process | FallbackProcess, object]" = weakref.WeakKeyDictionary() + + +def get_windows_executable_command(command: str) -> str: + """Resolves the command to a Windows executable path. + + Tries the bare name first, then the common script extensions (.cmd, .bat, + .exe, .ps1). + """ + try: + if command_path := shutil.which(command): + return command_path + + for ext in [".cmd", ".bat", ".exe", ".ps1"]: + ext_version = f"{command}{ext}" + if ext_path := shutil.which(ext_version): + return ext_path + + return command + except OSError: + return command # path probing failed (permissions, broken symlinks) + + +class FallbackProcess: + """Async wrapper around subprocess.Popen for SelectorEventLoop. + + Windows event loops without async subprocess support get this Popen-backed + fallback, with anyio file streams wrapping the pipes. + """ + + def __init__(self, popen_obj: subprocess.Popen[bytes]) -> None: + self.popen: subprocess.Popen[bytes] = popen_obj + stdin = popen_obj.stdin + stdout = popen_obj.stdout + + self.stdin = FileWriteStream(cast(BinaryIO, stdin)) if stdin else None + self.stdout = FileReadStream(cast(BinaryIO, stdout)) if stdout else None + + async def wait(self) -> int: + """Waits for exit by polling the Popen. + + A thread blocked in Popen.wait() cannot be cancelled by anyio, which + would defeat every timeout placed around this call. + """ + while (returncode := self.popen.poll()) is None: + await anyio.sleep(_EXIT_POLL_INTERVAL) + return returncode + + def terminate(self) -> None: + """Terminates the subprocess.""" + self.popen.terminate() + + def kill(self) -> None: + """Kills the subprocess (on Windows the same hard kill as terminate).""" + self.popen.kill() + + @property + def pid(self) -> int: + """Returns the process ID.""" + return self.popen.pid + + @property + def returncode(self) -> int | None: + """The exit code, or None while the process is still running. + + Polls the Popen so death is observable without anyone calling wait(). + """ + return self.popen.poll() + + +# The process handle stdio_client drives: anyio's Process, or the Popen-backed +# fallback used on Windows event loops without async subprocess support. +ServerProcess: TypeAlias = Process | FallbackProcess + + +async def create_windows_process( + command: str, + args: list[str], + env: dict[str, str] | None = None, + errlog: TextIO | None = sys.stderr, + cwd: Path | str | None = None, +) -> Process | FallbackProcess: + """Creates a subprocess with Job Object support for tree termination. + + Spawns via anyio's open_process; event loops without async subprocess + support (notably the SelectorEventLoop) raise NotImplementedError, in which + case the spawn falls back to a Popen-backed FallbackProcess. Either way the + process is then assigned to a Job Object so its children can be terminated + with it; children spawned before the assignment completes are not captured + (see the inline note below). + + Returns: + Process | FallbackProcess: The spawned process with async stdin/stdout streams. + """ + try: + process = await anyio.open_process( + [command, *args], + env=env, + # Ensure we don't create console windows for each process + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + stderr=errlog, + cwd=cwd, + ) + except NotImplementedError: + # Windows event loops without async subprocess support (SelectorEventLoop) + process = await _create_windows_fallback_process(command, args, env, errlog, cwd) + + # Children spawned before the assignment completes land outside the job + # (membership is inherited at CreateProcess, never acquired retroactively); + # if that ever bites, the fix is a CREATE_SUSPENDED spawn -> assign -> resume. + job = _create_job_object() + _maybe_assign_process_to_job(process, job) + return process + + +async def _create_windows_fallback_process( + command: str, + args: list[str], + env: dict[str, str] | None = None, + errlog: TextIO | None = sys.stderr, + cwd: Path | str | None = None, +) -> FallbackProcess: + """Spawns via subprocess.Popen and wraps it in FallbackProcess.""" + popen_obj = subprocess.Popen( + [command, *args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=errlog, + env=env, + cwd=cwd, + bufsize=0, # Unbuffered output + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + return FallbackProcess(popen_obj) + + +def _create_job_object() -> object | None: + """Creates a Windows Job Object configured to terminate all its processes when closed.""" + if sys.platform != "win32" or not win32api or not win32job: + return None + + job = None + try: + job = win32job.CreateJobObject(None, "") + extended_info = win32job.QueryInformationJobObject(job, win32job.JobObjectExtendedLimitInformation) + + extended_info["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + win32job.SetInformationJobObject(job, win32job.JobObjectExtendedLimitInformation, extended_info) + return job + except pywintypes.error: + logger.warning("Failed to create Job Object for process tree management", exc_info=True) + # If creation succeeded but configuration failed, close the handle now. + if job is not None: + _close_job_handle(job) + return None + + +def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: object | None) -> None: + """Assigns the process to the job and records it for tree termination. + + On any failure the job handle is closed instead. + """ + if job is None: + return + + if sys.platform != "win32" or not win32api or not win32con or not win32job: + return + + try: + process_handle = win32api.OpenProcess( + win32con.PROCESS_SET_QUOTA | win32con.PROCESS_TERMINATE, False, process.pid + ) + if not process_handle: + raise pywintypes.error(0, "OpenProcess", "Failed to open process handle") + + try: + win32job.AssignProcessToJobObject(job, process_handle) + finally: + win32api.CloseHandle(process_handle) + # Record only after the CloseHandle above succeeded: had it failed, the + # except below would close the job and KILL_ON_JOB_CLOSE takes the server. + _process_jobs[process] = job + except pywintypes.error: + logger.warning("Failed to assign process %d to Job Object", process.pid, exc_info=True) + _close_job_handle(job) + + +def close_process_job(process: Process | FallbackProcess) -> None: + """Closes the process's Job Object handle, if it still has one. + + KILL_ON_JOB_CLOSE makes the close also kill any members still alive, + deterministically rather than at GC time; a deliberate divergence from + POSIX, where a graceful server's children are left alive. + """ + if sys.platform != "win32": + return + + job = _process_jobs.pop(process, None) + if job is not None: + _close_job_handle(job) + + +async def terminate_windows_process_tree(process: Process | FallbackProcess) -> None: + """Terminates the process's job, or just the process if it has no job. + + Job termination is an immediate hard kill of every member. Windows has no + tree-wide SIGTERM; the stdin-close grace period is the server's chance to + exit cleanly. + """ + if sys.platform != "win32": + return + + job = _process_jobs.pop(process, None) + if job is not None and win32job: + try: + with suppress(pywintypes.error): # the job might already be terminated + win32job.TerminateJobObject(job, 1) + finally: + _close_job_handle(job) + + # The process may have no job (creation or assignment failed); kill it directly too. + try: + process.terminate() + except OSError: + pass + + +def _close_job_handle(job: object) -> None: + """Closes a Job Object handle, tolerating one that is already closed.""" + if win32api and pywintypes: + with suppress(pywintypes.error): + win32api.CloseHandle(job) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/py.typed b/.mcp-venv/lib/python3.13/site-packages/mcp/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5897e8aa8b6a3c1fd7b1043c12bdc63205a3ae06 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/__init__.py @@ -0,0 +1,7 @@ +from .caching import CacheHint +from .context import ServerRequestContext +from .lowlevel import NotificationOptions, Server +from .mcpserver import MCPServer +from .models import InitializationOptions + +__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/__main__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..4305b87e22aaf2ee669fd71a41ad029653c7db8d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/__main__.py @@ -0,0 +1,24 @@ +import logging +import sys +import warnings + +import anyio + +from mcp.server.lowlevel.server import Server +from mcp.server.stdio import stdio_server + +if not sys.warnoptions: + warnings.simplefilter("ignore") + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("server") + + +async def main() -> None: + server: Server[dict[str, object]] = Server("mcp") + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) + + +if __name__ == "__main__": + anyio.run(main, backend="trio") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/_otel.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/_otel.py new file mode 100644 index 0000000000000000000000000000000000000000..ff722eb9036a908814e48fb9c052d5536d88d263 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/_otel.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Any + +from mcp_types import INVALID_PARAMS, CallToolResult +from opentelemetry.trace import SpanKind, StatusCode +from pydantic import ValidationError + +from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.shared._otel import extract_trace_context, otel_span +from mcp.shared.exceptions import MCPError + + +class OpenTelemetryMiddleware(ServerMiddleware[Any]): + """Context-tier middleware that wraps each inbound message in an OpenTelemetry span.""" + + async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + name = ctx.params.get("name") if ctx.params else None + target = name if isinstance(name, str) else None + + attributes: dict[str, Any] = { + "mcp.method.name": ctx.method, + "mcp.protocol.version": ctx.protocol_version, + } + if ctx.request_id is not None: + attributes["jsonrpc.request.id"] = str(ctx.request_id) + + if ctx.method == "tools/call": + attributes["gen_ai.operation.name"] = "execute_tool" + if target is not None: + attributes["gen_ai.tool.name"] = target + elif ctx.method == "prompts/get" and target is not None: + attributes["gen_ai.prompt.name"] = target + + with otel_span( + name=f"{ctx.method}{f' {target}' if target else ''}", + kind=SpanKind.SERVER, + attributes=attributes, + context=extract_trace_context(ctx.meta), + record_exception=False, + set_status_on_exception=False, + ) as span: + try: + result = await call_next(ctx) + except MCPError as e: + code = str(e.error.code) + span.set_attributes({"error.type": code, "rpc.response.status_code": code}) + span.set_status(StatusCode.ERROR, e.error.message) + raise + except ValidationError: + # Mirror the sanitized wire response; pydantic messages carry client input. + code = str(INVALID_PARAMS) + span.set_attributes({"error.type": code, "rpc.response.status_code": code}) + span.set_status(StatusCode.ERROR, "Invalid request parameters") + raise + except Exception as e: + span.set_attribute("error.type", type(e).__qualname__) + span.record_exception(e) + span.set_status(StatusCode.ERROR, str(e)) + raise + if ctx.method == "tools/call": + # Tool errors are detected pre-serialization, so only shapes that reach the wire as an error + # count: the model, or the camelCase alias (`is_error` is dropped by the alias-only wire + # validation). A raw-dict `isError` is matched as a literal bool only - non-bool coercible + # values (1, "true") would serialize to an error but are rare enough to leave undetected. + match result: + case CallToolResult(is_error=True) | {"isError": True}: + span.set_attribute("error.type", "tool_error") + span.set_status(StatusCode.ERROR) + case _: + pass + return result diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/_streamable_http_modern.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/_streamable_http_modern.py new file mode 100644 index 0000000000000000000000000000000000000000..1db6b35f8ad6f3ecab837372162d70ff36340d79 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/_streamable_http_modern.py @@ -0,0 +1,464 @@ +"""Single-exchange HTTP serving for protocol version 2026-07-28. + +Private module โ€” entry is via `StreamableHTTPSessionManager.handle_request`. +The legacy streamable-HTTP transport is untouched and remains the supported +path for earlier protocol revisions. + +A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no +`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON +mode handles the request directly in the ASGI task. SSE mode runs the handler +as a sibling task and defers committing to `text/event-stream` until the +handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever +comes first: a handler that completes (or raises) within that window without +emitting still gets a JSON response with the table-mapped HTTP status, so +the spec's `404`/`400` MUSTs hold for kernel-dispatch errors; a handler that +runs silent past the window commits SSE so the keepalive ping can keep the +connection open behind a proxy idle-read timeout. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Awaitable, Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Final, cast + +import anyio +from anyio.streams.memory import MemoryObjectSendStream +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + HEADER_MISMATCH, + INVALID_REQUEST, + PARSE_ERROR, + PROTOCOL_VERSION_META_KEY, + ErrorData, + JSONRPCError, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + ProgressToken, + RequestId, +) +from mcp_types import methods as _methods +from pydantic import ValidationError +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Receive, Scope, Send + +from mcp.server.connection import Connection +from mcp.server.runner import modern_error_data, serve_one +from mcp.server.streamable_http import check_accept_headers +from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings +from mcp.shared.dispatcher import CallOptions +from mcp.shared.exceptions import NoBackChannelError +from mcp.shared.inbound import ( + ERROR_CODE_HTTP_STATUS, + MCP_PARAM_HEADER_PREFIX, + InboundLadderRejection, + InboundModernRoute, + classify_inbound_request, + find_duplicated_routing_header, + validate_mcp_param_headers, +) +from mcp.shared.jsonrpc_dispatcher import progress_token_from_params +from mcp.shared.message import MessageMetadata, ServerMessageMetadata +from mcp.shared.transport_context import TransportContext + +if TYPE_CHECKING: + from mcp.server.lowlevel.server import Server + +logger = logging.getLogger(__name__) + + +_OK_STATUS = 200 + + +@dataclass +class _SingleExchangeDispatchContext: + """`DispatchContext` for one inbound HTTP request. + + Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The + back-channel is closed by construction: a 2026-07-28 server cannot send + requests to the client. The SSE sink, when present, carries request-scoped + notifications onto this request's response stream. + """ + + transport: TransportContext + request_id: RequestId + message_metadata: MessageMetadata + progress_token: ProgressToken | None = None + sink: MemoryObjectSendStream[bytes] | None = None + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + can_send_request: bool = field(default=False, init=False) + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + if self.sink is None: + return + body = dict(params) if params is not None else None + try: + await self.sink.send(_sse_event(JSONRPCNotification(jsonrpc="2.0", method=method, params=body))) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + logger.debug("dropped %s: response stream closed", method) + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + if self.progress_token is None: + return + params: dict[str, Any] = {"progressToken": self.progress_token, "progress": progress} + if total is not None: + params["total"] = total + if message is not None: + params["message"] = message + await self.notify("notifications/progress", params) + + +async def _to_jsonrpc_response( + request_id: RequestId, coro: Awaitable[dict[str, Any]] +) -> JSONRPCResponse | JSONRPCError: + """Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``. + + The exception-to-wire boundary for the modern HTTP entry, composed around + `serve_one`: `modern_error_data` maps the shared ladder and surfaces + anything else as a generic `INTERNAL_ERROR` so handler internals never + reach the wire. + """ + try: + result = await coro + except Exception as exc: + return JSONRPCError(jsonrpc="2.0", id=request_id, error=modern_error_data(exc)) + return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result) + + +_SSE_PING_INTERVAL: float = 15.0 +"""Seconds between SSE comment-line keepalives once `text/event-stream` has committed.""" + +_SSE_HEADERS: Final[list[tuple[bytes, bytes]]] = [ + (b"content-type", b"text/event-stream"), + (b"cache-control", b"no-cache, no-transform"), + (b"connection", b"keep-alive"), + (b"x-accel-buffering", b"no"), +] + + +def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> bytes: + """Serialise a JSON-RPC message as one SSE `event: message` frame. + + SSE mode begins after the handler has emitted, so a `JSONRPCError` here + always carries the request's id; the `id: null` case lives in `_write`. + """ + body = msg.model_dump(mode="json", by_alias=True, exclude_none=True) + data = json.dumps(body, separators=(",", ":")) + return f"event: message\r\ndata: {data}\r\n\r\n".encode() + + +async def _write_rejection( + rejection: InboundLadderRejection, + request_id: RequestId, + scope: Scope, + receive: Receive, + send: Send, +) -> None: + """Send a ladder rejection as its JSON-RPC error with the table-mapped HTTP status.""" + rej = JSONRPCError( + jsonrpc="2.0", + id=request_id, + error=ErrorData(code=rejection.code, message=rejection.message, data=rejection.data), + ) + await _write(rej, scope, receive, send) + + +async def _write( + msg: JSONRPCResponse | JSONRPCError, + scope: Scope, + receive: Receive, + send: Send, +) -> None: + """Serialise a JSON-RPC reply with the table-mapped HTTP status.""" + status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS + body = msg.model_dump(mode="json", by_alias=True, exclude_none=True) + if isinstance(msg, JSONRPCError) and msg.id is None: + # JSON-RPC requires `id: null` to appear on the wire when the request + # id couldn't be parsed; `exclude_none` would otherwise drop it. + body["id"] = None + await Response( + json.dumps(body, separators=(",", ":")), + status_code=status, + media_type="application/json", + )(scope, receive, send) + + +_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower() + +_MCP_PARAM_LIST_PAGE_CAP: Final = 100 +"""Page cap for the schema-resolving tools/list walk: a buggy paginator degrades to a logged skip, not a hang.""" + + +async def _tool_input_schema( + app: Server[Any], + request: Request, + request_id: RequestId, + verdict: InboundModernRoute, + lifespan_state: Any, + name: str, +) -> Any | None: + """Resolve `name`'s inputSchema from the server's own registered `tools/list` handler. + + The listing runs through the normal `serve_one` path, so a visibility-scoped + catalog yields exactly what *this* caller was advertised. Returns None + (caller skips validation) when the listing fails or never advertises the tool. + """ + meta = { + PROTOCOL_VERSION_META_KEY: verdict.protocol_version, + CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities, + } + if verdict.client_info is not None: + # Optional key: a conforming pair-only caller omits it rather than sending null. + meta[CLIENT_INFO_META_KEY] = verdict.client_info + list_params: dict[str, Any] = {"_meta": meta} + try: + _methods.validate_client_request("tools/list", verdict.protocol_version, list_params) + except ValidationError: + # Client-fault envelope: the real dispatch produces the INVALID_PARAMS + # reply, and anything above a debug line would let clients flood the log. + logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation") + return None + seen_cursors: set[str] = set() + dctx = _SingleExchangeDispatchContext( + transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers), + request_id=request_id, + message_metadata=ServerMessageMetadata(request_context=request), + ) + for _ in range(_MCP_PARAM_LIST_PAGE_CAP): + # Fresh Connection per page: serve_one tears down the connection's exit stack on the way out. + connection = Connection.from_envelope( + verdict.protocol_version, verdict.client_info, verdict.client_capabilities + ) + try: + result = await serve_one( + app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state + ) + for tool in result.get("tools", []): + if tool.get("name") == name: + return tool.get("inputSchema") + cursor = result.get("nextCursor") + except Exception: + # Fail-open boundary by design: header validation must never break a + # working call path. Loud, precisely because the skip is fail-open. + logger.exception("Mcp-Param header validation skipped: the tools/list listing failed") + return None + if not isinstance(cursor, str): + # Listing exhausted without advertising `name`; dispatch owns rejecting an unknown tool. + return None + if cursor in seen_cursors: + logger.warning("Mcp-Param header validation skipped: the tools/list handler returned a cursor cycle") + return None + seen_cursors.add(cursor) + list_params = {"_meta": meta, "cursor": cursor} + logger.warning( + "Mcp-Param header validation skipped: tools/list pagination did not terminate within %d pages", + _MCP_PARAM_LIST_PAGE_CAP, + ) + return None + + +async def _mcp_param_rejection( + app: Server[Any], + request: Request, + req: JSONRPCRequest, + verdict: InboundModernRoute, + lifespan_state: Any, +) -> InboundLadderRejection | None: + """Validate a `tools/call` request's `Mcp-Param-*` headers against the called tool's schema. + + Runs pre-dispatch, before any SSE machinery, so a rejection is always a + plain `application/json` 400 (the spec's MUST). With no `tools/list` handler + the catalog is undiscoverable and there is no recognized header to validate. + """ + if req.method != "tools/call" or app.get_request_handler("tools/list") is None: + return None + params = req.params or {} + name = params.get("name") + if not isinstance(name, str): + return None + raw_arguments = params.get("arguments") + if raw_arguments is not None and not isinstance(raw_arguments, Mapping): + return None + arguments: Mapping[str, Any] = cast("Mapping[str, Any]", raw_arguments) if raw_arguments is not None else {} + # ASGI guarantees lowercase header names, so no case-folding here. + if not arguments and not any(header.startswith(_MCP_PARAM_PREFIX_LOWER) for header in request.headers): + # No argument values and no `Mcp-Param-*` headers: no declaration can be violated either way. + return None + input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name) + if input_schema is None: + return None + return validate_mcp_param_headers(input_schema, arguments, request.headers) + + +async def handle_modern_request( + app: Server[Any], + security_settings: TransportSecuritySettings | None, + json_response: bool, + lifespan_state: Any, + scope: Scope, + receive: Receive, + send: Send, +) -> None: + """ASGI handler for a single stateless-era POST. + + Called from `StreamableHTTPSessionManager.handle_request` when the + `MCP-Protocol-Version` header names a modern revision; the manager enters + `app.lifespan` once at startup and passes the state in. Never sets + `Mcp-Session-Id`. + """ + request = Request(scope, receive) + + security = TransportSecurityMiddleware(security_settings) + err = await security.validate_request(request, is_post=(request.method == "POST")) + if err is not None: + await err(scope, receive, send) + return + + if request.method != "POST": + # HTTP-layer rejection (Allow accompanies 405 per RFC 9110) โ€” happens + # before JSON-RPC parsing, so it doesn't go through `_write`. + await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send) + return + + has_json, has_sse = check_accept_headers(request) + if not has_json or (not json_response and not has_sse): + await Response(status_code=406)(scope, receive, send) + return + + body = await request.body() + try: + decoded = json.loads(body) + except (ValueError, RecursionError): + # Not just JSONDecodeError: oversized integer literals raise bare ValueError, deep nesting RecursionError. + rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) + await _write(rej, scope, receive, send) + return + try: + req = JSONRPCRequest.model_validate(decoded) + except ValidationError: + # Well-formed JSON that isn't a single request object. The transport + # spec permits notification POSTs and gives the server two responses + # (202 accept / 4xx cannot-accept; streamable-http ยงSending Messages + # item 5). The core protocol defines no clientโ†’server notifications + # over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this + # entry takes the cannot-accept branch. TODO(L57): S4 owns the + # strict-vs-lenient choice. + rej = JSONRPCError( + jsonrpc="2.0", + id=None, + error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"), + ) + await _write(rej, scope, receive, send) + return + + if req.method == "subscriptions/listen" and not has_sse: + # A listen response IS a notification stream, never JSON (the + # json_response carve-out below), so this one method requires the + # SSE accept even in JSON-response mode; SSE mode gated it above. + await Response(status_code=406)(scope, receive, send) + return + + duplicated = find_duplicated_routing_header(request.headers.items()) + if duplicated is not None: + # The raw carrier is the only place duplicates are visible; the classifier sees a folded mapping. + rejection = InboundLadderRejection(code=HEADER_MISMATCH, message=f"{duplicated} header appears more than once") + await _write_rejection(rejection, req.id, scope, receive, send) + return + + verdict = classify_inbound_request(decoded, headers=dict(request.headers)) + if isinstance(verdict, InboundLadderRejection): + await _write_rejection(verdict, req.id, scope, receive, send) + return + + mcp_param_rejection = await _mcp_param_rejection(app, request, req, verdict, lifespan_state) + if mcp_param_rejection is not None: + await _write_rejection(mcp_param_rejection, req.id, scope, receive, send) + return + + connection = Connection.from_envelope( + verdict.protocol_version, + verdict.client_info, + verdict.client_capabilities, + ) + dctx = _SingleExchangeDispatchContext( + transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers), + request_id=req.id, + message_metadata=ServerMessageMetadata(request_context=request), + progress_token=progress_token_from_params(req.params), + ) + + if json_response and req.method != "subscriptions/listen": + # A listen response IS a notification stream, so it always takes the + # SSE path below regardless of the JSON-response preference (the + # TypeScript and Go SDKs route it the same way). + msg = await _to_jsonrpc_response( + req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state) + ) + await _write(msg, scope, receive, send) + return + + send_ch, recv_ch = anyio.create_memory_object_stream[bytes](0) + dctx.sink = send_ch + result: list[JSONRPCResponse | JSONRPCError] = [] + + async def run_handler() -> None: + async with send_ch: + result.append( + await _to_jsonrpc_response( + req.id, + serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state), + ) + ) + + async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None: + while (await receive()).get("type") != "http.disconnect": + pass # pragma: no cover + cancel_scope.cancel() + + async with recv_ch, anyio.create_task_group() as tg: + tg.start_soon(run_handler) + tg.start_soon(watch_disconnect, tg.cancel_scope) + + event: bytes | None = None + done = False + with anyio.move_on_after(_SSE_PING_INTERVAL): + try: + event = await recv_ch.receive() + except anyio.EndOfStream: + done = True + + if done: + # Handler completed within the deferral window without emitting: + # `application/json` with the table-mapped status. Kernel-dispatch + # errors (METHOD_NOT_FOUND, missing-capability, INVALID_PARAMS) + # resolve here in practice. + await _write(result[0], scope, receive, send) + else: + # First notification arrived, or the deferral window elapsed: commit + # `text/event-stream` and start pinging so a proxy idle-read timeout + # cannot close the stream (which on this path cancels the handler). + await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS}) + while not done: + await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True}) + event = None + with anyio.move_on_after(_SSE_PING_INTERVAL): + try: + event = await recv_ch.receive() + except anyio.EndOfStream: + done = True + await send({"type": "http.response.body", "body": _sse_event(result[0]), "more_body": False}) + + tg.cancel_scope.cancel() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/apps.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..583e203ac0bc0d2a5a2a3776355f15cb04dd8978 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/apps.py @@ -0,0 +1,241 @@ +"""MCP Apps extension (`io.modelcontextprotocol/ui`). + +MCP Apps lets a tool carry a reference to an interactive UI: the tool's +`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served +with the `text/html;profile=mcp-app` MIME type) that the host renders in a +sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps +and the ext-apps spec for the wire format, and SEP-2133 for the extension framework. + +This is a self-contained, additive `Extension`: it contributes tools and +resources and advertises the capability, but does not intercept any core method. +A server opts in by passing an `Apps` instance to `MCPServer(extensions=[...])`. + + apps = Apps() + + @apps.tool(resource_uri="ui://clock/app.html", description="Current time") + def get_time(ctx: Context) -> str: + return datetime.now(timezone.utc).isoformat() + + apps.add_html_resource("ui://clock/app.html", CLOCK_HTML) + + mcp = MCPServer("clock", extensions=[apps]) + +Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should +still return meaningful text for clients that did not negotiate Apps. Use +`client_supports_apps(ctx)` to branch on the client's advertised support. (The SDK +keeps Apps in-core under `mcp.server.apps` rather than a separate package; the +TypeScript and C# SDKs ship it as a standalone package.) +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any, Literal, TypeVar + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, ResourceBinding, ToolBinding +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.resources import Resource, TextResource + +EXTENSION_ID = "io.modelcontextprotocol/ui" +"""The MCP Apps extension identifier (the shipped TS/C# constant).""" + +APP_MIME_TYPE = "text/html;profile=mcp-app" +"""MIME type for a `ui://` app resource.""" + +Visibility = Literal["model", "app"] +"""Where a UI-bound tool is surfaced (`_meta.ui.visibility`).""" + +_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) + + +class ResourcePermissions(BaseModel): + """Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`).""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + camera: dict[str, Any] | None = None + microphone: dict[str, Any] | None = None + geolocation: dict[str, Any] | None = None + clipboard_write: dict[str, Any] | None = None + + +class ResourceCsp(BaseModel): + """Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`).""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + connect_domains: list[str] | None = None + resource_domains: list[str] | None = None + frame_domains: list[str] | None = None + base_uri_domains: list[str] | None = None + + +class Apps(Extension): + """The MCP Apps extension: bind tools to `ui://` UI resources. + + Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML + with `add_html_resource(...)`, then pass the instance to + `MCPServer(extensions=[apps])`. + """ + + identifier = EXTENSION_ID + + def __init__(self) -> None: + self._tools: list[tuple[ToolBinding, str]] = [] # (binding, bound resource_uri) + self._resources: list[ResourceBinding] = [] + + def tool( + self, + *, + resource_uri: str, + visibility: Sequence[Visibility] | None = None, + meta: dict[str, Any] | None = None, + **tool_kwargs: Any, + ) -> Callable[[_CallableT], _CallableT]: + """Decorator registering a tool bound to a `ui://` resource. + + Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the + tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title, + description, annotations, ...); pass `meta=` to merge extra `_meta` keys + alongside the `ui` entry. + + Args: + resource_uri: The `ui://` URI of the UI resource this tool renders. + visibility: Where the tool is surfaced (`["model", "app"]`). + meta: Additional `_meta` keys to merge with the `ui` entry. + + Raises: + ValueError: If `resource_uri` does not use the `ui://` scheme, or + `meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`). + """ + _require_ui_scheme(resource_uri) + if meta and "ui" in meta: + raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key") + ui: dict[str, Any] = {"resourceUri": resource_uri} + if visibility is not None: + ui["visibility"] = list(visibility) + + def decorator(fn: _CallableT) -> _CallableT: + binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs) + self._tools.append((binding, resource_uri)) + return fn + + return decorator + + def add_html_resource( + self, + uri: str, + html: str, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + csp: ResourceCsp | None = None, + permissions: ResourcePermissions | None = None, + domain: str | None = None, + prefers_border: bool | None = None, + ) -> None: + """Register a `ui://` HTML resource served as `text/html;profile=mcp-app`. + + `csp`, `permissions`, `domain`, and `prefers_border` populate the + resource's `_meta.ui` per the ext-apps spec. + + Args: + uri: The `ui://` URI; a tool references it via `resource_uri`. + html: The HTML document the host renders. + + Raises: + ValueError: If `uri` does not use the `ui://` scheme. + """ + ui: dict[str, Any] = {} + if csp is not None: + ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True) + if permissions is not None: + ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True) + if domain is not None: + ui["domain"] = domain + if prefers_border is not None: + ui["prefersBorder"] = prefers_border + self.add_resource( + TextResource( + uri=uri, + name=name or uri, + title=title, + description=description, + mime_type=APP_MIME_TYPE, + meta={"ui": ui} if ui else None, + text=html, + ) + ) + + def add_resource(self, resource: Resource) -> None: + """Register a pre-built `ui://` resource. + + The escape hatch for resources `add_html_resource` cannot express (e.g. a + `FileResource` serving HTML from disk). A resource without an explicit + `mime_type` is served as `text/html;profile=mcp-app` โ€” hosts will not + render a `ui://` resource under any other MIME type, so an explicit + mismatch is rejected. + + Raises: + ValueError: If the resource URI does not use the `ui://` scheme, or + its explicit `mime_type` is not `text/html;profile=mcp-app`. + """ + _require_ui_scheme(resource.uri) + if "mime_type" not in resource.model_fields_set: + resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE}) + elif resource.mime_type != APP_MIME_TYPE: + raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}") + self._resources.append(ResourceBinding(resource=resource)) + + def tools(self) -> Sequence[ToolBinding]: + """The bound tools. + + Raises: + ValueError: If a tool's `resource_uri` has no matching resource + registered on this instance โ€” a tool advertising a + `_meta.ui.resourceUri` that 404s on `resources/read` is a + misconfiguration, caught when the server consumes the extension. + """ + registered = {binding.resource.uri for binding in self._resources} + for tool, uri in self._tools: + if uri not in registered: + raise ValueError( + f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource " + "is registered; add it with add_html_resource() or add_resource()" + ) + return [tool for tool, _ in self._tools] + + def resources(self) -> Sequence[ResourceBinding]: + return self._resources + + +def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool: + """Whether the connected client negotiated MCP Apps support. + + Returns `True` only when the client advertised the extension AND listed the + `text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool + can fall back to text-only output otherwise. + """ + capabilities = _client_capabilities(ctx) + extensions = capabilities.extensions if capabilities else None + settings = extensions.get(EXTENSION_ID) if extensions else None + if settings is None: + return False + mime_types = settings.get("mimeTypes") + return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types + + +def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any: + if isinstance(ctx, Context): + return ctx.client_capabilities + return ctx.session.client_capabilities + + +def _require_ui_scheme(uri: str) -> None: + if not uri.startswith("ui://"): + raise ValueError(f"MCP Apps URIs must use the ui:// scheme, got {uri!r}") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..61b60e3487bfebae1b345831aa38d9521460846d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/__init__.py @@ -0,0 +1 @@ +"""MCP OAuth server authorization components.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/errors.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..117deea83aaa381f1bffec26771a925679e848ac --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/errors.py @@ -0,0 +1,5 @@ +from pydantic import ValidationError + + +def stringify_pydantic_error(validation_error: ValidationError) -> str: + return "\n".join(f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in validation_error.errors()) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fd8a462b3790d26f62e3adf40668c2da4394cf15 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/__init__.py @@ -0,0 +1 @@ +"""Request handlers for MCP authorization endpoints.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/authorize.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/authorize.py new file mode 100644 index 0000000000000000000000000000000000000000..5cf93cf8c280a881a9010cb764a9d18d0e846356 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/authorize.py @@ -0,0 +1,225 @@ +import logging +from dataclasses import dataclass +from typing import Any, Literal + +# TODO(Marcelo): We should drop the `RootModel`. +from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError # noqa: TID251 +from starlette.datastructures import FormData, QueryParams +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response + +from mcp.server.auth.errors import stringify_pydantic_error +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.provider import ( + AuthorizationErrorCode, + AuthorizationParams, + AuthorizeError, + OAuthAuthorizationServerProvider, + construct_redirect_uri, +) +from mcp.shared.auth import InvalidRedirectUriError, InvalidScopeError + +logger = logging.getLogger(__name__) + + +class AuthorizationRequest(BaseModel): + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + client_id: str = Field(..., description="The client ID") + redirect_uri: AnyUrl | None = Field(None, description="URL to redirect to after authorization") + + # see OAuthClientMetadata; we only support `code` + response_type: Literal["code"] = Field(..., description="Must be 'code' for authorization code flow") + code_challenge: str = Field(..., description="PKCE code challenge") + code_challenge_method: Literal["S256"] = Field("S256", description="PKCE code challenge method, must be S256") + state: str | None = Field(None, description="Optional state parameter") + scope: str | None = Field( + None, + description="Optional scope; if specified, should be a space-separated list of scope strings", + ) + resource: str | None = Field( + None, + description="RFC 8707 resource indicator - the MCP server this token will be used with", + ) + + +class AuthorizationErrorResponse(BaseModel): + error: AuthorizationErrorCode + error_description: str | None + error_uri: AnyUrl | None = None + # must be set if provided in the request + state: str | None = None + + +def best_effort_extract_string(key: str, params: None | FormData | QueryParams) -> str | None: + if params is None: # pragma: no cover + return None + value = params.get(key) + if isinstance(value, str): + return value + return None + + +class AnyUrlModel(RootModel[AnyUrl]): + root: AnyUrl + + +@dataclass +class AuthorizationHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + + async def handle(self, request: Request) -> Response: + # implements authorization requests for grant_type=code; + # see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + + state = None + redirect_uri = None + client = None + params = None + + async def error_response( + error: AuthorizationErrorCode, + error_description: str | None, + attempt_load_client: bool = True, + ): + # Error responses take two different formats: + # 1. The request has a valid client ID & redirect_uri: we issue a redirect + # back to the redirect_uri with the error response fields as query + # parameters. This allows the client to be notified of the error. + # 2. Otherwise, we return an error response directly to the end user; + # we choose to do so in JSON, but this is left undefined in the + # specification. + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 + # + # This logic is a bit awkward to handle, because the error might be thrown + # very early in request validation, before we've done the usual Pydantic + # validation, loaded the client, etc. To handle this, error_response() + # contains fallback logic which attempts to load the parameters directly + # from the request. + + nonlocal client, redirect_uri, state + if client is None and attempt_load_client: + # make last-ditch attempt to load the client + client_id = best_effort_extract_string("client_id", params) + client = await self.provider.get_client(client_id) if client_id else None + if redirect_uri is None and client: + # make last-ditch effort to load the redirect uri + try: + if params is not None and "redirect_uri" not in params: + raw_redirect_uri = None + else: + raw_redirect_uri = AnyUrlModel.model_validate( + best_effort_extract_string("redirect_uri", params) + ).root + redirect_uri = client.validate_redirect_uri(raw_redirect_uri) + except (ValidationError, InvalidRedirectUriError): + # if the redirect URI is invalid, ignore it & just return the + # initial error + pass + + # the error response MUST contain the state specified by the client, if any + if state is None: + # make last-ditch effort to load state + state = best_effort_extract_string("state", params) + + error_resp = AuthorizationErrorResponse( + error=error, + error_description=error_description, + state=state, + ) + + if redirect_uri and client: + return RedirectResponse( + url=construct_redirect_uri(str(redirect_uri), **error_resp.model_dump(exclude_none=True)), + status_code=302, + headers={"Cache-Control": "no-store"}, + ) + else: + return PydanticJSONResponse( + status_code=400, + content=error_resp, + headers={"Cache-Control": "no-store"}, + ) + + try: + # Parse request parameters + if request.method == "GET": + # Convert query_params to dict for pydantic validation + params = request.query_params + else: + # Parse form data for POST requests + params = await request.form() + + # Save state if it exists, even before validation + state = best_effort_extract_string("state", params) + + try: + auth_request = AuthorizationRequest.model_validate(params) + state = auth_request.state # Update with validated state + except ValidationError as validation_error: + error: AuthorizationErrorCode = "invalid_request" + for e in validation_error.errors(): + if e["loc"] == ("response_type",) and e["type"] == "literal_error": + error = "unsupported_response_type" + break + return await error_response(error, stringify_pydantic_error(validation_error)) + + # Get client information + client = await self.provider.get_client( + auth_request.client_id, + ) + if not client: + # For client_id validation errors, return direct error (no redirect) + return await error_response( + error="invalid_request", + error_description=f"Client ID '{auth_request.client_id}' not found", + attempt_load_client=False, + ) + + # Validate redirect_uri against client's registered URIs + try: + redirect_uri = client.validate_redirect_uri(auth_request.redirect_uri) + except InvalidRedirectUriError as validation_error: + # For redirect_uri validation errors, return direct error (no redirect) + return await error_response( + error="invalid_request", + error_description=validation_error.message, + ) + + # Validate scope - for scope errors, we can redirect + try: + scopes = client.validate_scope(auth_request.scope) + except InvalidScopeError as validation_error: + # For scope errors, redirect with error parameters + return await error_response( + error="invalid_scope", + error_description=validation_error.message, + ) + + # Setup authorization parameters + auth_params = AuthorizationParams( + state=state, + scopes=scopes, + code_challenge=auth_request.code_challenge, + redirect_uri=redirect_uri, + redirect_uri_provided_explicitly=auth_request.redirect_uri is not None, + resource=auth_request.resource, # RFC 8707 + ) + + try: + # Let the provider pick the next URI to redirect to + return RedirectResponse( + url=await self.provider.authorize( + client, + auth_params, + ), + status_code=302, + headers={"Cache-Control": "no-store"}, + ) + except AuthorizeError as e: + # Handle authorization errors as defined in RFC 6749 Section 4.1.2.1 + return await error_response(error=e.error, error_description=e.error_description) + + except Exception as validation_error: # pragma: no cover + # Catch-all for unexpected errors + logger.exception("Unexpected error in authorization_handler", exc_info=validation_error) + return await error_response(error="server_error", error_description="An unexpected error occurred") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/metadata.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..f12644215007d3700a0b4c345aedcde89503ff3c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/metadata.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass + +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata + + +@dataclass +class MetadataHandler: + metadata: OAuthMetadata + + async def handle(self, request: Request) -> Response: + return PydanticJSONResponse( + content=self.metadata, + headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour + ) + + +@dataclass +class ProtectedResourceMetadataHandler: + metadata: ProtectedResourceMetadata + + async def handle(self, request: Request) -> Response: + return PydanticJSONResponse( + content=self.metadata, + headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/register.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/register.py new file mode 100644 index 0000000000000000000000000000000000000000..7fb14b2c4367cbe0840b10b5514d00a3131cc7d2 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/register.py @@ -0,0 +1,154 @@ +import secrets +import time +from dataclasses import dataclass +from typing import Any +from uuid import uuid4 + +from pydantic import BaseModel, ValidationError +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.errors import stringify_pydantic_error +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode +from mcp.server.auth.settings import ClientRegistrationOptions +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthClientMetadata + +# this alias is a no-op; it's just to separate out the types exposed to the +# provider from what we use in the HTTP handler +RegistrationRequest = OAuthClientMetadata + + +class RegistrationErrorResponse(BaseModel): + error: RegistrationErrorCode + error_description: str | None + + +@dataclass +class RegistrationHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + options: ClientRegistrationOptions + + async def handle(self, request: Request) -> Response: + # Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1 + try: + body = await request.body() + client_metadata = OAuthClientMetadata.model_validate_json(body) + + # Scope validation is handled below + except ValidationError as validation_error: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description=stringify_pydantic_error(validation_error), + ), + status_code=400, + ) + + client_id = str(uuid4()) + + # If auth method is None, default to client_secret_post + if client_metadata.token_endpoint_auth_method is None: + client_metadata.token_endpoint_auth_method = "client_secret_post" + # This server authenticates token requests with the client secret it mints; it holds + # no client key to verify a private_key_jwt assertion, so confirming that method would + # register a client whose every token request is then rejected. Refuse it instead + # (RFC 7591 ยง3.2.2), before minting credentials the client could never use. + if client_metadata.token_endpoint_auth_method == "private_key_jwt": + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="token_endpoint_auth_method 'private_key_jwt' is not supported", + ), + status_code=400, + ) + + client_secret = None + if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch + # cryptographically secure random 32-byte hex string + client_secret = secrets.token_hex(32) + + if client_metadata.scope is None and self.options.default_scopes is not None: + client_metadata.scope = " ".join(self.options.default_scopes) + elif client_metadata.scope is not None and self.options.valid_scopes is not None: + requested_scopes = set(client_metadata.scope.split()) + valid_scopes = set(self.options.valid_scopes) + if not requested_scopes.issubset(valid_scopes): # pragma: no branch + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="Requested scopes are not valid: " + f"{', '.join(requested_scopes - valid_scopes)}", + ), + status_code=400, + ) + if "authorization_code" not in client_metadata.grant_types: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="grant_types must include 'authorization_code'", + ), + status_code=400, + ) + + # SEP-990 ยง5.1 / draft-ietf-oauth-identity-assertion-authz-grant ยง8.1: the ID-JAG flow is + # for confidential clients provisioned out of band. Refuse to grant it through DCR so a + # self-registered client cannot reach the identity-assertion provider hook. + if JWT_BEARER_GRANT_TYPE in client_metadata.grant_types: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description=( + f"grant_types must not include '{JWT_BEARER_GRANT_TYPE}'; " + "the identity-assertion grant requires a pre-registered client" + ), + ), + status_code=400, + ) + + # The MCP spec requires servers to use the authorization `code` flow + # with PKCE + if "code" not in client_metadata.response_types: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="response_types must include 'code' for authorization_code grant", + ), + status_code=400, + ) + + client_id_issued_at = int(time.time()) + # RFC 7591 ยง3.2.1: client_secret_expires_at is REQUIRED whenever a client_secret is + # issued, with 0 (not omission) meaning it never expires; a public client gets none. + client_secret_expires_at = None + if client_secret is not None: + client_secret_expires_at = ( + client_id_issued_at + self.options.client_secret_expiry_seconds + if self.options.client_secret_expiry_seconds is not None + else 0 + ) + + # RFC 7591 ยง3.2.1: the response returns all registered metadata about the client, so + # the record is the whole validated request plus the credentials minted here - built + # from the request's dump so no metadata field can be silently omitted from the echo. + client_info = OAuthClientInformationFull.model_validate( + { + **client_metadata.model_dump(), + "client_id": client_id, + "client_id_issued_at": client_id_issued_at, + "client_secret": client_secret, + "client_secret_expires_at": client_secret_expires_at, + } + ) + try: + # Register client + await self.provider.register_client(client_info) + + # Return client information + return PydanticJSONResponse(content=client_info, status_code=201) + except RegistrationError as e: + # Handle registration errors as defined in RFC 7591 Section 3.2.2 + return PydanticJSONResponse( + content=RegistrationErrorResponse(error=e.error, error_description=e.error_description), + status_code=400, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/revoke.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/revoke.py new file mode 100644 index 0000000000000000000000000000000000000000..4efd1540019bbee298215853191c338e72648577 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/revoke.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass +from functools import partial +from typing import Any, Literal + +from pydantic import BaseModel, ValidationError +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.errors import ( + stringify_pydantic_error, +) +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator +from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, RefreshToken + + +class RevocationRequest(BaseModel): + """See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1""" + + token: str + token_type_hint: Literal["access_token", "refresh_token"] | None = None + client_id: str + client_secret: str | None + + +class RevocationErrorResponse(BaseModel): + error: Literal["invalid_request", "unauthorized_client"] + error_description: str | None = None + + +@dataclass +class RevocationHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + client_authenticator: ClientAuthenticator + + async def handle(self, request: Request) -> Response: + """Handler for the OAuth 2.0 Token Revocation endpoint.""" + try: + client = await self.client_authenticator.authenticate_request(request) + except AuthenticationError as e: # pragma: no cover + return PydanticJSONResponse( + status_code=401, + content=RevocationErrorResponse( + error="unauthorized_client", + error_description=e.message, + ), + ) + + try: + form_data = await request.form() + revocation_request = RevocationRequest.model_validate(dict(form_data)) + except ValidationError as e: + return PydanticJSONResponse( + status_code=400, + content=RevocationErrorResponse( + error="invalid_request", + error_description=stringify_pydantic_error(e), + ), + ) + + loaders = [ + self.provider.load_access_token, + partial(self.provider.load_refresh_token, client), + ] + if revocation_request.token_type_hint == "refresh_token": # pragma: no cover + loaders = reversed(loaders) + + token: None | AccessToken | RefreshToken = None + for loader in loaders: + token = await loader(revocation_request.token) + if token is not None: + break + + # if token is not found, just return HTTP 200 per the RFC + if token and token.client_id == client.client_id: + # Revoke token; provider is not meant to be able to do validation + # at this point that would result in an error + await self.provider.revoke_token(token) + + # Return successful empty response + return Response( + status_code=200, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/token.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/token.py new file mode 100644 index 0000000000000000000000000000000000000000..0e644c378ab111a8787b4fe56700cdc4fe6620fc --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/token.py @@ -0,0 +1,275 @@ +import base64 +import hashlib +import time +from dataclasses import dataclass +from typing import Annotated, Any, Literal + +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, TypeAdapter, ValidationError +from starlette.requests import Request + +from mcp.server.auth.errors import stringify_pydantic_error +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator +from mcp.server.auth.provider import ( + IdentityAssertionParams, + OAuthAuthorizationServerProvider, + TokenError, + TokenErrorCode, +) +from mcp.shared.auth import OAuthToken + + +class AuthorizationCodeRequest(BaseModel): + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + grant_type: Literal["authorization_code"] + code: str = Field(..., description="The authorization code") + redirect_uri: AnyUrl | None = Field(None, description="Must be the same as redirect URI provided in /authorize") + client_id: str + # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + client_secret: str | None = None + # See https://datatracker.ietf.org/doc/html/rfc7636#section-4.5 + code_verifier: str = Field(..., description="PKCE code verifier") + # RFC 8707 resource indicator + resource: str | None = Field(None, description="Resource indicator for the token") + + +class RefreshTokenRequest(BaseModel): + # See https://datatracker.ietf.org/doc/html/rfc6749#section-6 + grant_type: Literal["refresh_token"] + refresh_token: str = Field(..., description="The refresh token") + scope: str | None = Field(None, description="Optional scope parameter") + client_id: str + # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + client_secret: str | None = None + # RFC 8707 resource indicator + resource: str | None = Field(None, description="Resource indicator for the token") + + +class JwtBearerRequest(BaseModel): + # RFC 7523 ยง2.1 JWT bearer authorization grant. SEP-990 leg 2: the client presents the + # enterprise IdP-issued ID-JAG to the MCP authorization server as the `assertion`. + grant_type: Literal["urn:ietf:params:oauth:grant-type:jwt-bearer"] + # See https://datatracker.ietf.org/doc/html/rfc7523#section-2.1 + assertion: str = Field(..., description="The ID-JAG (a signed JWT) being presented as the grant") + scope: str | None = Field(None, description="Optional scope parameter") + client_id: str + # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + client_secret: str | None = None + # RFC 8707 resource indicator + resource: str | None = Field(None, description="Resource indicator for the token") + + +TokenRequest = Annotated[ + AuthorizationCodeRequest | RefreshTokenRequest | JwtBearerRequest, + Field(discriminator="grant_type"), +] +token_request_adapter = TypeAdapter[TokenRequest](TokenRequest) + + +class TokenErrorResponse(BaseModel): + """See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2""" + + error: TokenErrorCode + error_description: str | None = None + error_uri: AnyHttpUrl | None = None + + +# this is just an alias over OAuthToken; the only reason we do this +# is to have some separation between the HTTP response type, and the +# type returned by the provider +TokenSuccessResponse = OAuthToken + + +@dataclass +class TokenHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + client_authenticator: ClientAuthenticator + identity_assertion_enabled: bool = False + + def response(self, obj: TokenSuccessResponse | TokenErrorResponse): + status_code = 200 + if isinstance(obj, TokenErrorResponse): + status_code = 400 + + return PydanticJSONResponse( + content=obj, + status_code=status_code, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) + + async def handle(self, request: Request): + try: + client_info = await self.client_authenticator.authenticate_request(request) + except AuthenticationError as e: + # Authentication failures should return 401 + return PydanticJSONResponse( + content=TokenErrorResponse( + error="invalid_client", + error_description=e.message, + ), + status_code=401, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) + + try: + form_data = await request.form() + # TODO(Marcelo): Can someone check if this `dict()` wrapper is necessary? + token_request = token_request_adapter.validate_python(dict(form_data)) + except ValidationError as validation_error: + return self.response( + TokenErrorResponse( + error="invalid_request", + error_description=stringify_pydantic_error(validation_error), + ) + ) + + if token_request.grant_type not in client_info.grant_types: + return self.response( + TokenErrorResponse( + error="unsupported_grant_type", + error_description=(f"Unsupported grant type (supported grant types are {client_info.grant_types})"), + ) + ) + + tokens: OAuthToken + + match token_request: + case AuthorizationCodeRequest(): + auth_code = await self.provider.load_authorization_code(client_info, token_request.code) + if auth_code is None or auth_code.client_id != token_request.client_id: + # if code belongs to different client, pretend it doesn't exist + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="authorization code does not exist", + ) + ) + + # make auth codes expire after a deadline + # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.5 + if auth_code.expires_at < time.time(): + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="authorization code has expired", + ) + ) + + # verify redirect_uri doesn't change between /authorize and /tokens + # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6 + if auth_code.redirect_uri_provided_explicitly: + authorize_request_redirect_uri = auth_code.redirect_uri + else: # pragma: no cover + authorize_request_redirect_uri = None + + # Convert both sides to strings for comparison to handle AnyUrl vs string issues + token_redirect_str = str(token_request.redirect_uri) if token_request.redirect_uri is not None else None + auth_redirect_str = ( + str(authorize_request_redirect_uri) if authorize_request_redirect_uri is not None else None + ) + + if token_redirect_str != auth_redirect_str: + return self.response( + TokenErrorResponse( + error="invalid_request", + error_description=("redirect_uri did not match the one used when creating auth code"), + ) + ) + + # Verify PKCE code verifier + sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest() + hashed_code_verifier = base64.urlsafe_b64encode(sha256).decode().rstrip("=") + + if hashed_code_verifier != auth_code.code_challenge: + # see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6 + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="incorrect code_verifier", + ) + ) + + try: + # Exchange authorization code for tokens + tokens = await self.provider.exchange_authorization_code(client_info, auth_code) + except TokenError as e: + return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) + + case RefreshTokenRequest(): + refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token) + if refresh_token is None or refresh_token.client_id != token_request.client_id: + # if token belongs to different client, pretend it doesn't exist + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="refresh token does not exist", + ) + ) + + if refresh_token.expires_at and refresh_token.expires_at < time.time(): + # if the refresh token has expired, pretend it doesn't exist + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="refresh token has expired", + ) + ) + + # Parse scopes if provided + scopes = token_request.scope.split(" ") if token_request.scope else refresh_token.scopes + + for scope in scopes: + if scope not in refresh_token.scopes: + return self.response( + TokenErrorResponse( + error="invalid_scope", + error_description=(f"cannot request scope `{scope}` not provided by refresh token"), + ) + ) + + try: + # Exchange refresh token for new tokens + tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes) + except TokenError as e: + return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) + + case JwtBearerRequest(): # pragma: no branch + if not self.identity_assertion_enabled: + return self.response( + TokenErrorResponse( + error="unsupported_grant_type", + error_description="The JWT bearer grant is not supported by this authorization server", + ) + ) + + # SEP-990 ยง5.1: only confidential clients may present an ID-JAG. ClientAuthenticator + # already rejects a secret-based method with no stored secret; this additionally + # rejects the public `none` method so an unauthenticated client never reaches the + # provider hook. + if not client_info.client_secret: + # RFC 6749 ยง5.2: the client authenticated but is not permitted this grant, so + # unauthorized_client (not invalid_client, which is for failed authentication). + return self.response( + TokenErrorResponse( + error="unauthorized_client", + error_description="The JWT bearer grant requires a confidential client", + ) + ) + + params = IdentityAssertionParams( + assertion=token_request.assertion, + scopes=token_request.scope.split(" ") if token_request.scope else None, + resource=token_request.resource, + ) + try: + tokens = await self.provider.exchange_identity_assertion(client_info, params) + except TokenError as e: + return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) + + return self.response(tokens) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/json_response.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/json_response.py new file mode 100644 index 0000000000000000000000000000000000000000..bd95bd693b8bfa80296d5b4d4849f7ea0db768de --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/json_response.py @@ -0,0 +1,10 @@ +from typing import Any + +from starlette.responses import JSONResponse + + +class PydanticJSONResponse(JSONResponse): + # use pydantic json serialization instead of the stock `json.dumps`, + # so that we can handle serializing pydantic models like AnyHttpUrl + def render(self, content: Any) -> bytes: + return content.model_dump_json(exclude_none=True).encode("utf-8") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ab07d8416128025995fe191519fbaaa382bedf1f --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/__init__.py @@ -0,0 +1 @@ +"""Middleware for MCP authorization.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/auth_context.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/auth_context.py new file mode 100644 index 0000000000000000000000000000000000000000..1d34a5546b58988898f3b06e9c6d2353db37429b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/auth_context.py @@ -0,0 +1,46 @@ +import contextvars + +from starlette.types import ASGIApp, Receive, Scope, Send + +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken + +# Create a contextvar to store the authenticated user +# The default is None, indicating no authenticated user is present +auth_context_var = contextvars.ContextVar[AuthenticatedUser | None]("auth_context", default=None) + + +def get_access_token() -> AccessToken | None: + """Get the access token from the current context. + + Returns: + The access token if an authenticated user is available, None otherwise. + """ + auth_user = auth_context_var.get() + return auth_user.access_token if auth_user else None + + +class AuthContextMiddleware: + """Middleware that extracts the authenticated user from the request + and sets it in a contextvar for easy access throughout the request lifecycle. + + This middleware should be added after the AuthenticationMiddleware in the + middleware stack to ensure that the user is properly authenticated before + being stored in the context. + """ + + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + user = scope.get("user") + if isinstance(user, AuthenticatedUser): + # Set the authenticated user in the contextvar + token = auth_context_var.set(user) + try: + await self.app(scope, receive, send) + finally: + auth_context_var.reset(token) + else: + # No authenticated user, just process the request + await self.app(scope, receive, send) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/bearer_auth.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/bearer_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..29413abf2b8a56f5a3bd63f573a54c342c0167b4 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/bearer_auth.py @@ -0,0 +1,143 @@ +import json +import time +from typing import Any, TypedDict + +from pydantic import AnyHttpUrl +from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser +from starlette.requests import HTTPConnection +from starlette.types import Receive, Scope, Send + +from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components + + +class AuthenticatedUser(SimpleUser): + """User with authentication info.""" + + def __init__(self, auth_info: AccessToken): + super().__init__(auth_info.client_id) + self.access_token = auth_info + self.scopes = auth_info.scopes + + +class AuthorizationContext(TypedDict): + client_id: str + issuer: str | None + subject: str | None + + +def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: + """Identify the principal `user` represents, for transports to compare + against the principal that created a session. Components the token + verifier does not supply are `None`, so the comparison degrades to the + remaining components. + + See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for + a verifier that populates `subject` and `claims` from an introspection + response.""" + client_id, issuer, subject = principal_components(user.access_token) + return AuthorizationContext(client_id=client_id, issuer=issuer, subject=subject) + + +class BearerAuthBackend(AuthenticationBackend): + """Authentication backend that validates Bearer tokens using a TokenVerifier.""" + + def __init__(self, token_verifier: TokenVerifier): + self.token_verifier = token_verifier + + async def authenticate(self, conn: HTTPConnection): + auth_header = next( + (conn.headers.get(key) for key in conn.headers if key.lower() == "authorization"), + None, + ) + if not auth_header or not auth_header.lower().startswith("bearer "): + return None + + token = auth_header[7:] # Remove "Bearer " prefix + + # Validate the token with the verifier + auth_info = await self.token_verifier.verify_token(token) + + if not auth_info: + return None + + if auth_info.expires_at and auth_info.expires_at < int(time.time()): + return None + + return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info) + + +class RequireAuthMiddleware: + """Middleware that requires a valid Bearer token in the Authorization header. + + This will validate the token with the auth provider and store the resulting + auth info in the request state. + """ + + def __init__( + self, + app: Any, + required_scopes: list[str], + resource_metadata_url: AnyHttpUrl | None = None, + ): + """Initialize the middleware. + + Args: + app: ASGI application + required_scopes: List of scopes that the token must have + resource_metadata_url: Optional protected resource metadata URL for WWW-Authenticate header + """ + self.app = app + self.required_scopes = required_scopes + self.resource_metadata_url = resource_metadata_url + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + auth_user = scope.get("user") + if not isinstance(auth_user, AuthenticatedUser): + await self._send_auth_error( + send, status_code=401, error="invalid_token", description="Authentication required" + ) + return + + auth_credentials = scope.get("auth") + + for required_scope in self.required_scopes: + # auth_credentials should always be provided; this is just paranoia + if auth_credentials is None or required_scope not in auth_credentials.scopes: + await self._send_auth_error( + send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}" + ) + return + + await self.app(scope, receive, send) + + async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None: + """Send an authentication error response with WWW-Authenticate header.""" + # Build WWW-Authenticate header value + www_auth_parts = [f'error="{error}"', f'error_description="{description}"'] + if self.resource_metadata_url: + www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') + + www_authenticate = f"Bearer {', '.join(www_auth_parts)}" + + # Send response + body = {"error": error, "error_description": description} + body_bytes = json.dumps(body).encode() + + await send( + { + "type": "http.response.start", + "status": status_code, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body_bytes)).encode()), + (b"www-authenticate", www_authenticate.encode()), + ], + } + ) + + await send( + { + "type": "http.response.body", + "body": body_bytes, + } + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/client_auth.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/client_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..3d5067d61118992b81c05cc9f1cc423a9e73885b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/client_auth.py @@ -0,0 +1,119 @@ +import base64 +import binascii +import hmac +import time +from typing import Any +from urllib.parse import unquote + +from starlette.requests import Request + +from mcp.server.auth.provider import OAuthAuthorizationServerProvider +from mcp.shared.auth import OAuthClientInformationFull + + +class AuthenticationError(Exception): + def __init__(self, message: str): + self.message = message + + +class ClientAuthenticator: + """ClientAuthenticator is a callable which validates requests from a client + application, used to verify /token calls. + + If, during registration, the client requested to be issued a secret, the + authenticator asserts that /token calls must be authenticated with + that same secret. + + NOTE: clients can opt for no authentication during registration, in which case this + logic is skipped. + """ + + def __init__(self, provider: OAuthAuthorizationServerProvider[Any, Any, Any]): + """Initialize the authenticator. + + Args: + provider: Provider to look up client information + """ + self.provider = provider + + async def authenticate_request(self, request: Request) -> OAuthClientInformationFull: + """Authenticate a client from an HTTP request. + + Extracts client credentials from the appropriate location based on the + client's registered authentication method and validates them. + + Args: + request: The HTTP request containing client credentials + + Returns: + The authenticated client information + + Raises: + AuthenticationError: If authentication fails + """ + form_data = await request.form() + client_id = form_data.get("client_id") + if not client_id: + raise AuthenticationError("Missing client_id") + + client = await self.provider.get_client(str(client_id)) + if not client: + raise AuthenticationError("Invalid client_id") # pragma: no cover + + request_client_secret: str | None = None + auth_header = request.headers.get("Authorization", "") + + if client.token_endpoint_auth_method == "client_secret_basic": + if not auth_header.startswith("Basic "): + raise AuthenticationError("Missing or invalid Basic authentication in Authorization header") + + try: + encoded_credentials = auth_header[6:] # Remove "Basic " prefix + decoded = base64.b64decode(encoded_credentials).decode("utf-8") + if ":" not in decoded: + raise ValueError("Invalid Basic auth format") + basic_client_id, request_client_secret = decoded.split(":", 1) + + # URL-decode both parts per RFC 6749 Section 2.3.1 + basic_client_id = unquote(basic_client_id) + request_client_secret = unquote(request_client_secret) + + if basic_client_id != client_id: + raise AuthenticationError("Client ID mismatch in Basic auth") + except (ValueError, UnicodeDecodeError, binascii.Error): + raise AuthenticationError("Invalid Basic authentication header") + + elif client.token_endpoint_auth_method == "client_secret_post": + raw_form_data = form_data.get("client_secret") + # form_data.get() can return an UploadFile or None, so we need to check if it's a string + if isinstance(raw_form_data, str): + request_client_secret = str(raw_form_data) + + elif client.token_endpoint_auth_method == "none": + request_client_secret = None + else: + raise AuthenticationError( # pragma: no cover + f"Unsupported auth method: {client.token_endpoint_auth_method}" + ) + + # A client registered for a secret-based auth method but with no stored secret is + # misconfigured: nothing was actually verified above, so it must not pass authentication. + if client.token_endpoint_auth_method != "none" and not client.client_secret: + raise AuthenticationError("Client is registered for secret-based authentication but has no stored secret") + + # If client from the store expects a secret, validate that the request provides + # that secret + if client.client_secret: + if not request_client_secret: + raise AuthenticationError("Client secret is required") + + # hmac.compare_digest requires that both arguments are either bytes or a `str` containing + # only ASCII characters. Since we do not control `request_client_secret`, we encode both + # arguments to bytes. + if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()): + raise AuthenticationError("Invalid client_secret") + + if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()): + raise AuthenticationError("Client secret has expired") # pragma: no cover + + return client diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/provider.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/provider.py new file mode 100644 index 0000000000000000000000000000000000000000..644868f3e5164e0fa32a9128dfc42ee88baa3e96 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/provider.py @@ -0,0 +1,371 @@ +from dataclasses import dataclass +from typing import Any, Generic, Literal, Protocol, TypeVar +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from pydantic import AnyUrl, BaseModel + +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class AuthorizationParams(BaseModel): + state: str | None + scopes: list[str] | None + code_challenge: str + redirect_uri: AnyUrl + redirect_uri_provided_explicitly: bool + resource: str | None = None # RFC 8707 resource indicator + + +class IdentityAssertionParams(BaseModel): + """Validated parameters of a SEP-990 identity-assertion (RFC 7523 jwt-bearer) request. + + Passed to ``OAuthAuthorizationServerProvider.exchange_identity_assertion``. ``assertion`` is the + ID-JAG (a signed JWT) the enterprise identity provider issued; the provider validates it per + RFC 7523 ยง3 and the SEP-990 ยง5.1 processing rules before issuing an access token. + """ + + assertion: str # RFC 7523 ยง2.1: the JWT (ID-JAG) presented as the authorization grant + scopes: list[str] | None = None + resource: str | None = None # RFC 8707 resource indicator from the token request + + +class AuthorizationCode(BaseModel): + code: str + scopes: list[str] + expires_at: float + client_id: str + code_challenge: str + redirect_uri: AnyUrl + redirect_uri_provided_explicitly: bool + resource: str | None = None # RFC 8707 resource indicator + subject: str | None = None # resource owner; propagate to the issued AccessToken + + +class RefreshToken(BaseModel): + token: str + client_id: str + scopes: list[str] + expires_at: int | None = None + subject: str | None = None # resource owner; propagate to refreshed AccessTokens + + +class AccessToken(BaseModel): + token: str + client_id: str + scopes: list[str] + expires_at: int | None = None + resource: str | None = None # RFC 8707 resource indicator + subject: str | None = None # RFC 7662/9068 `sub`: resource owner; unique only per issuer + claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`) + + +def principal_components(token: AccessToken) -> tuple[str, str | None, str | None]: + """The (client_id, issuer, subject) triple identifying the principal a token represents. + + The single source for "who is this token's principal": session ownership and + request-state binding both build on it. Components the token verifier does + not supply are `None`, so comparisons degrade to the remaining components. + """ + issuer = (token.claims or {}).get("iss") + return token.client_id, str(issuer) if issuer is not None else None, token.subject + + +RegistrationErrorCode = Literal[ + "invalid_redirect_uri", + "invalid_client_metadata", + "invalid_software_statement", + "unapproved_software_statement", +] + + +@dataclass(frozen=True) +class RegistrationError(Exception): + error: RegistrationErrorCode + error_description: str | None = None + + +AuthorizationErrorCode = Literal[ + "invalid_request", + "unauthorized_client", + "access_denied", + "unsupported_response_type", + "invalid_scope", + "server_error", + "temporarily_unavailable", + "invalid_target", +] + + +@dataclass(frozen=True) +class AuthorizeError(Exception): + error: AuthorizationErrorCode + error_description: str | None = None + + +TokenErrorCode = Literal[ + "invalid_request", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "invalid_scope", + # RFC 8707 ยง2: the requested resource (RFC 8707 indicator) is unknown or unsupported. + "invalid_target", +] + + +@dataclass(frozen=True) +class TokenError(Exception): + error: TokenErrorCode + error_description: str | None = None + + +class TokenVerifier(Protocol): + """Protocol for verifying bearer tokens.""" + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify a bearer token and return access info if valid.""" + + +# NOTE: MCPServer doesn't render any of these types in the user response, so it's +# OK to add fields to subclasses which should not be exposed externally. +AuthorizationCodeT = TypeVar("AuthorizationCodeT", bound=AuthorizationCode) +RefreshTokenT = TypeVar("RefreshTokenT", bound=RefreshToken) +AccessTokenT = TypeVar("AccessTokenT", bound=AccessToken) + + +class OAuthAuthorizationServerProvider(Protocol, Generic[AuthorizationCodeT, RefreshTokenT, AccessTokenT]): + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + """Retrieves client information by client ID. + + Implementors MAY raise NotImplementedError if dynamic client registration is + disabled in ClientRegistrationOptions. + + Args: + client_id: The ID of the client to retrieve. + + Returns: + The client information, or None if the client does not exist. + """ + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + """Saves client information as part of registering it. + + Implementors MAY raise NotImplementedError if dynamic client registration is + disabled in ClientRegistrationOptions. + + Args: + client_info: The client metadata to register. + + Raises: + RegistrationError: If the client metadata is invalid. + """ + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + """Handle the /authorize endpoint and return a URL that the client + will be redirected to. + + Many MCP implementations will redirect to a third-party provider to perform + a second OAuth exchange with that provider. In this sort of setup, the client + has an OAuth connection with the MCP server, and the MCP server has an OAuth + connection with the 3rd-party provider. At the end of this flow, the client + should be redirected to the redirect_uri from params.redirect_uri. + + +--------+ +------------+ +-------------------+ + | | | | | | + | Client | --> | MCP Server | --> | 3rd Party OAuth | + | | | | | Server | + +--------+ +------------+ +-------------------+ + | ^ | + +------------+ | | | + | | | | Redirect | + |redirect_uri|<-----+ +------------------+ + | | + +------------+ + + Implementations will need to define another handler on the MCP server's return + flow to perform the second redirect, and generate and store an authorization + code as part of completing the OAuth authorization step. + + Implementations SHOULD generate an authorization code with at least 160 bits of + entropy, + and MUST generate an authorization code with at least 128 bits of entropy. + See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10. + + Args: + client: The client requesting authorization. + params: The parameters of the authorization request. + + Returns: + A URL to redirect the client to for authorization. + + Raises: + AuthorizeError: If the authorization request is invalid. + """ + ... + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCodeT | None: + """Loads an AuthorizationCode by its code. + + Args: + client: The client that requested the authorization code. + authorization_code: The authorization code to get the challenge for. + + Returns: + The AuthorizationCode, or None if not found. + """ + ... + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCodeT + ) -> OAuthToken: + """Exchanges an authorization code for an access token and refresh token. + + Args: + client: The client exchanging the authorization code. + authorization_code: The authorization code to exchange. + + Returns: + The OAuth token, containing access and refresh tokens. + + Raises: + TokenError: If the request is invalid. + """ + ... + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshTokenT | None: + """Loads a RefreshToken by its token string. + + Args: + client: The client that is requesting to load the refresh token. + refresh_token: The refresh token string to load. + + Returns: + The RefreshToken object if found, or None if not found. + """ + ... + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshTokenT, + scopes: list[str], + ) -> OAuthToken: + """Exchanges a refresh token for an access token and refresh token. + + Implementations SHOULD rotate both the access token and refresh token. + + Args: + client: The client exchanging the refresh token. + refresh_token: The refresh token to exchange. + scopes: Optional scopes to request with the new access token. + + Returns: + The OAuth token, containing access and refresh tokens. + + Raises: + TokenError: If the request is invalid. + """ + ... + + async def load_access_token(self, token: str) -> AccessTokenT | None: + """Loads an access token by its token string. + + Args: + token: The access token to verify. + + Returns: + The access token, or None if the token is invalid. + """ + + async def revoke_token( + self, + token: AccessTokenT | RefreshTokenT, + ) -> None: + """Revokes an access or refresh token. + + If the given token is invalid or already revoked, this method should do nothing. + + Implementations SHOULD revoke both the access token and its corresponding + refresh token, regardless of which of the access token or refresh token is + provided. + + Args: + token: The token to revoke. + """ + + async def exchange_identity_assertion( + self, + client: OAuthClientInformationFull, + params: IdentityAssertionParams, + ) -> OAuthToken: + """Exchanges an Identity Assertion Authorization Grant (ID-JAG) for an access token. + + This is leg 2 of SEP-990: the client presents an ID-JAG - issued by the enterprise + identity provider - using the RFC 7523 ``urn:ietf:params:oauth:grant-type:jwt-bearer`` + grant, and receives an access token for this MCP server. The default implementation + rejects every request as an unsupported grant type; override it to enable the grant. + + The implementation is responsible for validating ``params.assertion`` per RFC 7523 ยง3 + and the SEP-990 ยง5.1 processing rules, in particular: + + - verify the JWT signature, ``iss``, and ``exp``, and that ``typ`` is ``oauth-id-jag+jwt``; + - require ``aud`` to identify this authorization server (its own issuer); + - require a ``sub`` (RFC 7523 ยง3 makes it mandatory) identifying the end user; + - reject replays - enforce ``exp``, and track ``jti`` for the assertion's lifetime; + - require the ID-JAG's ``client_id`` claim to match the authenticated ``client`` - do + NOT derive authorization from ``client.client_id`` alone, which for a confidential + client is authenticated but for any client is ultimately self-asserted in the request; + - audience-restrict the issued access token to the resource named in the ID-JAG's + ``resource`` claim, not merely ``params.resource`` (which the client controls); + - derive the granted scopes from the ID-JAG and policy rather than granting + ``params.scopes`` verbatim. + + The handler guarantees ``client`` is confidential (it rejects clients without a stored + secret before calling this hook), but the ID-JAG remains the authoritative grant. + + Args: + client: The authenticated client presenting the assertion. + params: The validated jwt-bearer request parameters (the ID-JAG and indicators). + + Returns: + The OAuth token, containing the issued access token. A refresh token SHOULD NOT be + issued: SEP-990 relies on the IdP to control session lifetime via re-issued ID-JAGs. + + Raises: + TokenError: If the assertion or request is invalid. Use ``invalid_grant`` for a + rejected assertion and ``invalid_target`` for an unknown ``resource``. + """ + raise TokenError( + error="unsupported_grant_type", + error_description="The JWT bearer grant is not supported by this authorization server", + ) + + +def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str: + parsed_uri = urlparse(redirect_uri_base) + query_params = [(k, v) for k, vs in parse_qs(parsed_uri.query).items() for v in vs] + for k, v in params.items(): + if v is not None: + query_params.append((k, v)) + + redirect_uri = urlunparse(parsed_uri._replace(query=urlencode(query_params))) + return redirect_uri + + +class ProviderTokenVerifier(TokenVerifier): + """Token verifier that uses an OAuthAuthorizationServerProvider. + + This is provided for backwards compatibility with existing auth_server_provider + configurations. For new implementations using AS/RS separation, consider using + the TokenVerifier protocol with a dedicated implementation like IntrospectionTokenVerifier. + """ + + def __init__(self, provider: "OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]"): + self.provider = provider + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token using the provider's load_access_token method.""" + return await self.provider.load_access_token(token) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/routes.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..fa88dddcf4681428b04c5d4db826dd6d9840f5c0 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/routes.py @@ -0,0 +1,262 @@ +from collections.abc import Awaitable, Callable +from typing import Any +from urllib.parse import urlparse + +from pydantic import AnyHttpUrl +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route, request_response # type: ignore +from starlette.types import ASGIApp + +from mcp.server.auth.handlers.authorize import AuthorizationHandler +from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler +from mcp.server.auth.handlers.register import RegistrationHandler +from mcp.server.auth.handlers.revoke import RevocationHandler +from mcp.server.auth.handlers.token import TokenHandler +from mcp.server.auth.middleware.client_auth import ClientAuthenticator +from mcp.server.auth.provider import OAuthAuthorizationServerProvider +from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthMetadata, ProtectedResourceMetadata +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER + + +def validate_issuer_url(url: AnyHttpUrl): + """Validate that the issuer URL meets OAuth 2.0 requirements. + + Args: + url: The issuer URL to validate. + + Raises: + ValueError: If the issuer URL is invalid. + """ + + # RFC 8414 requires HTTPS, but we allow loopback/localhost HTTP for testing + if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): + raise ValueError("Issuer URL must be HTTPS") + + # No fragments or query parameters allowed + if url.fragment: + raise ValueError("Issuer URL must not have a fragment") + if url.query: + raise ValueError("Issuer URL must not have a query string") + + +AUTHORIZATION_PATH = "/authorize" +TOKEN_PATH = "/token" +REGISTRATION_PATH = "/register" +REVOCATION_PATH = "/revoke" + +# SEP-990: leg 2 uses the RFC 7523 jwt-bearer grant; support is advertised as the ID-JAG profile. +ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag" + + +def cors_middleware( + handler: Callable[[Request], Response | Awaitable[Response]], + allow_methods: list[str], +) -> ASGIApp: + cors_app = CORSMiddleware( + app=request_response(handler), + allow_origins="*", + allow_methods=allow_methods, + allow_headers=[MCP_PROTOCOL_VERSION_HEADER], + ) + return cors_app + + +def create_auth_routes( + provider: OAuthAuthorizationServerProvider[Any, Any, Any], + issuer_url: AnyHttpUrl, + service_documentation_url: AnyHttpUrl | None = None, + client_registration_options: ClientRegistrationOptions | None = None, + revocation_options: RevocationOptions | None = None, + identity_assertion_enabled: bool = False, +) -> list[Route]: + validate_issuer_url(issuer_url) + + client_registration_options = client_registration_options or ClientRegistrationOptions() + revocation_options = revocation_options or RevocationOptions() + metadata = build_metadata( + issuer_url, + service_documentation_url, + client_registration_options, + revocation_options, + supports_identity_assertion=identity_assertion_enabled, + ) + client_authenticator = ClientAuthenticator(provider) + + # Create routes + # Allow CORS requests for endpoints meant to be hit by the OAuth client + # (with the client secret). This is intended to support things like MCP Inspector, + # where the client runs in a web browser. + routes = [ + Route( + "/.well-known/oauth-authorization-server", + endpoint=cors_middleware( + MetadataHandler(metadata).handle, + ["GET", "OPTIONS"], + ), + methods=["GET", "OPTIONS"], + ), + Route( + AUTHORIZATION_PATH, + # do not allow CORS for authorization endpoint; + # clients should just redirect to this + endpoint=AuthorizationHandler(provider).handle, + methods=["GET", "POST"], + ), + Route( + TOKEN_PATH, + endpoint=cors_middleware( + TokenHandler( + provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled + ).handle, + ["POST", "OPTIONS"], + ), + methods=["POST", "OPTIONS"], + ), + ] + + if client_registration_options.enabled: # pragma: no branch + registration_handler = RegistrationHandler( + provider, + options=client_registration_options, + ) + routes.append( + Route( + REGISTRATION_PATH, + endpoint=cors_middleware( + registration_handler.handle, + ["POST", "OPTIONS"], + ), + methods=["POST", "OPTIONS"], + ) + ) + + if revocation_options.enabled: # pragma: no branch + revocation_handler = RevocationHandler(provider, client_authenticator) + routes.append( + Route( + REVOCATION_PATH, + endpoint=cors_middleware( + revocation_handler.handle, + ["POST", "OPTIONS"], + ), + methods=["POST", "OPTIONS"], + ) + ) + + return routes + + +def build_metadata( + issuer_url: AnyHttpUrl, + service_documentation_url: AnyHttpUrl | None, + client_registration_options: ClientRegistrationOptions, + revocation_options: RevocationOptions, + supports_identity_assertion: bool = False, +) -> OAuthMetadata: + authorization_url = AnyHttpUrl(str(issuer_url).rstrip("/") + AUTHORIZATION_PATH) + token_url = AnyHttpUrl(str(issuer_url).rstrip("/") + TOKEN_PATH) + + grant_types_supported = ["authorization_code", "refresh_token"] + # SEP-990 / ext-auth ยง6: support for the ID-JAG flow is advertised as a grant PROFILE, not as + # the jwt-bearer grant type (which an AS might support for other purposes). + authorization_grant_profiles_supported: list[str] | None = None + if supports_identity_assertion: + grant_types_supported.append(JWT_BEARER_GRANT_TYPE) + authorization_grant_profiles_supported = [ID_JAG_GRANT_PROFILE] + + # Create metadata + metadata = OAuthMetadata( + issuer=issuer_url, + authorization_endpoint=authorization_url, + token_endpoint=token_url, + scopes_supported=client_registration_options.valid_scopes, + response_types_supported=["code"], + response_modes_supported=None, + grant_types_supported=grant_types_supported, + token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"], + token_endpoint_auth_signing_alg_values_supported=None, + service_documentation=service_documentation_url, + ui_locales_supported=None, + op_policy_uri=None, + op_tos_uri=None, + introspection_endpoint=None, + code_challenge_methods_supported=["S256"], + authorization_grant_profiles_supported=authorization_grant_profiles_supported, + ) + + # Add registration endpoint if supported + if client_registration_options.enabled: # pragma: no branch + metadata.registration_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REGISTRATION_PATH) + + # Add revocation endpoint if supported + if revocation_options.enabled: # pragma: no branch + metadata.revocation_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REVOCATION_PATH) + metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post", "client_secret_basic"] + + return metadata + + +def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl: + """Build RFC 9728 compliant protected resource metadata URL. + + Inserts /.well-known/oauth-protected-resource between host and resource path + as specified in RFC 9728 ยง3.1. + + Args: + resource_server_url: The resource server URL (e.g., https://example.com/mcp) + + Returns: + The metadata URL (e.g., https://example.com/.well-known/oauth-protected-resource/mcp) + """ + parsed = urlparse(str(resource_server_url)) + # Handle trailing slash: if path is just "/", treat as empty + resource_path = parsed.path if parsed.path != "/" else "" + return AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}") + + +def create_protected_resource_routes( + resource_url: AnyHttpUrl, + authorization_servers: list[AnyHttpUrl], + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, +) -> list[Route]: + """Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728). + + Args: + resource_url: The URL of this resource server + authorization_servers: List of authorization servers that can issue tokens + scopes_supported: Optional list of scopes supported by this resource + resource_name: Optional human-readable name for this resource + resource_documentation: Optional URL to documentation for this resource + + Returns: + List of Starlette routes for protected resource metadata + """ + metadata = ProtectedResourceMetadata( + resource=resource_url, + authorization_servers=authorization_servers, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, + # bearer_methods_supported defaults to ["header"] in the model + ) + + handler = ProtectedResourceMetadataHandler(metadata) + + # RFC 9728 ยง3.1: Register route at /.well-known/oauth-protected-resource + resource path + metadata_url = build_resource_metadata_url(resource_url) + # Extract just the path part for route registration + parsed = urlparse(str(metadata_url)) + well_known_path = parsed.path + + return [ + Route( + well_known_path, + endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), + methods=["GET", "OPTIONS"], + ) + ] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/settings.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..ae2083a38bfe983b362565c3c2bfdf0ab99c0ba5 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/settings.py @@ -0,0 +1,42 @@ +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field + + +class ClientRegistrationOptions(BaseModel): + enabled: bool = False + client_secret_expiry_seconds: int | None = None + valid_scopes: list[str] | None = None + default_scopes: list[str] | None = None + + +class RevocationOptions(BaseModel): + enabled: bool = False + + +class AuthSettings(BaseModel): + # Preserve empty URL paths so a path-less issuer/resource passed as a string keeps its + # canonical form (no trailing slash). RFC 8414/9207 issuer comparison is exact string + # comparison, so a spurious trailing slash would break it. See PR #2925 for the metadata + # models; this applies the same to the server's own configured URLs. + model_config = ConfigDict(url_preserve_empty_path=True) + + issuer_url: AnyHttpUrl = Field( + ..., + description="OAuth authorization server URL that issues tokens for this resource server.", + ) + service_documentation_url: AnyHttpUrl | None = None + client_registration_options: ClientRegistrationOptions | None = None + revocation_options: RevocationOptions | None = None + required_scopes: list[str] | None = None + identity_assertion_enabled: bool = Field( + default=False, + description="Advertise and accept the SEP-990 Identity Assertion Authorization Grant " + "(the RFC 7523 jwt-bearer grant carrying an ID-JAG) at the token endpoint, for enterprise " + "IdP flows. The provider must implement `exchange_identity_assertion`.", + ) + + # Resource Server settings (when operating as RS only) + resource_server_url: AnyHttpUrl | None = Field( + ..., + description="The URL of the MCP server to be used as the resource identifier " + "and base route to look up OAuth Protected Resource Metadata.", + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/caching.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/caching.py new file mode 100644 index 0000000000000000000000000000000000000000..5e99303159672b03f277d2c5451c8782a412afcd --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/caching.py @@ -0,0 +1,85 @@ +"""Server-side caching hints (SEP-2549, protocol revision 2026-07-28). + +Results for the cacheable methods carry `ttlMs`/`cacheScope` freshness hints. +A handler sets them by returning a result with explicit `ttl_ms`/`cache_scope` +values; `Server(cache_hints={method: CacheHint(...)})` fills them for handlers +that don't. Fields the handler set win, per field, so a server-wide hint never +overrides a handler's explicit choice. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal, TypeVar + +import mcp_types as types +from mcp_types.methods import CACHEABLE_METHODS, CacheableMethod + +__all__ = ["CACHEABLE_METHODS", "CacheHint", "CacheableMethod", "apply_cache_hint", "validate_cache_hints"] + + +@dataclass(frozen=True, slots=True) +class CacheHint: + """Freshness hint for one cacheable method's results. + + `ttl_ms` is how long, in milliseconds, a client may consider the result + fresh (`0` means immediately stale). `scope` is whether a cached result may + be shared across authorization contexts (`"public"`) or only reused within + the one that produced it (`"private"`). + """ + + ttl_ms: int = 0 + scope: Literal["public", "private"] = "private" + + def __post_init__(self) -> None: + if self.ttl_ms < 0: + raise ValueError(f"ttl_ms must be >= 0, got {self.ttl_ms}") + if self.scope not in ("public", "private"): + raise ValueError(f"scope must be 'public' or 'private', got {self.scope!r}") + + +CacheableResultT = TypeVar("CacheableResultT", bound=types.CacheableResult) + + +def apply_cache_hint(result: CacheableResultT, hint: CacheHint) -> CacheableResultT: + """Fill `ttl_ms`/`cache_scope` on `result` from `hint`. + + Per-field: a field the handler set explicitly - even to its default value, + tracked via `model_fields_set` - is left alone; only unset fields take the + hint. A handler constructing results with `model_construct` bypasses that + tracking and is treated as having set nothing. + """ + update: dict[str, int | str] = {} + if "ttl_ms" not in result.model_fields_set: + update["ttl_ms"] = hint.ttl_ms + if "cache_scope" not in result.model_fields_set: + update["cache_scope"] = hint.scope + return result.model_copy(update=update) if update else result + + +def validate_cache_hints(cache_hints: Mapping[Any, Any] | None) -> dict[str, CacheHint]: + """Validate a `cache_hints` constructor argument into a plain dict. + + The `Server`/`MCPServer` signatures already close the key set and value + type for type-checked callers; this runtime gate is deliberately loose in + its parameter so it covers everyone else (e.g. a map deserialized from + config) - a bad entry fails at construction, not on the first request to + that method. + + Raises: + ValueError: If a key is not a cacheable method. + TypeError: If a value is not a `CacheHint`. + """ + if cache_hints is None: + return {} + # repr-format keys so a non-string key raises this ValueError, not a TypeError from sorted/join. + unknown = sorted(repr(method) for method in cache_hints if method not in CACHEABLE_METHODS) + if unknown: + raise ValueError(f"cache_hints keys must be cacheable methods (see CacheableMethod); got: {', '.join(unknown)}") + validated: dict[str, CacheHint] = {} + for method, hint in cache_hints.items(): + if not isinstance(hint, CacheHint): + raise TypeError(f"cache_hints[{method!r}] must be a CacheHint, got {type(hint).__name__}") + validated[method] = hint + return validated diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/connection.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/connection.py new file mode 100644 index 0000000000000000000000000000000000000000..99ba2da48185259979ef8ec9a5a112810cd8f097 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/connection.py @@ -0,0 +1,493 @@ +"""`Connection` - per-client connection state and the standalone outbound channel. + +Always present on `Context` (never `None`), even in stateless deployments. +Holds peer info, per-connection scratch `state` and an `exit_stack` for +teardown, and an `Outbound` for the standalone stream (the SSE GET stream in +streamable HTTP, or the single duplex stream in stdio). + +Construct via the factories: `Connection.from_envelope` for the 2026-era +single-exchange path (born ready, no back-channel) and `Connection.for_loop` +for the handshake-driven loop path. Both populate `protocol_version` so the +kernel reads it as a fact. + +`notify` is best-effort: it never raises. If there's no standalone channel +or the stream has been dropped, the notification is debug-logged and silently +discarded - server-initiated notifications are inherently advisory. +`send_raw_request` raises `NoBackChannelError` when there's no channel; `ping` +is the only spec-sanctioned standalone request. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from contextlib import AsyncExitStack +from typing import Any, Final, TypeVar, get_args, overload + +import anyio +from mcp_types import ( + LOG_LEVEL_META_KEY, + ClientCapabilities, + CreateMessageRequest, + CreateMessageResult, + ElicitRequest, + ElicitResult, + EmptyResult, + Implementation, + InitializeRequestParams, + ListRootsRequest, + ListRootsResult, + LoggingLevel, + PingRequest, + Request, +) +from mcp_types import methods as _methods +from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS +from pydantic import BaseModel, ValidationError +from typing_extensions import deprecated + +from mcp.shared.dispatcher import CallOptions, Outbound +from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError +from mcp.shared.peer import Meta, dump_params +from mcp.shared.subscriptions import LISTEN_STREAM_METHODS + +__all__ = ["Connection"] + +logger = logging.getLogger(__name__) +# `Connection.log`'s `logger` parameter (public API, the spec's logger-name +# field) shadows the module logger inside that method; this alias keeps the +# module logger reachable there. +_logger = logger + +_LOG_LEVELS: Final[tuple[LoggingLevel, ...]] = get_args(LoggingLevel) +"""Severity-ascending, from the `LoggingLevel` literal's declaration order (the +RFC 5424 scale) - the literal is the single source of the ordering.""" + +_ALL_LOG_LEVELS: Final[frozenset[LoggingLevel]] = frozenset(_LOG_LEVELS) + + +def allowed_log_levels(protocol_version: str, meta: Mapping[str, Any] | None) -> frozenset[LoggingLevel]: + """The `notifications/message` levels deliverable for one inbound request. + + 2026-07-28+ makes log delivery a per-request opt-in (server/utilities/ + logging): the client sets the reserved `io.modelcontextprotocol/logLevel` + `_meta` key, absent means no levels - the server MUST NOT send - and + present means that level and above. An unrecognized value reads as absent; + spec methods already reject a malformed value at surface validation + before any handler runs, so that arm only serves custom methods, where + dropping is the safe direction. Connection-scoped emitters pass + `meta=None`: `logging/setLevel` is gone at 2026 and log delivery is + request-scoped only, so they deliver nothing. Handshake versions keep + their `logging/setLevel`-era semantics: every level may be sent, filtering + is the application's `logging/setLevel` handler's job as before. + """ + if protocol_version not in MODERN_PROTOCOL_VERSIONS: + return _ALL_LOG_LEVELS + requested = (meta or {}).get(LOG_LEVEL_META_KEY) + if requested not in _LOG_LEVELS: + return frozenset() + return frozenset(_LOG_LEVELS[_LOG_LEVELS.index(requested) :]) + + +ResultT = TypeVar("ResultT", bound=BaseModel) + +# Result types for the spec's server-to-client request set, used by +# `Connection.send_request` to infer the result type. If the spec's request +# set grows substantially, consider declaring the result mapping on the +# request types themselves (a `__mcp_result__` ClassVar read via a structural +# protocol) so this table and the overload ladder don't need maintaining. +_RESULT_FOR: dict[type[Request[Any, Any]], type[BaseModel]] = { + CreateMessageRequest: CreateMessageResult, + ElicitRequest: ElicitResult, + ListRootsRequest: ListRootsResult, + PingRequest: EmptyResult, +} + + +_ModelT = TypeVar("_ModelT", bound=BaseModel) + + +def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None: + """Validate a raw envelope value into a typed model. + + A missing, null or mis-shaped value falls through to `ValidationError` + and is treated as not supplied so the request still routes. Spec methods + are separately re-validated by the kernel's per-version params surface, + which types the reserved `_meta` keys strictly. + """ + try: + return model.model_validate(raw, by_name=False) + except ValidationError: + return None + + +def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> dict[str, Any] | None: + if not meta: + return payload + out = dict(payload or {}) + out["_meta"] = meta + return out + + +class _NoChannelOutbound: + """Connection-scoped `Outbound` for the no-back-channel case. + + The structural answer to "this connection cannot push to its peer": + `send_raw_request` raises `NoBackChannelError`; `notify` drops with a + debug log. `Connection.from_envelope` installs this so the modern + single-exchange path never needs a mode flag - the channel itself says no. + """ + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + logger.debug("dropped %s: no standalone channel", method) + + +_NO_CHANNEL = _NoChannelOutbound() + + +class NotifyOnlyOutbound(_NoChannelOutbound): + """Connection-scoped `Outbound` that forwards notifications and refuses requests. + + Installed by `serve_dual_era_loop` for modern (2026-07-28+) connections + over duplex stream transports: the pipe is real, so server notifications + ride it, but the modern protocol forbids server-initiated JSON-RPC + requests, so `send_raw_request` (inherited) refuses by construction. + + Change notifications (`notifications/*/list_changed`, + `notifications/resources/updated`) are dropped with a debug log: at this + era they reach a client only through a `subscriptions/listen` stream it + opened, so a bare copy on the shared channel would be an unrequested + notification. Publish them on the server's `SubscriptionBus` instead. + """ + + def __init__(self, outbound: Outbound) -> None: + self._outbound = outbound + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + # At the 2026-07-28 era these are `subscriptions/listen` stream goods + # only: the spec forbids sending a change notification a subscription + # did not request, and listen streams deliver them (stamped, filtered) + # via the request-scoped outbound, never this connection-scoped channel. + if method in LISTEN_STREAM_METHODS: + logger.debug("dropped %s: delivered via subscriptions/listen at this era", method) + return + await self._outbound.notify(method, params, opts) + + +class Connection: + """Per-client connection state and standalone-stream `Outbound`. + + Construct via `from_envelope` (modern single-exchange: born ready, no + back-channel) or `for_loop` (handshake-driven: ready once the client's + `notifications/initialized` arrives). Either way `protocol_version` is + populated at construction. + """ + + outbound: Outbound + """The connection-scoped channel for server-initiated messages.""" + + session_id: str | None + + client_capabilities: ClientCapabilities | None + """The capabilities the peer declared: the handshake's on the loop path, + the request envelope's on the modern path. `None` when none were declared. + Kept in lockstep with `client_params` by its setter, and settable on its + own for the modern envelope, where capabilities are required but client + info is optional (spec PR #3002) - capability checks must not depend on the + peer having identified itself.""" + + protocol_version: str + """The protocol version this connection speaks. Populated at construction + by the factory and overwritten by `_handle_initialize` once the handshake + commits on the loop path.""" + + initialized: anyio.Event + """Set when `notifications/initialized` arrives (matches TS `oninitialized`); + the point from which the spec permits server-initiated requests beyond + ping/logging. Pre-set on connections built via `from_envelope`.""" + + state: dict[str, Any] + """Per-connection scratch state; persists across requests on this connection.""" + + exit_stack: AsyncExitStack + """Per-connection teardown, unwound LIFO (shielded) when the connection + closes. Push cleanup from handlers or middleware; exceptions are logged + and swallowed.""" + + def __init__( + self, + outbound: Outbound, + *, + protocol_version: str, + session_id: str | None = None, + client_params: InitializeRequestParams | None = None, + ) -> None: + self.outbound = outbound + self.protocol_version = protocol_version + self.session_id = session_id + self.client_capabilities = None + self.client_params = client_params + self.initialized = anyio.Event() + self.state = {} + self.exit_stack = AsyncExitStack() + + @property + def client_params(self) -> InitializeRequestParams | None: + """The full `initialize` request params, or the equivalent built from the + 2026-era envelope. `None` when no client info was supplied.""" + return self._client_params + + @client_params.setter + def client_params(self, value: InitializeRequestParams | None) -> None: + # Assignment is the sync point: recording full client params (the + # handshake commit, or a modern envelope carrying client info) also + # records the capabilities fact, so the two can never drift. Clearing + # to `None` leaves `client_capabilities` alone - the modern envelope + # declares capabilities without client info. + self._client_params = value + if value is not None: + self.client_capabilities = value.capabilities + + @classmethod + def from_envelope( + cls, + protocol_version: str, + client_info: Any, + client_capabilities: Any, + *, + outbound: Outbound = _NO_CHANNEL, + ) -> Connection: + """A born-ready connection populated from a request's `_meta` envelope. + + `protocol_version` must be an already-validated version string - the + inbound classification ladder owns rejecting non-string or unsupported + values. `client_info` and `client_capabilities` are the raw envelope + values: this constructor owns turning them into connection identity, + identically on every modern entry, so a mis-shaped value degrades to + not-supplied rather than failing the request. `initialized` is set, + well-formed capabilities are recorded as `client_capabilities` (client + info is optional per spec PR #3002, so capability checks never depend on + it), and the full `client_params` is additionally synthesized when + client info was supplied too. `outbound` defaults to the no-channel + sentinel for the single-exchange HTTP path; duplex modern transports + (e.g. stdio) pass a notify-only wrapper around the dispatcher so + server notifications ride the pipe while server-initiated requests + stay refused. + """ + info = _typed(Implementation, client_info) + capabilities = _typed(ClientCapabilities, client_capabilities) + client_params = None + if info is not None and capabilities is not None: + client_params = InitializeRequestParams( + protocol_version=protocol_version, + capabilities=capabilities, + client_info=info, + ) + connection = cls(outbound, protocol_version=protocol_version, client_params=client_params) + connection.client_capabilities = capabilities + connection.initialized.set() + return connection + + @classmethod + def for_loop( + cls, + outbound: Outbound, + *, + session_id: str | None = None, + protocol_version_hint: str | None = None, + ) -> Connection: + """A connection for the handshake-driven loop path. + + Not born-ready: `initialized` is set later by the kernel when + `notifications/initialized` arrives. `protocol_version` is seeded from + the transport hint (or `LATEST_HANDSHAKE_VERSION`) so it's never `None`; + the handshake overwrites it once negotiated. + """ + return cls( + outbound, + protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_HANDSHAKE_VERSION, + session_id=session_id, + ) + + @property + def has_standalone_channel(self) -> bool: + """Whether this connection has a real back-channel for server-initiated + messages. Derived from `outbound` - the no-channel sentinel is the only + case that doesn't. + + Channel presence, not request permission: a modern (2026-07-28+) + duplex connection has a channel that carries notifications while + `send_raw_request` still refuses, because the protocol forbids + server-initiated requests.""" + return self.outbound is not _NO_CHANNEL + + @property + def initialize_accepted(self) -> bool: + """True once the inbound request gate is open: `initialize` recorded the + peer info, or the handshake completed outright (born-ready, or a bare + `notifications/initialized`). Derived, never stored.""" + return self.client_params is not None or self.initialized.is_set() + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + """Send a raw request on the standalone stream. + + Low-level `Outbound` channel. Prefer the typed `send_request` or the + convenience methods below; use this directly only for off-spec + messages. `opts` carries per-call `timeout` / `on_progress` / + resumption hints; see `CallOptions`. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: no back-channel for server-initiated requests - + `has_standalone_channel` is `False`, or a modern (2026-07-28+) + connection, where the protocol forbids them. + """ + return await self.outbound.send_raw_request(method, params, opts) + + @overload + async def send_request( + self, req: CreateMessageRequest, *, opts: CallOptions | None = None + ) -> CreateMessageResult: ... + @overload + async def send_request(self, req: ElicitRequest, *, opts: CallOptions | None = None) -> ElicitResult: ... + @overload + async def send_request(self, req: ListRootsRequest, *, opts: CallOptions | None = None) -> ListRootsResult: ... + @overload + async def send_request(self, req: PingRequest, *, opts: CallOptions | None = None) -> EmptyResult: ... + @overload + async def send_request( + self, req: Request[Any, Any], *, result_type: type[ResultT], opts: CallOptions | None = None + ) -> ResultT: ... + async def send_request( + self, + req: Request[Any, Any], + *, + result_type: type[BaseModel] | None = None, + opts: CallOptions | None = None, + ) -> BaseModel: + """Send a typed server-to-client request and return its typed result. + + For spec request types the result type is inferred. For custom requests + pass `result_type=` explicitly. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: No back-channel for server-initiated requests. + pydantic.ValidationError: The peer's result does not match the expected result type. + KeyError: `result_type` omitted for a non-spec request type. + """ + raw = await self.send_raw_request(req.method, dump_params(req.params), opts) + if req.method in _methods.MONOLITH_REQUESTS: + try: + _methods.validate_client_result(req.method, self.protocol_version, raw) + except KeyError: + pass + cls = result_type if result_type is not None else _RESULT_FOR[type(req)] + return cls.model_validate(raw, by_name=False) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + """Send a best-effort notification on the standalone stream. + + Never raises. If there's no standalone channel or the stream is broken, + the notification is dropped and debug-logged. + """ + try: + await self.outbound.notify(method, params, opts) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("dropped %s: standalone stream closed", method) + + async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = None) -> None: + """Send a `ping` request on the standalone stream. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: no back-channel for server-initiated requests - + `has_standalone_channel` is `False`, or a modern (2026-07-28+) + connection, where the protocol forbids them. + """ + await self.send_raw_request("ping", dump_params(None, meta), opts) + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None: + """Send a `notifications/message` log entry on the standalone stream. Best-effort. + + On 2026-07-28+ connections this never sends: log delivery is a + per-request opt-in that rides the requesting stream (`ctx.log`, + `ctx.session.send_log_message`), and the standalone stream is + forbidden from carrying `notifications/message`, so the entry is + debug-logged and dropped. + """ + if level not in allowed_log_levels(self.protocol_version, None): + _logger.debug("dropped notifications/message: no connection-wide log delivery at %s", self.protocol_version) + return + params: dict[str, Any] = {"level": level, "data": data} + if logger is not None: + params["logger"] = logger + await self.notify("notifications/message", _notification_params(params, meta)) + + async def send_tool_list_changed(self, *, meta: Meta | None = None) -> None: + await self.notify("notifications/tools/list_changed", _notification_params(None, meta)) + + async def send_prompt_list_changed(self, *, meta: Meta | None = None) -> None: + await self.notify("notifications/prompts/list_changed", _notification_params(None, meta)) + + async def send_resource_list_changed(self, *, meta: Meta | None = None) -> None: + await self.notify("notifications/resources/list_changed", _notification_params(None, meta)) + + async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) -> None: + await self.notify("notifications/resources/updated", _notification_params({"uri": uri}, meta)) + + def check_capability(self, capability: ClientCapabilities) -> bool: + """Return whether the connected client declared the given capability. + + Returns `False` when no capabilities have been recorded. + """ + # TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability + # verbatim for parity. + if self.client_capabilities is None: + return False + have = self.client_capabilities + if capability.roots is not None: + if have.roots is None: + return False + if capability.roots.list_changed and not have.roots.list_changed: + return False + if capability.sampling is not None: + if have.sampling is None: + return False + if capability.sampling.context is not None and have.sampling.context is None: + return False + if capability.sampling.tools is not None and have.sampling.tools is None: + return False + if capability.elicitation is not None and have.elicitation is None: + return False + if capability.experimental is not None: + if have.experimental is None: + return False + for k, v in capability.experimental.items(): + if k not in have.experimental or have.experimental[k] != v: + return False + if capability.extensions is not None: + # SEP-2133: an extension is supported when the client declares its + # identifier. Settings are negotiated per-extension (the client may + # advertise more than the server asks for), so presence - not value + # equality - is the meaningful check. + if have.extensions is None: + return False + for identifier in capability.extensions: + if identifier not in have.extensions: + return False + return True diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/context.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/context.py new file mode 100644 index 0000000000000000000000000000000000000000..bfcb9c9ca4c16a51ba639f6e4637bb4da3a19893 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/context.py @@ -0,0 +1,194 @@ +import logging +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from typing import Any, Generic, Protocol + +from mcp_types import LoggingLevel, RequestId, RequestParamsMeta +from pydantic import BaseModel +from typing_extensions import TypeVar, deprecated + +from mcp.server.connection import Connection, allowed_log_levels +from mcp.server.session import ServerSession +from mcp.shared.context import BaseContext +from mcp.shared.dispatcher import DispatchContext +from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.shared.message import CloseSSEStreamCallback +from mcp.shared.peer import Meta +from mcp.shared.transport_context import TransportContext + +logger = logging.getLogger(__name__) +# `Context.log`'s `logger` parameter (public API, the spec's logger-name +# field) shadows the module logger inside that method; this alias keeps it +# reachable there. +_logger = logger + +# Invariant: parametrizes a mutable dataclass field; dict default matches the default lifespan. +LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any]) +RequestT = TypeVar("RequestT", default=Any) + + +@dataclass(kw_only=True) +class ServerRequestContext(Generic[LifespanContextT, RequestT]): + """Per-request context handed to lowlevel request and notification handlers. + + Built by `ServerRunner._make_context` for each inbound message. Carries the + connection-scoped `ServerSession` (server-to-client requests and + notifications), per-request metadata, and any per-message data the + transport attached (the HTTP request, SSE stream-close callbacks). + """ + + session: ServerSession + lifespan_context: LifespanContextT + protocol_version: str + method: str + params: Mapping[str, Any] | None = None + request_id: RequestId | None = None + meta: RequestParamsMeta | None = None + request: RequestT | None = None + close_sse_stream: CloseSSEStreamCallback | None = None + close_standalone_sse_stream: CloseSSEStreamCallback | None = None + + +# Covariant: `lifespan` is exposed read-only, so a `Context[AppState]` passes as `Context[object]`. +LifespanT_co = TypeVar("LifespanT_co", default=Any, covariant=True) + + +class Context(BaseContext[TransportContext], Generic[LifespanT_co]): + """Server-side per-request context. + + Extends `BaseContext` (transport metadata, the raw back-channel, progress + reporting) with `lifespan`, `connection`, and request-scoped `log`. + + Not currently constructed by `ServerRunner`, which hands handlers a + `ServerRequestContext` instead. + """ + + def __init__( + self, + dctx: DispatchContext[TransportContext], + *, + lifespan: LifespanT_co, + connection: Connection, + meta: RequestParamsMeta | None = None, + ) -> None: + super().__init__(dctx, meta=meta) + self._lifespan = lifespan + self._connection = connection + # Same per-request log gate as `ServerSession`: fixed at construction + # from this request's `_meta` log-level opt-in and the connection's era. + self._allowed_log_levels = allowed_log_levels(connection.protocol_version, meta) + + @property + def lifespan(self) -> LifespanT_co: + """The server-wide lifespan output (what `Server(..., lifespan=...)` yielded).""" + return self._lifespan + + @property + def connection(self) -> Connection: + """The per-client `Connection` for this request's connection.""" + return self._connection + + @property + def session_id(self) -> str | None: + """The transport's session id for this connection, when one exists. + + Convenience for `ctx.connection.session_id`. `None` on stdio and + stateless HTTP. + """ + return self._connection.session_id + + @property + def headers(self) -> Mapping[str, str] | None: + """Request headers carried by this message, when the transport has them. + + Convenience for `ctx.transport.headers`. `None` on stdio. + """ + return self.transport.headers + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None: + """Send a request-scoped `notifications/message` log entry. + + Uses this request's back-channel (so the entry rides the request's SSE + stream in streamable HTTP), not the standalone stream - use + `ctx.connection.log(...)` for that. + + On 2026-07-28+ delivery is a per-request opt-in: nothing is sent + unless this request's `_meta` carried the reserved log-level key, and + entries below the requested level are dropped (debug-logged). + Handshake versions send unconditionally, as before. + """ + if level not in self._allowed_log_levels: + _logger.debug("dropped notifications/message at %r: not opted in at that level on this request", level) + return + params: dict[str, Any] = {"level": level, "data": data} + if logger is not None: + params["logger"] = logger + if meta: + params["_meta"] = meta + await self.notify("notifications/message", params) + + +HandlerResult = BaseModel | dict[str, Any] | None +"""What a request handler (or middleware) may return. `ServerRunner` serializes +all three to a result dict.""" + +CallNext = Callable[["ServerRequestContext[Any, Any]"], Awaitable[HandlerResult]] +"""Invokes the rest of the chain with the given context. What a context +rewrite (`dataclasses.replace(ctx, ...)`) can alter depends on the tier: +`ServerMiddleware` runs before params validation, so its rewrites change what +the handler is invoked with; an `Extension` interceptor runs after, so its +rewrites change only what the handler observes on `ctx`.""" + +_MwLifespanT = TypeVar("_MwLifespanT") + + +class ServerMiddleware(Protocol[_MwLifespanT]): + """Context-tier middleware: `(ctx, call_next) -> result`. + + Runs at the top of `ServerRunner._on_request` / `_on_notify` after `ctx` + is built but before any validation, lookup, or handshake. Wraps every + inbound request and notification: `initialize`, the pre-init gate, + `METHOD_NOT_FOUND`, params validation, the handler call, and + `notifications/initialized` all run inside `call_next(ctx)`. + `notifications/cancelled` is observed too; the dispatcher applies the + cancellation itself, then forwards the notification. A request-side + failure reaches the middleware as a raised `MCPError` (or + `ValidationError` for malformed params) so observation/logging middleware + can record it. Listed outermost-first on `Server.middleware`. + + The method and the raw inbound params are `ctx.method` and `ctx.params` (no + model validation has happened yet). To rewrite either before the handler + runs, pass an adjusted context: `await call_next(replace(ctx, params=...))`. + `ctx.request_id is None` distinguishes a notification from a request. For + notifications `call_next(ctx)` returns `None` (a dropped or unhandled + notification also returns `None`) and the middleware's own return value is + discarded. + + !!! warning + `initialize` is handled inline - the dispatcher does not read + further inbound messages until the middleware chain returns. Awaiting a + server-to-client request (`ctx.session.send_request`, `send_ping`, ...) + while handling `initialize` therefore deadlocks the connection: the + response can never be dequeued. Send-and-forget notifications are safe. + `initialize` is observed but not rewritable: the post-chain handshake + commit reads the wire params, so to veto the handshake raise *before* + `call_next()`. + + `Server[L].middleware` holds `ServerMiddleware[L]`, so an app-specific + middleware sees `ctx.lifespan_context: L`. While the context is the + mutable `ServerRequestContext` dataclass it is invariant in `L`, so a + reusable middleware should be typed `ServerMiddleware[Any]` to register on + any `Server[L]`. + """ + + # TODO(maxisbey): once `_make_context` returns the (covariant) `Context[L]` + # again, restore `_MwLifespanT` to `contravariant=True` and retype `ctx` + # below to `Context[_MwLifespanT]` so reusable middleware can be + # `ServerMiddleware[object]` instead of `ServerMiddleware[Any]`. + + async def __call__( + self, + ctx: ServerRequestContext[_MwLifespanT, Any], + call_next: CallNext, + ) -> HandlerResult: ... diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/elicitation.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/elicitation.py new file mode 100644 index 0000000000000000000000000000000000000000..26425c13380fc3668162e1470430e0ab70b3f9d1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/elicitation.py @@ -0,0 +1,190 @@ +"""Elicitation utilities for MCP servers.""" + +from __future__ import annotations + +from typing import Any, Generic, Literal, TypeVar + +from mcp_types import RequestId + +# Internal surface package; imported as the gate's source of truth for spec-valid property schemas. +from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition +from pydantic import BaseModel, ValidationError +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue +from pydantic_core import core_schema +from typing_extensions import TypeAliasType + +from mcp.server.session import ServerSession + +ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel) + + +class AcceptedElicitation(BaseModel, Generic[ElicitSchemaModelT]): + """Result when user accepts the elicitation.""" + + action: Literal["accept"] = "accept" + data: ElicitSchemaModelT + + +class DeclinedElicitation(BaseModel): + """Result when user declines the elicitation.""" + + action: Literal["decline"] = "decline" + + +class CancelledElicitation(BaseModel): + """Result when user cancels the elicitation.""" + + action: Literal["cancel"] = "cancel" + + +ElicitationResult = TypeAliasType( + "ElicitationResult", + AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation, + type_params=(ElicitSchemaModelT,), +) + + +class AcceptedUrlElicitation(BaseModel): + """Result when user accepts a URL mode elicitation.""" + + action: Literal["accept"] = "accept" + + +UrlElicitationResult = AcceptedUrlElicitation | DeclinedElicitation | CancelledElicitation + + +class _ElicitationJsonSchema(GenerateJsonSchema): + """JSON-Schema generator that flattens `T | None` to `T` and drops `None` defaults. + + The spec's `PrimitiveSchemaDefinition` admits no `anyOf` or null type; an + optional field is expressed by leaving it out of `required`, which pydantic + already does for any field with a default. + """ + + def nullable_schema(self, schema: core_schema.NullableSchema) -> JsonSchemaValue: + return self.generate_inner(schema["schema"]) + + def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaValue: + result = super().default_schema(schema) + if result.get("default") is None: + result.pop("default", None) + return result + + +def _validate_rendered_properties(json_schema: dict[str, Any]) -> None: + """Reject any `properties` entry the spec's `PrimitiveSchemaDefinition` won't accept. + + Catches whatever the renderer let through that isn't spec-valid: bare + `list[str]` (no enum), multi-primitive unions, nested models. + """ + for field_name, prop in json_schema.get("properties", {}).items(): + try: + PrimitiveSchemaDefinition.model_validate(prop) + except ValidationError: + raise TypeError( + f"Elicitation schema field {field_name!r} rendered as {prop!r}, " + f"which is not a valid PrimitiveSchemaDefinition" + ) from None + + +def render_elicitation_schema(schema: type[BaseModel]) -> dict[str, Any]: + """Render a model as the spec-valid `requested_schema` for an elicitation. + + Raises: + TypeError: If a field renders as something the spec's + `PrimitiveSchemaDefinition` does not accept. + """ + json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema) + _validate_rendered_properties(json_schema) + return json_schema + + +async def elicit_with_validation( + session: ServerSession, + message: str, + schema: type[ElicitSchemaModelT], + related_request_id: RequestId | None = None, +) -> ElicitationResult[ElicitSchemaModelT]: + """Elicit information from the client/user with schema validation (form mode). + + This method can be used to interactively ask for additional information from the + client within a tool's execution. The client might display the message to the + user and collect a response according to the provided schema. If the client + is an agent, it might decide how to handle the elicitation -- either by asking + the user or automatically generating a response. + + For sensitive data like credentials or OAuth flows, use elicit_url() instead. + + Raises: + ValueError: If the client accepted the elicitation without supplying + content, or with content that does not match the requested schema. + """ + json_schema = render_elicitation_schema(schema) + + result = await session.elicit_form( + message=message, + requested_schema=json_schema, + related_request_id=related_request_id, + ) + + if result.action == "accept": + if result.content is None: + raise ValueError("Received an accepted elicitation with no content") + try: + validated_data = schema.model_validate(result.content) + except ValidationError as e: + raise ValueError( + "Received an accepted elicitation whose content does not match the requested schema" + ) from e + return AcceptedElicitation(data=validated_data) + if result.action == "decline": + return DeclinedElicitation() + return CancelledElicitation() + + +async def elicit_url( + session: ServerSession, + message: str, + url: str, + elicitation_id: str, + related_request_id: RequestId | None = None, +) -> UrlElicitationResult: + """Elicit information from the user via out-of-band URL navigation (URL mode). + + This method directs the user to an external URL where sensitive interactions can + occur without passing data through the MCP client. Use this for: + - Collecting sensitive credentials (API keys, passwords) + - OAuth authorization flows with third-party services + - Payment and subscription flows + - Any interaction where data should not pass through the LLM context + + The response indicates whether the user consented to navigate to the URL. + The actual interaction happens out-of-band. When the elicitation completes, + the server should send an ElicitCompleteNotification to notify the client. + + Args: + session: The server session + message: Human-readable explanation of why the interaction is needed + url: The URL the user should navigate to + elicitation_id: Unique identifier for tracking this elicitation + related_request_id: Optional ID of the request that triggered this elicitation + + Returns: + UrlElicitationResult indicating accept, decline, or cancel + """ + result = await session.elicit_url( + message=message, + url=url, + elicitation_id=elicitation_id, + related_request_id=related_request_id, + ) + + if result.action == "accept": + return AcceptedUrlElicitation() + elif result.action == "decline": + return DeclinedElicitation() + elif result.action == "cancel": + return CancelledElicitation() + else: # pragma: no cover + # This should never happen, but handle it just in case + raise ValueError(f"Unexpected elicitation action: {result.action}") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/extension.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/extension.py new file mode 100644 index 0000000000000000000000000000000000000000..6205a7033d05c95e1c5c22e22b23219c33a128c6 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/extension.py @@ -0,0 +1,185 @@ +"""Pluggable extension interface for MCP servers (SEP-2133). + +An extension is a self-contained, opt-in bundle of MCP behaviour, identified by +a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed to +`MCPServer(extensions=[...])`, and the server applies a *closed* set of +contribution kinds: tools, resources, new request methods, and one `tools/call` +interceptor. The server never hands itself to an extension; the extension +declares what it adds, and the server consumes it. + +The shape follows the httpx2 `Transport`/`Auth` pattern: a narrow base class whose +methods have sensible defaults, so an extension overrides only what it needs. A +purely additive extension (Apps) overrides `tools`/`resources`; an interceptive +one overrides `methods`/`intercept_tool_call`. + +This module lives at the `mcp.server` tier (not `mcp.server.mcpserver`) so the +base class itself never drags in the composition tier that consumes it; +extensions remain importable without constructing an `MCPServer`. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from mcp_types import CallToolRequestParams +from mcp_types.methods import SPEC_CLIENT_METHODS +from pydantic import BaseModel + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext + +# Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers. +from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier + +if TYPE_CHECKING: + from mcp.server.mcpserver.resources import Resource + +RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]] + + +@dataclass(frozen=True) +class ToolBinding: + """A tool an extension contributes, plus the `_meta` to stamp on it.""" + + fn: Callable[..., Any] + meta: dict[str, Any] | None = None + kwargs: dict[str, Any] = field(default_factory=lambda: {}) + + +@dataclass(frozen=True) +class ResourceBinding: + """A pre-built resource an extension contributes.""" + + resource: Resource + + +@dataclass(frozen=True) +class MethodBinding: + """A new request method an extension serves, e.g. `tasks/get`. + + `params_type` validates incoming params before `handler` runs; it should + subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`, + when set, restricts the method to those wire versions - a request for the + method at any other version is rejected as `METHOD_NOT_FOUND`, mirroring the + spec's `(method, version)` boundary table. `None` (the default) admits the + method at every version. + + Extension methods are additive: `method` must not name a spec-defined + request method (`tools/list`, `completion/complete`, ...) โ€” those handlers + belong to the server, and an extension binding one would silently shadow or + be shadowed by it. Both constraints are enforced at construction. To + re-provide a spec method the 2026 revision removed (e.g. `logging/setLevel` + for legacy clients), use the lowlevel `Server.add_request_handler` API + instead โ€” the runner's per-version surface gate would never route such a + method to an extension handler anyway. + """ + + method: str + params_type: type[BaseModel] + handler: RequestHandler + protocol_versions: frozenset[str] | None = None + + def __post_init__(self) -> None: + if self.method in SPEC_CLIENT_METHODS: + raise ValueError( + f"MethodBinding cannot bind spec method {self.method!r}; extension methods are " + "additive โ€” use Extension.intercept_tool_call or Server.middleware to wrap core behaviour" + ) + if self.protocol_versions is not None and not self.protocol_versions: + raise ValueError( + f"MethodBinding for {self.method!r} has an empty protocol_versions set, so it could " + "never be served; use None to admit every version" + ) + + +class Extension: + """Base class for an opt-in MCP extension. Override only the methods you need. + + Subclass and set `identifier`, then override the contribution methods that + apply. Every method has a default, so a minimal extension overrides nothing + but `identifier` and one of `tools`/`resources`/`methods`. `identifier` is + enforced at subclass-definition time. + """ + + #: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`. + identifier: str + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + # Validate a class-level `identifier` at definition time. A subclass may + # instead assign `identifier` in `__init__` (per-instance ids); that case + # is validated when the extension is applied, since no class attribute + # exists to inspect here. + identifier = cls.__dict__.get("identifier") + if identifier is not None: + validate_extension_identifier(identifier, owner=cls.__name__) + + def settings(self) -> dict[str, Any]: + """Per-extension settings advertised at `capabilities.extensions[identifier]`. + + An empty dict (the default) advertises the extension with no settings. + """ + return {} + + def tools(self) -> Sequence[ToolBinding]: + """Tools this extension contributes (additive).""" + return () + + def resources(self) -> Sequence[ResourceBinding]: + """Resources this extension contributes (additive).""" + return () + + def methods(self) -> Sequence[MethodBinding]: + """New request methods this extension serves (additive).""" + return () + + async def intercept_tool_call( + self, + params: CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + """Wrap `tools/call`. Default: pass through unchanged. + + Override to short-circuit (return a result without calling `call_next`) + or to observe the call. `params` is the validated `tools/call` params; + `call_next(ctx)` runs the rest of the chain and the real handler, and + returns the handler's domain result. Interceptors run at the handler + layer: whatever they return is serialized like any handler result, + including the 2026-era `serverInfo` `_meta` stamp. The `params` this + interceptor received is what the wrapped handler is invoked with - + passing a rewritten context through `call_next` adjusts what the + handler observes on `ctx`, not the tool invocation. Wire-level request + rewriting belongs to `Server.middleware`, above params validation. + """ + return await call_next(ctx) + + +def compose_tool_call_handler(extensions: Sequence[Extension], handler: RequestHandler) -> RequestHandler: + """Fold every extension's `intercept_tool_call` around the `tools/call` handler. + + The returned handler nests the interceptors (first extension outermost) and + replaces the plain `tools/call` registration. Interception happens at the + handler layer, below the runner's outbound envelope pass, so a + short-circuiting interceptor's result is sieved and stamped exactly like + the wrapped handler's would be. + """ + + async def wrapped(ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams) -> HandlerResult: + async def innermost(inner_ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return await handler(inner_ctx, params) + + chain: CallNext = innermost + for extension in reversed(extensions): + chain = _bind_interceptor(extension, params, chain) + return await chain(ctx) + + return wrapped + + +def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext: + async def call(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return await extension.intercept_tool_call(params, ctx, call_next) + + return call diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..37191ba1a0668b6c78a88ab6d72886ff02946062 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/__init__.py @@ -0,0 +1,3 @@ +from .server import NotificationOptions, Server + +__all__ = ["NotificationOptions", "Server"] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/helper_types.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/helper_types.py new file mode 100644 index 0000000000000000000000000000000000000000..fecc716db6c1f43b4bd8400b2e1a18cee8a6e2f8 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/helper_types.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass +class ReadResourceContents: + """Contents returned from a read_resource call.""" + + content: str | bytes + mime_type: str | None = None + meta: dict[str, Any] | None = None diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/server.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/server.py new file mode 100644 index 0000000000000000000000000000000000000000..1cbd3f2bd6eaa21f0e39562bf5da07369e297ccf --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/lowlevel/server.py @@ -0,0 +1,833 @@ +"""MCP Server Module + +This module provides a framework for creating an MCP (Model Context Protocol) server. +It allows you to easily define and handle various types of requests and notifications +using constructor-based handler registration. + +Usage: +1. Define handler functions: + async def my_list_tools(ctx, params): + return types.ListToolsResult(tools=[...]) + + async def my_call_tool(ctx, params): + return types.CallToolResult(content=[...]) + +2. Create a Server instance with on_* handlers: + server = Server( + "your_server_name", + on_list_tools=my_list_tools, + on_call_tool=my_call_tool, + ) + +3. Run the server: + async def main(): + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + asyncio.run(main()) + +The Server class dispatches incoming requests and notifications to registered +handler callables by method string. +""" + +from __future__ import annotations + +import copy +import logging +import warnings +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from dataclasses import dataclass +from functools import cached_property +from typing import Any, Generic, overload + +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from pydantic import BaseModel +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.routing import Mount, Route +from typing_extensions import TypeVar, deprecated + +from mcp.server._otel import OpenTelemetryMiddleware +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier +from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes +from mcp.server.auth.settings import AuthSettings +from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints +from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.server.models import InitializationOptions +from mcp.server.runner import serve_dual_era_loop +from mcp.server.streamable_http import EventStore +from mcp.server.streamable_http_manager import ( + DEFAULT_MAX_REQUEST_BODY_SIZE, + StreamableHTTPASGIApp, + StreamableHTTPSessionManager, +) +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared._stream_protocols import ReadStream, WriteStream +from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.shared.message import SessionMessage + +logger = logging.getLogger(__name__) + +LifespanResultT = TypeVar("LifespanResultT", default=Any) + +_ParamsT = TypeVar("_ParamsT", bound=BaseModel, default=BaseModel) + +RequestHandler = Callable[[ServerRequestContext[LifespanResultT], _ParamsT], Awaitable[HandlerResult]] +"""A registered request handler: `(ctx, params) -> result`.""" + +NotificationHandler = Callable[[ServerRequestContext[LifespanResultT], _ParamsT], Awaitable[None]] +"""A registered notification handler: `(ctx, params) -> None`.""" + + +@dataclass(frozen=True, slots=True) +class HandlerEntry(Generic[LifespanResultT]): + """A registered handler and the params model to validate incoming params against. + + Stored in `Server._request_handlers` / `_notification_handlers` and consumed + by `ServerRunner` to validate, build `Context`, and invoke. The handler's + second-argument type is erased to `Any` in storage (each entry has a + different concrete params type and `Callable` parameters are contravariant); + the precise type is recoverable via `params_type`. The correlation is + enforced at registration time by `Server.add_request_handler`. + """ + + params_type: type[BaseModel] + handler: RequestHandler[LifespanResultT, Any] + + +class NotificationOptions: + def __init__(self, prompts_changed: bool = False, resources_changed: bool = False, tools_changed: bool = False): + self.prompts_changed = prompts_changed + self.resources_changed = resources_changed + self.tools_changed = tools_changed + + +@asynccontextmanager +async def lifespan(_: Server[Any]) -> AsyncIterator[dict[str, Any]]: + """Default lifespan context manager that does nothing. + + Returns: + An empty context object + """ + yield {} + + +async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestParams | None) -> types.EmptyResult: + return types.EmptyResult() + + +class Server(Generic[LifespanResultT]): + @overload + def __init__( + self, + name: str, + *, + version: str = "", + title: str | None = None, + description: str | None = None, + instructions: str | None = None, + website_url: str | None = None, + icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + lifespan: Callable[ + [Server[LifespanResultT]], + AbstractAsyncContextManager[LifespanResultT], + ] = lifespan, + # Request handlers + on_list_tools: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListToolsResult], + ] + | None = None, + on_call_tool: Callable[ + [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], + Awaitable[types.CallToolResult | types.InputRequiredResult], + ] + | None = None, + on_list_resources: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourcesResult], + ] + | None = None, + on_list_resource_templates: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourceTemplatesResult], + ] + | None = None, + on_read_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.ReadResourceRequestParams], + Awaitable[types.ReadResourceResult | types.InputRequiredResult], + ] + | None = None, + on_subscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_unsubscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.UnsubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_subscriptions_listen: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscriptionsListenRequestParams], + Awaitable[types.SubscriptionsListenResult], + ] + | None = None, + on_list_prompts: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListPromptsResult], + ] + | None = None, + on_get_prompt: Callable[ + [ServerRequestContext[LifespanResultT], types.GetPromptRequestParams], + Awaitable[types.GetPromptResult | types.InputRequiredResult], + ] + | None = None, + on_completion: Callable[ + [ServerRequestContext[LifespanResultT], types.CompleteRequestParams], + Awaitable[types.CompleteResult], + ] + | None = None, + on_ping: Callable[ + [ServerRequestContext[LifespanResultT], types.RequestParams | None], + Awaitable[types.EmptyResult], + ] = _ping_handler, + ) -> None: ... + @overload + @deprecated( + "on_set_logging_level (Logging) and on_roots_list_changed (Roots) are deprecated as of 2026-07-28 " + "(SEP-2577); on_progress (client-to-server progress) is deprecated as of 2026-07-28. Passing any of " + "them emits an MCPDeprecationWarning at runtime.", + category=MCPDeprecationWarning, + ) + def __init__( + self, + name: str, + *, + version: str = "", + title: str | None = None, + description: str | None = None, + instructions: str | None = None, + website_url: str | None = None, + icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + lifespan: Callable[ + [Server[LifespanResultT]], + AbstractAsyncContextManager[LifespanResultT], + ] = lifespan, + # Request handlers + on_list_tools: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListToolsResult], + ] + | None = None, + on_call_tool: Callable[ + [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], + Awaitable[types.CallToolResult | types.InputRequiredResult], + ] + | None = None, + on_list_resources: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourcesResult], + ] + | None = None, + on_list_resource_templates: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourceTemplatesResult], + ] + | None = None, + on_read_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.ReadResourceRequestParams], + Awaitable[types.ReadResourceResult | types.InputRequiredResult], + ] + | None = None, + on_subscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_unsubscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.UnsubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_subscriptions_listen: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscriptionsListenRequestParams], + Awaitable[types.SubscriptionsListenResult], + ] + | None = None, + on_list_prompts: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListPromptsResult], + ] + | None = None, + on_get_prompt: Callable[ + [ServerRequestContext[LifespanResultT], types.GetPromptRequestParams], + Awaitable[types.GetPromptResult | types.InputRequiredResult], + ] + | None = None, + on_completion: Callable[ + [ServerRequestContext[LifespanResultT], types.CompleteRequestParams], + Awaitable[types.CompleteResult], + ] + | None = None, + on_set_logging_level: Callable[ + [ServerRequestContext[LifespanResultT], types.SetLevelRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_ping: Callable[ + [ServerRequestContext[LifespanResultT], types.RequestParams | None], + Awaitable[types.EmptyResult], + ] = _ping_handler, + # Notification handlers + on_roots_list_changed: Callable[ + [ServerRequestContext[LifespanResultT], types.NotificationParams | None], + Awaitable[None], + ] + | None = None, + on_progress: Callable[ + [ServerRequestContext[LifespanResultT], types.ProgressNotificationParams], + Awaitable[None], + ] + | None = None, + ) -> None: ... + def __init__( + self, + name: str, + *, + version: str = "", + title: str | None = None, + description: str | None = None, + instructions: str | None = None, + website_url: str | None = None, + icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + lifespan: Callable[ + [Server[LifespanResultT]], + AbstractAsyncContextManager[LifespanResultT], + ] = lifespan, + # Request handlers + on_list_tools: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListToolsResult], + ] + | None = None, + on_call_tool: Callable[ + [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], + Awaitable[types.CallToolResult | types.InputRequiredResult], + ] + | None = None, + on_list_resources: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourcesResult], + ] + | None = None, + on_list_resource_templates: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourceTemplatesResult], + ] + | None = None, + on_read_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.ReadResourceRequestParams], + Awaitable[types.ReadResourceResult | types.InputRequiredResult], + ] + | None = None, + on_subscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_unsubscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.UnsubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_subscriptions_listen: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscriptionsListenRequestParams], + Awaitable[types.SubscriptionsListenResult], + ] + | None = None, + on_list_prompts: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListPromptsResult], + ] + | None = None, + on_get_prompt: Callable[ + [ServerRequestContext[LifespanResultT], types.GetPromptRequestParams], + Awaitable[types.GetPromptResult | types.InputRequiredResult], + ] + | None = None, + on_completion: Callable[ + [ServerRequestContext[LifespanResultT], types.CompleteRequestParams], + Awaitable[types.CompleteResult], + ] + | None = None, + on_set_logging_level: Callable[ + [ServerRequestContext[LifespanResultT], types.SetLevelRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_ping: Callable[ + [ServerRequestContext[LifespanResultT], types.RequestParams | None], + Awaitable[types.EmptyResult], + ] = _ping_handler, + # Notification handlers + on_roots_list_changed: Callable[ + [ServerRequestContext[LifespanResultT], types.NotificationParams | None], + Awaitable[None], + ] + | None = None, + on_progress: Callable[ + [ServerRequestContext[LifespanResultT], types.ProgressNotificationParams], + Awaitable[None], + ] + | None = None, + ) -> None: + if on_set_logging_level is not None: + warnings.warn( + "The logging capability is deprecated as of 2026-07-28 (SEP-2577).", + MCPDeprecationWarning, + stacklevel=2, + ) + if on_roots_list_changed is not None: + warnings.warn( + "The roots capability is deprecated as of 2026-07-28 (SEP-2577).", + MCPDeprecationWarning, + stacklevel=2, + ) + if on_progress is not None: + warnings.warn( + "Client-to-server progress is deprecated as of 2026-07-28.", + MCPDeprecationWarning, + stacklevel=2, + ) + + self.name = name + self.version = version + self.title = title + self.description = description + self.instructions = instructions + self.website_url = website_url + self.icons = icons + # Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner` + # after the handler returns; fields the handler set explicitly win. + self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints) + self.lifespan = lifespan + self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} + self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} + self._session_manager: StreamableHTTPSessionManager | None = None + # Context-tier middleware: wraps every inbound request (including + # `initialize`, lookup, validation, handler) with + # `(ctx, call_next)`. Applied in `ServerRunner._on_request`. + # `OpenTelemetryMiddleware` ships on by default so every server emits a + # SERVER span per message; it is a no-op until an OTel exporter is + # installed. Drop it from this list to opt out. + # TODO(L54): provisional - signature and semantics change with the + # Context/middleware rework (covariant `Context[L]`, outbound seam) before + # v2 final. + self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()] + # SEP-2133 extension settings advertised under `ServerCapabilities.extensions` + # (identifier -> settings). Higher layers (e.g. `MCPServer(extensions=...)`) + # populate it; `get_capabilities` reads it when no explicit map is passed. + self.extensions: dict[str, dict[str, Any]] = {} + logger.debug("Initializing server %r", name) + + _spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [ + ("ping", types.RequestParams, on_ping), + ("server/discover", types.RequestParams, self._handle_discover), + ("prompts/list", types.PaginatedRequestParams, on_list_prompts), + ("prompts/get", types.GetPromptRequestParams, on_get_prompt), + ("resources/list", types.PaginatedRequestParams, on_list_resources), + ("resources/templates/list", types.PaginatedRequestParams, on_list_resource_templates), + ("resources/read", types.ReadResourceRequestParams, on_read_resource), + ("resources/subscribe", types.SubscribeRequestParams, on_subscribe_resource), + ("resources/unsubscribe", types.UnsubscribeRequestParams, on_unsubscribe_resource), + ("subscriptions/listen", types.SubscriptionsListenRequestParams, on_subscriptions_listen), + ("tools/list", types.PaginatedRequestParams, on_list_tools), + ("tools/call", types.CallToolRequestParams, on_call_tool), + ("logging/setLevel", types.SetLevelRequestParams, on_set_logging_level), + ("completion/complete", types.CompleteRequestParams, on_completion), + ] + self._request_handlers.update({m: HandlerEntry(pt, h) for m, pt, h in _spec_requests if h is not None}) + + _spec_notifications: list[tuple[str, type[BaseModel], NotificationHandler[LifespanResultT, Any] | None]] = [ + ("notifications/roots/list_changed", types.NotificationParams, on_roots_list_changed), + ("notifications/progress", types.ProgressNotificationParams, on_progress), + ] + self._notification_handlers.update( + {m: HandlerEntry(pt, h) for m, pt, h in _spec_notifications if h is not None} + ) + + def add_request_handler( + self, + method: str, + params_type: type[_ParamsT], + handler: RequestHandler[LifespanResultT, _ParamsT], + ) -> None: + """Register a request handler for `method`. + + `params_type` is the model incoming params are validated against + before the handler is invoked. It should subclass `RequestParams` so + `_meta` parses uniformly. A message with no `params` member validates + `{}` against `params_type`: models with required fields reject it as + INVALID_PARAMS, all-optional models reach the handler with their + defaults - the handler never receives `None`. Replaces any existing + handler for the same method, except `initialize`, which is reserved: + the runner owns the handshake, so registering it raises `ValueError`. + Use `Server.middleware` to observe or wrap initialization. + """ + if method == "initialize": + raise ValueError( + "'initialize' is handled by the server runner and cannot be overridden; " + "use Server.middleware to observe or wrap initialization" + ) + self._request_handlers[method] = HandlerEntry(params_type, handler) + + def add_notification_handler( + self, + method: str, + params_type: type[_ParamsT], + handler: NotificationHandler[LifespanResultT, _ParamsT], + ) -> None: + """Register a notification handler for `method`. + + `params_type` should subclass `NotificationParams` so `_meta` + parses uniformly. Absent params follow the same contract as requests: + `{}` is validated, so the handler receives the model with its defaults, + never `None`. Replaces any existing handler. A handler for + `notifications/initialized` runs after the runner has marked the + connection initialized. + """ + self._notification_handlers[method] = HandlerEntry(params_type, handler) + + def get_request_handler(self, method: str) -> HandlerEntry[LifespanResultT] | None: + """Return the registered entry for a request method, or `None`.""" + return self._request_handlers.get(method) + + def get_notification_handler(self, method: str) -> HandlerEntry[LifespanResultT] | None: + """Return the registered entry for a notification method, or `None`.""" + return self._notification_handlers.get(method) + + # TODO(L53): Rethink capabilities API. Currently capabilities are derived from registered + # handlers but require NotificationOptions to be passed externally for list_changed + # flags, and experimental_capabilities as a separate dict. Consider deriving capabilities + # entirely from server state (e.g. constructor params for list_changed) instead of + # requiring callers to assemble them at create_initialization_options() time. + def create_initialization_options( + self, + notification_options: NotificationOptions | None = None, + experimental_capabilities: dict[str, dict[str, Any]] | None = None, + extensions: dict[str, dict[str, Any]] | None = None, + ) -> InitializationOptions: + """Create initialization options from this server instance. + + `extensions` advertises SEP-2133 extension support under + `ServerCapabilities.extensions`; keys are extension identifiers (e.g. + `io.modelcontextprotocol/ui`), values are per-extension settings. + Defaults to `self.extensions`, which higher layers populate. + """ + return InitializationOptions( + server_name=self.name, + server_version=self.version, + title=self.title, + description=self.description, + capabilities=self.get_capabilities( + notification_options or NotificationOptions(), + experimental_capabilities or {}, + extensions if extensions is not None else self.extensions, + ), + instructions=self.instructions, + website_url=self.website_url, + icons=self.icons, + ) + + def get_capabilities( + self, + notification_options: NotificationOptions | None = None, + experimental_capabilities: dict[str, dict[str, Any]] | None = None, + extensions: dict[str, dict[str, Any]] | None = None, + *, + protocol_version: str | None = None, + ) -> types.ServerCapabilities: + """Convert existing handlers to a ServerCapabilities object. + + `extensions` is the SEP-2133 extension map (identifier -> settings) + advertised under `ServerCapabilities.extensions`; it defaults to + `self.extensions`. + + `protocol_version` makes the subscription-delivered bits era-honest: + at 2026-07-28+ versions, change notifications are delivered only on + `subscriptions/listen` streams, so the `listChanged` flags and + `resources.subscribe` derive from whether that method is served - + `notification_options` and the legacy `resources/subscribe` handler + (which the modern wire cannot dispatch) are ignored. When omitted, the + handshake-era derivation applies unchanged. + """ + notification_options = notification_options or NotificationOptions() + prompts_capability = None + resources_capability = None + tools_capability = None + logging_capability = None + completions_capability = None + + if protocol_version in MODERN_PROTOCOL_VERSIONS: + listen_served = "subscriptions/listen" in self._request_handlers + prompts_changed = tools_changed = resources_changed = subscribe = listen_served + else: + prompts_changed = notification_options.prompts_changed + tools_changed = notification_options.tools_changed + resources_changed = notification_options.resources_changed + subscribe = "resources/subscribe" in self._request_handlers + + # Set prompt capabilities if handler exists + if "prompts/list" in self._request_handlers: + prompts_capability = types.PromptsCapability(list_changed=prompts_changed) + + # Set resource capabilities if handler exists + if "resources/list" in self._request_handlers: + resources_capability = types.ResourcesCapability( + subscribe=subscribe, + list_changed=resources_changed, + ) + + # Set tool capabilities if handler exists + if "tools/list" in self._request_handlers: + tools_capability = types.ToolsCapability(list_changed=tools_changed) + + # Set logging capabilities if handler exists + if "logging/setLevel" in self._request_handlers: + logging_capability = types.LoggingCapability() + + # Set completions capabilities if handler exists + if "completion/complete" in self._request_handlers: + completions_capability = types.CompletionsCapability() + + capabilities = types.ServerCapabilities( + prompts=prompts_capability, + resources=resources_capability, + tools=tools_capability, + logging=logging_capability, + experimental=experimental_capabilities, + extensions=extensions if extensions is not None else (self.extensions or None), + completions=completions_capability, + ) + return capabilities + + @property + def server_info(self) -> types.Implementation: + """The `serverInfo` block describing this implementation. + + Derived from the constructor's identity fields. An unversioned server + reports an empty `version`; the SDK never substitutes its own. + """ + return types.Implementation( + name=self.name, + version=self.version, + title=self.title, + description=self.description, + website_url=self.website_url, + icons=self.icons, + ) + + @cached_property + def _server_info_stamp_source(self) -> dict[str, Any]: + # Identity is fixed at construction, so the dump is computed once per + # server instead of per request. Never handed out directly: nested + # values (`icons`) would alias the cache into stamped responses. + return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True) + + @property + def server_info_stamp(self) -> dict[str, Any]: + """A fresh wire dump of `server_info`; callers own the returned dict. + + Each access materializes a deep copy of the once-per-server dump, so + a caller mutating a stamped response can never corrupt the identity + stamped into later responses. + """ + return copy.deepcopy(self._server_info_stamp_source) + + async def _handle_discover( + self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None + ) -> types.DiscoverResult: + """Default `server/discover` handler. + + Auto-derived from server state at call time, so capabilities reflect + whatever has been registered (constructor `on_*` kwargs and later + `add_request_handler` calls). Operators can replace it wholesale via + `add_request_handler("server/discover", ...)`. Reachability for legacy + peers is decided at the boundary (`types.methods`), not here. + """ + return types.DiscoverResult( + supported_versions=list(MODERN_PROTOCOL_VERSIONS), + capabilities=self.get_capabilities(protocol_version=ctx.protocol_version), + instructions=self.instructions, + ) + + @property + def session_manager(self) -> StreamableHTTPSessionManager: + """Get the StreamableHTTP session manager. + + Raises: + RuntimeError: If called before streamable_http_app() has been called. + """ + if self._session_manager is None: + raise RuntimeError( # pragma: no cover + "Session manager can only be accessed after calling streamable_http_app(). " + "The session manager is created lazily to avoid unnecessary initialization." + ) + return self._session_manager + + async def run( + self, + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + initialization_options: InitializationOptions, + # When False, exceptions are returned as messages to the client. + # When True, exceptions are raised, which will cause the server to shut down + # but also make tracing exceptions much easier during testing and when using + # in-process servers. + raise_exceptions: bool = False, + ) -> None: + """Serve a single connection over the given streams until the read side closes. + + Thin wrapper over `serve_dual_era_loop`: enters the server lifespan, + then drives the loop, serving the legacy handshake era and the modern + per-request-envelope era (the client's first request decides which). + Transports with their own lifespan owner (the streamable-HTTP manager) + call `serve_loop` directly instead. + """ + async with self.lifespan(self) as lifespan_context: + await serve_dual_era_loop( + self, + read_stream, + write_stream, + lifespan_state=lifespan_context, + init_options=initialization_options, + raise_exceptions=raise_exceptions, + ) + + def streamable_http_app( + self, + *, + streamable_http_path: str = "/mcp", + json_response: bool = False, + stateless_http: bool = False, + event_store: EventStore | None = None, + retry_interval: int | None = None, + max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, + transport_security: TransportSecuritySettings | None = None, + host: str = "127.0.0.1", + auth: AuthSettings | None = None, + token_verifier: TokenVerifier | None = None, + auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, + custom_starlette_routes: list[Route] | None = None, + debug: bool = False, + ) -> Starlette: + """Return an instance of the StreamableHTTP server app.""" + # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) + if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], + allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], + ) + + session_manager = StreamableHTTPSessionManager( + app=self, + event_store=event_store, + retry_interval=retry_interval, + json_response=json_response, + stateless=stateless_http, + security_settings=transport_security, + max_request_body_size=max_request_body_size, + ) + self._session_manager = session_manager + + # Create the ASGI handler + streamable_http_app = StreamableHTTPASGIApp(session_manager) + + # Create routes + routes: list[Route | Mount] = [] + middleware: list[Middleware] = [] + required_scopes: list[str] = [] + + # Set up auth if configured + if auth: + required_scopes = auth.required_scopes or [] + + # Add auth middleware if token verifier is available + if token_verifier: + middleware = [ + Middleware( + AuthenticationMiddleware, + backend=BearerAuthBackend(token_verifier), + ), + Middleware(AuthContextMiddleware), + ] + + # Add auth endpoints if auth server provider is configured + if auth_server_provider: + routes.extend( + create_auth_routes( + provider=auth_server_provider, + issuer_url=auth.issuer_url, + service_documentation_url=auth.service_documentation_url, + client_registration_options=auth.client_registration_options, + revocation_options=auth.revocation_options, + identity_assertion_enabled=auth.identity_assertion_enabled, + ) + ) + + # Set up routes with or without auth + if token_verifier: + # Determine resource metadata URL + resource_metadata_url = None + if auth and auth.resource_server_url: # pragma: no branch + # Build compliant metadata URL for WWW-Authenticate header + resource_metadata_url = build_resource_metadata_url(auth.resource_server_url) + + routes.append( + Route( + streamable_http_path, + endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url), + ) + ) + else: + # Auth is disabled, no wrapper needed + routes.append( + Route( + streamable_http_path, + endpoint=streamable_http_app, + ) + ) + + # Add protected resource metadata endpoint if configured as RS + if auth and auth.resource_server_url: + routes.extend( + create_protected_resource_routes( + resource_url=auth.resource_server_url, + authorization_servers=[auth.issuer_url], + scopes_supported=auth.required_scopes, + ) + ) + + if custom_starlette_routes: + routes.extend(custom_starlette_routes) + + return Starlette( + debug=debug, + routes=routes, + middleware=middleware, + lifespan=lambda app: session_manager.run(), + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..56d1c23cbad455e1081331c41485b67c98a7aac1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/__init__.py @@ -0,0 +1,57 @@ +"""MCPServer - A more ergonomic interface for MCP servers.""" + +from mcp_types import Icon + +from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding +from mcp.server.request_state import ( + AESGCMRequestStateCodec, + InvalidRequestState, + RequestStateBoundary, + RequestStateCodec, + RequestStateSecurity, + authenticated_principal, +) + +from .context import Context +from .resolve import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, + Elicit, + ElicitationResult, + ListRoots, + Resolve, + Sample, +) +from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity +from .server import MCPServer, require_client_extension +from .utilities.types import Audio, Image + +__all__ = [ + "MCPServer", + "Context", + "Image", + "Audio", + "Icon", + "Resolve", + "Elicit", + "Sample", + "ListRoots", + "ElicitationResult", + "AcceptedElicitation", + "DeclinedElicitation", + "CancelledElicitation", + "Extension", + "ToolBinding", + "ResourceBinding", + "MethodBinding", + "require_client_extension", + "ResourceSecurity", + "DEFAULT_RESOURCE_SECURITY", + "RequestStateSecurity", + "RequestStateCodec", + "RequestStateBoundary", + "AESGCMRequestStateCodec", + "InvalidRequestState", + "authenticated_principal", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/context.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/context.py new file mode 100644 index 0000000000000000000000000000000000000000..bf4c26a248a45ac700cc3c7a2749b46e72072c58 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/context.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, Generic, cast + +from mcp_types import ClientCapabilities, InputRequiredResult, InputResponseRequestParams, InputResponses, LoggingLevel +from pydantic import AnyUrl, BaseModel +from typing_extensions import deprecated + +from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext +from mcp.server.elicitation import ( + ElicitationResult, + ElicitSchemaModelT, + UrlElicitationResult, + elicit_url, + elicit_with_validation, +) +from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.server.subscriptions import SubscriptionBus +from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.shared.subscriptions import ( + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ToolsListChanged, +) + +if TYPE_CHECKING: + from mcp.server.mcpserver.server import MCPServer + + +class Context(BaseModel, Generic[LifespanContextT, RequestT]): + """Context object providing access to MCP capabilities. + + This provides a cleaner interface to MCP's RequestContext functionality. + It gets injected into tool and resource functions that request it via type hints. + + To use context in a tool function, add a parameter with the Context type annotation: + + ```python + @server.tool() + async def my_tool(x: int, ctx: Context) -> str: + # Log messages to the client + await ctx.info(f"Processing {x}") + await ctx.debug("Debug info") + await ctx.warning("Warning message") + await ctx.error("Error message") + + # Report progress + await ctx.report_progress(50, 100) + + # Access resources + data = await ctx.read_resource("resource://data") + + # Get request info + request_id = ctx.request_id + + return str(x) + ``` + + The context parameter name can be anything as long as it's annotated with Context. + The context is optional - tools that don't need it can omit the parameter. + """ + + _request_context: ServerRequestContext[LifespanContextT, RequestT] | None + _mcp_server: MCPServer | None + _input_params: InputResponseRequestParams | None + _subscriptions: SubscriptionBus | None + + # TODO(maxisbey): Consider making request_context/mcp_server required, or refactor Context entirely. + def __init__( + self, + *, + request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None, + mcp_server: MCPServer | None = None, + input_params: InputResponseRequestParams | None = None, + subscriptions: SubscriptionBus | None = None, + # TODO(Marcelo): We should drop this kwargs parameter. + **kwargs: Any, + ): + super().__init__(**kwargs) + self._request_context = request_context + self._mcp_server = mcp_server + self._input_params = input_params + self._subscriptions = subscriptions + + @property + def mcp_server(self) -> MCPServer: + """Access to the MCPServer instance.""" + if self._mcp_server is None: + raise ValueError("Context is not available outside of a request") + return self._mcp_server + + @property + def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]: + """Access to the underlying request context.""" + if self._request_context is None: + raise ValueError("Context is not available outside of a request") + return self._request_context + + def _nested_invocation(self) -> Context[LifespanContextT, RequestT]: + """A Context for invoking another handler's function from inside this request. + + Shares the request infrastructure (session, request metadata, lifespan) but + carries no `input_responses`/`request_state`: those are addressed to the wire + request's own target โ€” their keys are ones that handler minted โ€” so a nested + invocation always starts on round one. + """ + return Context( + request_context=self._request_context, mcp_server=self._mcp_server, subscriptions=self._subscriptions + ) + + async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + """Report progress for the current operation. + + Args: + progress: Current progress value (e.g., 24) + total: Optional total value (e.g., 100) + message: Optional message (e.g., "Starting render...") + """ + await self.request_context.session.report_progress(progress, total, message) + + @property + def _bus(self) -> SubscriptionBus: + if self._subscriptions is None: + raise ValueError("Context is not available outside of a request") + return self._subscriptions + + async def notify_tools_changed(self) -> None: + """Publish a tools list-changed event to `subscriptions/listen` subscribers.""" + await self._bus.publish(ToolsListChanged()) + + async def notify_prompts_changed(self) -> None: + """Publish a prompts list-changed event to `subscriptions/listen` subscribers.""" + await self._bus.publish(PromptsListChanged()) + + async def notify_resources_changed(self) -> None: + """Publish a resources list-changed event to `subscriptions/listen` subscribers.""" + await self._bus.publish(ResourcesListChanged()) + + async def notify_resource_updated(self, uri: str | AnyUrl) -> None: + """Publish a resource-updated event for `uri` to `subscriptions/listen` subscribers. + + The URI is matched as an exact string against each stream's filter. + Reaches `subscriptions/listen` streams only; clients on earlier + protocol versions that used `resources/subscribe` are notified via + `ctx.session.send_resource_updated(uri)` instead. + """ + await self._bus.publish(ResourceUpdated(uri=str(uri))) + + async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: + """Read a resource by URI. + + This is a content reader: an `InputRequiredResult` returned by a + resource template function (the 2026-07-28 multi-round-trip flow) + raises here, and the nested template never sees this request's + `input_responses`/`request_state` โ€” those answer the outer handler's + own questions, so the template always behaves as round one. A handler + that wants to receive and forward an `InputRequiredResult` as its own + result calls `MCPServer.read_resource(uri, context)` instead โ€” but + not from a tool whose dependencies elicit via `Resolve(...)`: the + resolver owns that tool's `request_state` channel, and a forwarded + result's state would clobber it. + + Args: + uri: Resource URI to read + + Returns: + The resource content as either text or bytes + + Raises: + ResourceNotFoundError: If no resource or template matches the URI. + ResourceError: If template creation or resource reading fails. + RuntimeError: If the resource returned an `InputRequiredResult`. + """ + assert self._mcp_server is not None, "Context is not available outside of a request" + result = await self._mcp_server.read_resource(uri, self._nested_invocation()) + if isinstance(result, InputRequiredResult): + raise RuntimeError( + "Resource returned InputRequiredResult; ctx.read_resource() only returns " + "content โ€” use MCPServer.read_resource(uri, context) to receive and forward it." + ) + return result + + async def elicit( + self, + message: str, + schema: type[ElicitSchemaModelT], + ) -> ElicitationResult[ElicitSchemaModelT]: + """Elicit information from the client/user. + + This method can be used to interactively ask for additional information from the + client within a tool's execution. The client might display the message to the + user and collect a response according to the provided schema. If the client + is an agent, it might decide how to handle the elicitation -- either by asking + the user or automatically generating a response. + + Args: + message: Message to present to the user + schema: A Pydantic model class defining the expected response structure. + According to the specification, only primitive types are allowed. + + Returns: + An ElicitationResult containing the action taken and the data if accepted + + Note: + Check the result.action to determine if the user accepted, declined, or cancelled. + The result.data will only be populated if action is "accept" and validation succeeded. + """ + + return await elicit_with_validation( + session=self.request_context.session, + message=message, + schema=schema, + related_request_id=self.request_id, + ) + + async def elicit_url( + self, + message: str, + url: str, + elicitation_id: str, + ) -> UrlElicitationResult: + """Request URL mode elicitation from the client. + + This directs the user to an external URL for out-of-band interactions + that must not pass through the MCP client. Use this for: + - Collecting sensitive credentials (API keys, passwords) + - OAuth authorization flows with third-party services + - Payment and subscription flows + - Any interaction where data should not pass through the LLM context + + The response indicates whether the user consented to navigate to the URL. + The actual interaction happens out-of-band. When the elicitation completes, + call `ctx.session.send_elicit_complete(elicitation_id)` to notify the client. + + Args: + message: Human-readable explanation of why the interaction is needed + url: The URL the user should navigate to + elicitation_id: Unique identifier for tracking this elicitation + + Returns: + UrlElicitationResult indicating accept, decline, or cancel + """ + return await elicit_url( + session=self.request_context.session, + message=message, + url=url, + elicitation_id=elicitation_id, + related_request_id=self.request_id, + ) + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def log( + self, + level: LoggingLevel, + data: Any, + *, + logger_name: str | None = None, + ) -> None: + """Send a log message to the client. + + Args: + level: Log level (debug, info, notice, warning, error, critical, + alert, emergency) + data: The data to be logged. Any JSON serializable type is allowed + (string, dict, list, number, bool, etc.) per the MCP specification. + logger_name: Optional logger name + """ + await self.request_context.session.send_log_message( # pyright: ignore[reportDeprecated] + level=level, + data=data, + logger=logger_name, + related_request_id=self.request_id, + ) + + @property + def headers(self) -> Mapping[str, str] | None: + """Request headers carried by this message, when the transport has them. + + Populated by HTTP-based transports; `None` on stdio or when the + transport's request object carries no headers. Headers are + client-supplied input - never treat one as an identity assertion. + """ + return cast("Mapping[str, str] | None", getattr(self.request_context.request, "headers", None)) + + @property + def request_id(self) -> str: + """Get the unique ID for this request.""" + return str(self.request_context.request_id) + + @property + def protocol_version(self) -> str | None: + """The negotiated protocol version, or `None` outside of an active request.""" + return self._request_context.protocol_version if self._request_context is not None else None + + @property + def input_responses(self) -> InputResponses | None: + """Client responses to a prior `InputRequiredResult.input_requests`. + + `None` on the initial round, or when the client retried without + responses. + """ + return self._input_params.input_responses if self._input_params else None + + @property + def request_state(self) -> str | None: + """Opaque state echoed from a prior `InputRequiredResult.request_state`. + + `None` on the initial round. + """ + return self._input_params.request_state if self._input_params else None + + @property + def client_capabilities(self) -> ClientCapabilities | None: + """The client's declared capabilities for this connection. + + `None` when the client declared none (e.g. an anonymous stateless + request without the reserved `_meta` keys). Client info is not + required for capabilities to be recorded. + """ + return self.request_context.session.client_capabilities + + @property + def session(self): + """Access to the underlying session for advanced usage.""" + return self.request_context.session + + async def close_sse_stream(self) -> None: + """Close the SSE stream to trigger client reconnection. + + This method closes the HTTP connection for the current request, triggering + client reconnection. Events continue to be stored in the event store and will + be replayed when the client reconnects with Last-Event-ID. + + Use this to implement polling behavior during long-running operations - + the client will reconnect after the retry interval specified in the priming event. + + Note: + This is a no-op if not using StreamableHTTP transport with event_store. + The callback is only available when event_store is configured. + """ + if self._request_context and self._request_context.close_sse_stream: # pragma: no branch + await self._request_context.close_sse_stream() + + async def close_standalone_sse_stream(self) -> None: + """Close the standalone GET SSE stream to trigger client reconnection. + + This method closes the HTTP connection for the standalone GET stream used + for unsolicited server-to-client notifications. The client SHOULD reconnect + with Last-Event-ID to resume receiving notifications. + + Note: + This is a no-op if not using StreamableHTTP transport with event_store. + Currently, client reconnection for standalone GET streams is NOT + implemented - this is a known gap. + """ + if self._request_context and self._request_context.close_standalone_sse_stream: # pragma: no cover + await self._request_context.close_standalone_sse_stream() + + # Convenience methods for common log levels + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def debug(self, data: Any, *, logger_name: str | None = None) -> None: + """Send a debug log message.""" + await self.log("debug", data, logger_name=logger_name) # pyright: ignore[reportDeprecated] + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def info(self, data: Any, *, logger_name: str | None = None) -> None: + """Send an info log message.""" + await self.log("info", data, logger_name=logger_name) # pyright: ignore[reportDeprecated] + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def warning(self, data: Any, *, logger_name: str | None = None) -> None: + """Send a warning log message.""" + await self.log("warning", data, logger_name=logger_name) # pyright: ignore[reportDeprecated] + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def error(self, data: Any, *, logger_name: str | None = None) -> None: + """Send an error log message.""" + await self.log("error", data, logger_name=logger_name) # pyright: ignore[reportDeprecated] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/exceptions.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..239785e9a986eb59dc0568e379584699d9e22428 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/exceptions.py @@ -0,0 +1,26 @@ +"""Custom exceptions for MCPServer.""" + + +class MCPServerError(Exception): + """Base error for MCPServer.""" + + +class ResourceError(MCPServerError): + """Error in resource operations.""" + + +class ResourceNotFoundError(ResourceError): + """Resource does not exist. + + Raise this from a resource template handler to signal that the requested instance does not exist; + clients receive `-32602` (invalid params) per + [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). + """ + + +class ToolError(MCPServerError): + """Error in tool operations.""" + + +class InvalidSignature(Exception): + """Invalid signature for use with MCPServer.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..763726964c234f9532a88edbc06ade78cc94c6b1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/__init__.py @@ -0,0 +1,4 @@ +from .base import Prompt +from .manager import PromptManager + +__all__ = ["Prompt", "PromptManager"] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/base.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/base.py new file mode 100644 index 0000000000000000000000000000000000000000..0a010de7d286f4a3c0c6a6e8204d6fd7a1957a6b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/base.py @@ -0,0 +1,200 @@ +"""Base classes for MCPServer prompts.""" + +from __future__ import annotations + +import functools +from collections.abc import Awaitable, Callable, Sequence +from typing import TYPE_CHECKING, Any, Literal + +import anyio.to_thread +import pydantic_core +from mcp_types import ContentBlock, Icon, InputRequiredResult, TextContent +from pydantic import BaseModel, Field, TypeAdapter, validate_call + +from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context +from mcp.server.mcpserver.utilities.func_metadata import func_metadata +from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError + +if TYPE_CHECKING: + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.context import Context + + +class Message(BaseModel): + """Base class for all prompt messages.""" + + role: Literal["user", "assistant"] + content: ContentBlock + + def __init__(self, content: str | ContentBlock, **kwargs: Any): + if isinstance(content, str): + content = TextContent(type="text", text=content) + super().__init__(content=content, **kwargs) + + +class UserMessage(Message): + """A message from the user.""" + + role: Literal["user", "assistant"] = "user" + + def __init__(self, content: str | ContentBlock, **kwargs: Any): + super().__init__(content=content, **kwargs) + + +class AssistantMessage(Message): + """A message from the assistant.""" + + role: Literal["user", "assistant"] = "assistant" + + def __init__(self, content: str | ContentBlock, **kwargs: Any): + super().__init__(content=content, **kwargs) + + +message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage) + +SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]] +PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] + + +class PromptArgument(BaseModel): + """An argument that can be passed to a prompt.""" + + name: str = Field(description="Name of the argument") + description: str | None = Field(None, description="Description of what the argument does") + required: bool = Field(default=False, description="Whether the argument is required") + + +class Prompt(BaseModel): + """A prompt template that can be rendered with parameters.""" + + name: str = Field(description="Name of the prompt") + title: str | None = Field(None, description="Human-readable title of the prompt") + description: str | None = Field(None, description="Description of what the prompt does") + arguments: list[PromptArgument] | None = Field(None, description="Arguments that can be passed to the prompt") + fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True) + icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this prompt") + context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context", exclude=True) + + @classmethod + def from_function( + cls, + fn: Callable[..., PromptResult | Awaitable[PromptResult]], + name: str | None = None, + title: str | None = None, + description: str | None = None, + icons: list[Icon] | None = None, + context_kwarg: str | None = None, + ) -> Prompt: + """Create a Prompt from a function. + + The function can return: + - A string (converted to a message) + - A Message object + - A dict (converted to a message) + - A sequence of any of the above + - An InputRequiredResult (passed through unchanged; the 2026-07-28 + multi-round-trip flow โ€” read `ctx.input_responses` on the retry) + """ + func_name = name or fn.__name__ + + if func_name == "": # pragma: no cover + raise ValueError("You must provide a name for lambda functions") + + # Find context parameter if it exists + if context_kwarg is None: # pragma: no branch + context_kwarg = find_context_parameter(fn) + + # Get schema from func_metadata, excluding context parameter + func_arg_metadata = func_metadata( + fn, + skip_names=[context_kwarg] if context_kwarg is not None else [], + ) + parameters = func_arg_metadata.arg_model.model_json_schema() + + # Convert parameters to PromptArguments + arguments: list[PromptArgument] = [] + if "properties" in parameters: # pragma: no branch + for param_name, param in parameters["properties"].items(): + required = param_name in parameters.get("required", []) + arguments.append( + PromptArgument( + name=param_name, + description=param.get("description"), + required=required, + ) + ) + + # ensure the arguments are properly cast + fn = validate_call(fn) + + return cls( + name=func_name, + title=title, + description=description or fn.__doc__ or "", + arguments=arguments, + fn=fn, + icons=icons, + context_kwarg=context_kwarg, + ) + + async def render( + self, + arguments: dict[str, Any] | None, + context: Context[LifespanContextT, RequestT], + ) -> list[Message] | InputRequiredResult: + """Render the prompt with arguments. + + An `InputRequiredResult` returned by the prompt function is passed + through unchanged so the multi-round-trip flow reaches the client. + + Raises: + ValueError: If required arguments are missing, or if rendering fails. + """ + # Validate required arguments + if self.arguments: + required = {arg.name for arg in self.arguments if arg.required} + provided = set(arguments or {}) + missing = required - provided + if missing: + raise ValueError(f"Missing required arguments: {missing}") + + try: + # Add context to arguments if needed + call_args = inject_context(self.fn, arguments or {}, context, self.context_kwarg) + + fn = self.fn + if is_async_callable(fn): + result = await fn(**call_args) + else: + result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args)) + + if isinstance(result, InputRequiredResult): + return result + + # Validate messages + if not isinstance(result, list | tuple): + result = [result] + + # Convert result to messages + messages: list[Message] = [] + for msg in result: # type: ignore[reportUnknownVariableType] + try: + if isinstance(msg, Message): + messages.append(msg) + elif isinstance(msg, dict): + messages.append(message_validator.validate_python(msg)) + elif isinstance(msg, str): + content = TextContent(type="text", text=msg) + messages.append(UserMessage(content=content)) + else: # pragma: no cover + content = pydantic_core.to_json(msg, fallback=str, indent=2).decode() + messages.append(Message(role="user", content=content)) + except Exception: # pragma: no cover + raise ValueError(f"Could not convert prompt result to message: {msg}") + + return messages + except MCPError: + raise + except Exception as e: + raise ValueError(f"Error rendering prompt {self.name}: {e}") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/manager.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/manager.py new file mode 100644 index 0000000000000000000000000000000000000000..01a0823bf74c72c2197b846bf94a5f0ff00dfcea --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/prompts/manager.py @@ -0,0 +1,67 @@ +"""Prompt management functionality.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from mcp_types import InputRequiredResult + +from mcp.server.mcpserver.prompts.base import Message, Prompt +from mcp.server.mcpserver.utilities.logging import get_logger + +if TYPE_CHECKING: + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.context import Context + +logger = get_logger(__name__) + + +class PromptManager: + """Manages MCPServer prompts.""" + + def __init__(self, warn_on_duplicate_prompts: bool = True): + self._prompts: dict[str, Prompt] = {} + self.warn_on_duplicate_prompts = warn_on_duplicate_prompts + + def get_prompt(self, name: str) -> Prompt | None: + """Get prompt by name.""" + return self._prompts.get(name) + + def list_prompts(self) -> list[Prompt]: + """List all registered prompts.""" + return list(self._prompts.values()) + + def add_prompt( + self, + prompt: Prompt, + ) -> Prompt: + """Add a prompt to the manager.""" + + # Check for duplicates + existing = self._prompts.get(prompt.name) + if existing: + if self.warn_on_duplicate_prompts: + logger.warning(f"Prompt already exists: {prompt.name}") + return existing + + self._prompts[prompt.name] = prompt + return prompt + + def remove_prompt(self, name: str) -> None: + """Remove a prompt by name.""" + if name not in self._prompts: + raise ValueError(f"Unknown prompt: {name}") + del self._prompts[name] + + async def render_prompt( + self, + name: str, + arguments: dict[str, Any] | None, + context: Context[LifespanContextT, RequestT], + ) -> list[Message] | InputRequiredResult: + """Render a prompt by name with arguments.""" + prompt = self.get_prompt(name) + if not prompt: + raise ValueError(f"Unknown prompt: {name}") + + return await prompt.render(arguments, context) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resolve.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resolve.py new file mode 100644 index 0000000000000000000000000000000000000000..d4a744af3788fb96edb24e17b25430410977c87e --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resolve.py @@ -0,0 +1,838 @@ +"""Resolver dependency injection for MCPServer tools. + +A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running the +resolver `fn` before the tool body, instead of from the LLM-supplied arguments. +Resolvers form a DAG: a resolver may declare its own `Resolve(...)` dependencies, +take tool arguments by name, and take the `Context`. A resolver may return a +request marker (`Elicit[T]` to ask the user, `Sample` to sample the client's +LLM, `ListRoots` to fetch its roots); the framework injects the response. + +The transport follows the negotiated protocol: >= 2026-07-28 batches the requests +into an `InputRequiredResult` and resumes when the client retries with +`input_responses`/`request_state`; <= 2025-11-25 sends each standalone server-to-client +request mid-call. Only *asked* outcomes ride `request_state`, so each question is asked once. Resolver +bodies may re-run on every round; a recorded outcome is consulted only when the +body asks its question again, so a resolver's own computation always wins over +anything the client echoes back in `request_state`. + +Whether the consumer receives the unwrapped model or the full +`ElicitationResult` union is decided by the consumer's annotation: + +- `Annotated[T, Resolve(fn)]` -> unwrapped `T`; decline/cancel aborts the call. +- `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the + full outcome; the consumer branches on accept/decline/cancel. + +`Sample` and `ListRoots` have no decline arm; their consumers annotate the result type directly. +""" + +from __future__ import annotations + +import base64 +import hashlib +import inspect +import json +import logging +import types +import typing +from collections.abc import Callable, Hashable, Mapping +from typing import Annotated, Any, Generic, Literal, TypeGuard, get_args, get_origin + +import anyio.to_thread +from mcp_types import ( + MISSING_REQUIRED_CLIENT_CAPABILITY, + ClientCapabilities, + CreateMessageRequest, + CreateMessageRequestParams, + CreateMessageResult, + CreateMessageResultWithTools, + ElicitationCapability, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + FormElicitationCapability, + IncludeContext, + InputRequest, + InputRequests, + InputRequiredResult, + InputResponses, + ListRootsRequest, + ListRootsResult, + MissingRequiredClientCapabilityErrorData, + ModelPreferences, + RootsCapability, + SamplingCapability, + SamplingMessage, + SamplingToolsCapability, + Tool, + ToolChoice, +) +from mcp_types.version import is_version_at_least +from pydantic import BaseModel, ValidationError +from typing_extensions import TypeVar + +from mcp.server.elicitation import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, + ElicitationResult, + render_elicitation_schema, +) +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError +from mcp.server.request_state import compact_json +from mcp.server.validation import validate_tool_use_result_messages, wants_sampling_tools +from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError +from mcp.shared.message import ServerMessageMetadata + +T = TypeVar("T", bound=BaseModel) + +# The union members the framework injects when a consumer opts into the outcome. +_ELICITATION_RESULT_MEMBERS = (AcceptedElicitation, DeclinedElicitation, CancelledElicitation) + +# First protocol revision whose `tools/call` carries elicitation inside +# `InputRequiredResult` rather than as a standalone server-to-client request. +# Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added). +_INPUT_REQUIRED_VERSION = "2026-07-28" +_STATE_VERSION = 3 # v3: recorded and pended outcomes pinned to ASCII-canonical question renders + +logger = logging.getLogger(__name__) + + +class Resolve: + """Marker for `Annotated[T, Resolve(fn)]`: fill the parameter by running `fn`.""" + + def __init__(self, fn: Callable[..., Any]) -> None: + self.fn = fn + + +class Elicit(Generic[T]): + """A resolver's request to ask the client. + + Returned from a resolver to signal that the value must be elicited. The + framework runs `ctx.elicit(message, schema)` and injects the outcome. + """ + + def __init__(self, message: str, schema: type[T]) -> None: + self.message = message + self.schema = schema + + +class Sample: + """A resolver's request to sample the client's LLM via `sampling/createMessage`. + + The framework injects a `CreateMessageResult` (`CreateMessageResultWithTools` when `tools` or + `tool_choice` are given, which also requires the client's `sampling.tools`); requires the + `sampling` capability. On >= 2026-07-28 the request must render identically across retry + rounds, and the sampled result rides `request_state` on every later round. `include_context` + other than "none" is deprecated in the draft spec. + """ + + def __init__( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: list[Tool] | None = None, + tool_choice: ToolChoice | None = None, + ) -> None: + validate_tool_use_result_messages(messages) + self.params = CreateMessageRequestParams( + messages=messages, + max_tokens=max_tokens, + system_prompt=system_prompt, + include_context=include_context, + temperature=temperature, + stop_sequences=stop_sequences, + metadata=metadata, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + ) + + +class ListRoots: + """A resolver's request for the client's roots via `roots/list`; the framework injects the `ListRootsResult`.""" + + +_Marker = Elicit[Any] | Sample | ListRoots +"""The request markers a resolver may return.""" + + +class _ParamPlan: + """How to fill one resolver parameter, decided once at registration.""" + + kind: str # "context" | "resolve" | "by_name" + resolve: Resolve | None + wants_union: bool + + def __init__(self, kind: str, resolve: Resolve | None = None, wants_union: bool = False) -> None: + self.kind = kind + self.resolve = resolve + self.wants_union = wants_union + + +class _ResolverPlan: + """A resolver's parameters and whether it is async, analyzed once.""" + + def __init__( + self, + fn: Callable[..., Any], + params: dict[str, _ParamPlan], + is_async: bool, + wire_key: str, + ) -> None: + self.fn = fn + self.params = params + self.is_async = is_async + # Deterministic, collision-free key for this resolver's elicitation on the + # wire (`input_requests`/`request_state`). Assigned at registration so it is + # stable across rounds even when `module:qualname` collides (closures). + self.wire_key = wire_key + + +def _type_hints(fn: Callable[..., Any]) -> dict[str, Any]: + """Resolve type hints for a function or a callable object. + + `typing.get_type_hints` raises on a callable *instance*; fall back to its + `__call__`. Returns an empty mapping when hints cannot be resolved, matching + `find_context_parameter`'s tolerance so callables without annotations (or with + unresolvable ones) simply have no resolved parameters. + """ + target = fn if inspect.isroutine(fn) else getattr(type(fn), "__call__", fn) + try: + return typing.get_type_hints(target, include_extras=True) + except Exception: + return {} + + +def _resolver_name(fn: Callable[..., Any]) -> str: + """Best-effort display name for error messages (callable objects lack `__name__`).""" + return getattr(fn, "__name__", None) or type(fn).__name__ + + +def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve, bool]]: + """Find parameters of `fn` annotated `Annotated[_, Resolve(...)]`. + + Returns a mapping of parameter name to `(Resolve, wants_union)`, where + `wants_union` is True when the annotated type is an `ElicitationResult` member + (the consumer wants the full outcome rather than the unwrapped model). + """ + hints = _type_hints(fn) + resolved: dict[str, tuple[Resolve, bool]] = {} + for name in inspect.signature(fn).parameters: + annotation = hints.get(name) + if get_origin(annotation) is not Annotated: + # A `Resolve` marker is only honored at the top level; flag (rather than + # silently drop) one buried in a union, e.g. `Annotated[T, Resolve(f)] | None`. + if _contains_resolve(annotation): + raise InvalidSignature( + f"Parameter {name!r} of {_resolver_name(fn)!r} wraps `Resolve(...)` in a " + "union; annotate the parameter directly as `Annotated[T, Resolve(...)]`" + ) + continue + type_arg, *metadata = get_args(annotation) + marker = next((m for m in metadata if isinstance(m, Resolve)), None) + if marker is not None: + resolved[name] = (marker, _wants_union(type_arg)) + return resolved + + +def returns_input_required(fn: Callable[..., Any]) -> bool: + """True when `fn`'s return annotation carries an `InputRequiredResult` arm. + + Used at tool registration to reject combining `Resolve(...)` parameters with a + hand-rolled `InputRequiredResult` flow: a call has a single + `input_responses`/`request_state` channel, so the two flows would overwrite + each other's state and the call could never converge. + """ + return _has_input_required_arm(_type_hints(fn).get("return")) + + +def _has_input_required_arm(annotation: Any) -> bool: + """Walk an annotation's arms through `Annotated`, type aliases, and unions.""" + if get_origin(annotation) is Annotated: + return _has_input_required_arm(get_args(annotation)[0]) + # A `type X = ...` / `TypeAliasType` alias carries its target on `__value__` (a + # subscripted alias forwards the attribute to its origin). The access evaluates + # a PEP 695 alias lazily, so an alias naming things unavailable at runtime + # (TYPE_CHECKING-only imports) raises NameError; such an alias declares no arm + # this check can see, and the in-call guard in `Tool.run` still covers it. + try: + value = getattr(annotation, "__value__", None) + except NameError: + return False + if value is not None: + return _has_input_required_arm(value) + if _is_union(annotation): + return any(_has_input_required_arm(arg) for arg in get_args(annotation)) + return isinstance(annotation, type) and issubclass(annotation, InputRequiredResult) + + +def _contains_resolve(annotation: Any) -> bool: + """True when a `Resolve` marker is nested inside `annotation` (e.g. a union member).""" + if get_origin(annotation) is Annotated: + return any(isinstance(m, Resolve) for m in get_args(annotation)[1:]) + return any(_contains_resolve(arg) for arg in get_args(annotation)) + + +def _check_elicit_return(return_annotation: Any, name: str) -> None: + """Validate the request-marker arms of a resolver's return annotation. + + Raises: + InvalidSignature: If the annotation has more than one marker arm. + """ + candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,) + # Typing dedupes equal union members, so two arms here are genuinely distinct. + arms: list[Any] = [ + c + for c in candidates + # Origin guard for 3.10: `dict[str, Any]` passes `isinstance(c, type)` there and would crash `issubclass`. + if get_origin(c) is Elicit + or (get_origin(c) is None and isinstance(c, type) and issubclass(c, Elicit | Sample | ListRoots)) + ] + if len(arms) > 1: + raise InvalidSignature( + f"Resolver {name!r} return annotation has multiple Elicit/Sample/ListRoots arms; " + "a resolver asks one question - split it into separate resolvers" + ) + + +def _is_union(annotation: Any) -> bool: + return get_origin(annotation) in (typing.Union, types.UnionType) + + +def _wants_union(type_arg: Any) -> bool: + """True when `type_arg` is an `ElicitationResult` member (or a union of them). + + Handles the subscripted `ElicitationResult[T]` alias (a `TypeAliasType` whose + union is on the origin's `__value__`), the bare `ElicitationResult` alias (the + `__value__` is on `type_arg` itself), an explicit `AcceptedElicitation[T] | ...` + union, and a single member. + """ + # Unwrap the `ElicitationResult` alias whether it is bare or subscripted. + value = getattr(type_arg, "__value__", None) or getattr(get_origin(type_arg), "__value__", None) + if value is not None: + type_arg = value + members = get_args(type_arg) if get_origin(type_arg) is not None else (type_arg,) + return any(isinstance(m, type) and issubclass(m, _ELICITATION_RESULT_MEMBERS) for m in members) + + +def _resolver_key(fn: Callable[..., Any]) -> Hashable: + """Identity key for memoizing a resolver. + + A bound method - pure-python (`inspect.ismethod`) or built-in (e.g. `obj.meth` + on a C-extension type) - is recreated on each attribute access, so `id(fn)` + differs every time. Key it by its underlying function (or name) plus its + `__self__` identity so `auth.login` referenced in two places memoizes to one + call. Everything else keys by `id`, so two distinct callables never collide + even if they compare equal. + """ + bound_self = getattr(fn, "__self__", None) + if bound_self is not None: + # `__func__` (pure-python) has a stable identity; built-ins expose only a + # stable `__name__`. Use the function's id or the name's value accordingly. + func = getattr(fn, "__func__", None) + underlying: Hashable = id(func) if func is not None else getattr(fn, "__name__", id(fn)) + return (underlying, id(bound_self)) + return id(fn) + + +def build_resolver_plans( + resolved_params: Mapping[str, tuple[Resolve, bool]], + tool_arg_names: set[str], +) -> dict[Hashable, _ResolverPlan]: + """Statically analyze the resolver DAG rooted at a tool's resolved parameters. + + Raises: + InvalidSignature: If a resolver has a cyclic dependency, or a resolver + parameter cannot be classified (not a `Context`, a nested `Resolve`, + or a tool argument by name). + """ + plans: dict[Hashable, _ResolverPlan] = {} + # Count how many distinct resolvers share each `module:qualname` base so closures + # from one factory get distinct, deterministic wire keys (`base`, `base#1`, ...). + base_counts: dict[str, int] = {} + + def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None: + key = _resolver_key(fn) + if key in stack: + raise InvalidSignature(f"Resolver {_resolver_name(fn)!r} has a cyclic dependency") + if key in plans: + return + + base = _state_key(fn) + seen = base_counts.get(base, 0) + base_counts[base] = seen + 1 + wire_key = base if seen == 0 else f"{base}#{seen}" + + hints = _type_hints(fn) + sig = inspect.signature(fn) + params: dict[str, _ParamPlan] = {} + nested: list[Callable[..., Any]] = [] + for param_name in sig.parameters: + annotation = hints.get(param_name) + if annotation is not None and _is_context_annotation(annotation): + params[param_name] = _ParamPlan("context") + continue + marker, wants_union = _resolve_marker(annotation) + if marker is not None: + params[param_name] = _ParamPlan("resolve", marker, wants_union) + nested.append(marker.fn) + continue + if param_name in tool_arg_names: + params[param_name] = _ParamPlan("by_name") + continue + raise InvalidSignature( + f"Resolver {_resolver_name(fn)!r} parameter {param_name!r} cannot be resolved: " + "expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name" + ) + + _check_elicit_return(hints.get("return"), _resolver_name(fn)) + plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), wire_key) + for dep in nested: + analyze(dep, stack + (key,)) + + for marker, _ in resolved_params.values(): + analyze(marker.fn, ()) + return plans + + +def _resolve_marker(annotation: Any) -> tuple[Resolve | None, bool]: + if get_origin(annotation) is not Annotated: + return None, False + type_arg, *metadata = get_args(annotation) + marker = next((m for m in metadata if isinstance(m, Resolve)), None) + return marker, (_wants_union(type_arg) if marker is not None else False) + + +def _is_context_annotation(annotation: Any) -> bool: + if get_origin(annotation) is Annotated: + annotation = get_args(annotation)[0] + candidates = get_args(annotation) if get_origin(annotation) is not None else (annotation,) + return any(isinstance(c, type) and issubclass(c, Context) for c in candidates) + + +class _Pending(Exception): + """Internal: a resolver needs client input not yet available this round.""" + + +class _Resolution: + """Per-`tools/call` resolution state, shared across the DAG walk. + + `input_required` selects the transport: at >= 2026-07-28 requests are + batched into `pending` and surfaced as an `InputRequiredResult`; at older + revisions each marker is answered synchronously over the back-channel. + """ + + def __init__( + self, + plans: Mapping[Hashable, _ResolverPlan], + tool_args: Mapping[str, Any], + context: Context[Any, Any], + input_required: bool, + ) -> None: + self.plans = plans + self.tool_args = tool_args + self.context = context + self.input_required = input_required + self.answers: InputResponses = context.input_responses or {} if input_required else {} + decoded = _decode_state(context.request_state if input_required else None) + self.state = decoded.outcomes + # Digests of the questions asked last round: an answer is accepted only + # for the exact rendering the client was shown. + self.asked = decoded.asked + # In-call dedup keyed by resolver identity (distinguishes two instances of + # the same bound method); `persist` holds the wire-shaped record of each + # asked outcome, keyed by its wire key - exactly what the next round's `request_state` + # carries: the client's own validated content (elicitation) or the validated result's + # dump (sample/roots). Pure resolvers are cheap to re-run each round and are not persisted. + self.cache: dict[Hashable, ElicitationResult[Any]] = {} + self.persist: dict[str, _StateEntry] = {} + self.pending: InputRequests = {} + + +def _state_key(fn: Callable[..., Any]) -> str: + """Worker-stable base wire key for a resolver, derived only from registration data. + + `input_requests`/`request_state` must round-trip through the client and resume on + any worker (stateless HTTP), so the key carries no `id(...)`: it is the resolver's + `module:qualname` (a callable object uses its type's). Distinct resolvers that + share this base - two instances of one method, two closures from one factory - are + disambiguated deterministically by `build_resolver_plans` (`base`, `base#1`, ...). + """ + qualname = getattr(fn, "__qualname__", None) or type(fn).__qualname__ + module = getattr(fn, "__module__", None) or type(fn).__module__ + return f"{module}:{qualname}" + + +async def resolve_arguments( + resolved_params: Mapping[str, tuple[Resolve, bool]], + plans: Mapping[Hashable, _ResolverPlan], + tool_args: Mapping[str, Any], + context: Context[Any, Any], +) -> dict[str, Any] | InputRequiredResult: + """Resolve every `Resolve`-marked tool parameter into a concrete value. + + Returns the mapping of tool parameter name to injected value when every + resolver is satisfied. When a resolver still needs client input (and the + negotiated protocol is >= 2026-07-28), returns an `InputRequiredResult` + carrying the batched questions instead; the tool body is not run. + + Each question is asked once - its answer is carried in `request_state` across + rounds and satisfies the question when the resolver asks it again. Resolver + bodies themselves may re-run on each round; a recorded answer is consulted + only when the body asks, never in place of running it. + + Raises: + ToolError: If an elicited value is declined or cancelled and the consumer + asked for the unwrapped model (rather than the result union). + """ + # `ctx.protocol_version` is `None` outside an active request: `MCPServer.call_tool()` + # called directly builds such a `Context`, and a tool whose resolvers never elicit + # must still work there. A missing version means the synchronous (non-input_required) + # transport, which never reaches a server-to-client request anyway. + res = _Resolution(plans, tool_args, context, _uses_input_required(context.protocol_version)) + injected: dict[str, Any] = {} + for name, (marker, wants_union) in resolved_params.items(): + try: + outcome = await _resolve(marker.fn, res) + except _Pending: + continue + injected[name] = outcome if wants_union else _unwrap(outcome, name) + + if res.pending: + asked = {key: _request_digest(request) for key, request in res.pending.items()} + return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist, asked)) + return injected + + +async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResult[Any]: + """Resolve one resolver, deduped within the call by its resolver identity. + + Raises `_Pending` when the resolver (or one of its dependencies) needs client + input that has not arrived yet. + """ + cache_key = _resolver_key(fn) + if cache_key in res.cache: + return res.cache[cache_key] + + plan = res.plans[cache_key] + wire_key = plan.wire_key + if wire_key in res.pending: + # Already asked this round by another consumer; don't run the resolver again. + raise _Pending + + kwargs: dict[str, Any] = {} + dep_pending = False + for param_name, param_plan in plan.params.items(): + if param_plan.kind == "context": + kwargs[param_name] = res.context + elif param_plan.kind == "by_name": + kwargs[param_name] = res.tool_args[param_name] + else: + assert param_plan.resolve is not None + try: + # Visit every dependency so independent ones that need input are all + # collected into `res.pending` and batched into a single round. + dep_outcome = await _resolve(param_plan.resolve.fn, res) + except _Pending: + dep_pending = True + continue + kwargs[param_name] = dep_outcome if param_plan.wants_union else _unwrap(dep_outcome, param_name) + if dep_pending: + raise _Pending + + result: Any + if plan.is_async: + result = await fn(**kwargs) + else: + result = await anyio.to_thread.run_sync(lambda: fn(**kwargs)) + + if _is_marker(result): + outcome = await _fulfil(result, wire_key, res) + else: + # A resolver may return any type (not just `BaseModel`), so accept it as the + # outcome without validating against the schema bound. Plain outcomes are not + # persisted in `request_state`; the resolver re-runs next round instead. + outcome = _accepted(result) + + res.cache[cache_key] = outcome + return outcome + + +async def _fulfil(marker: _Marker, key: str, res: _Resolution) -> ElicitationResult[Any]: + """Turn a resolver's request marker into an outcome via the negotiated transport.""" + if not res.input_required: + # Gate wherever the request could actually be sent; otherwise the send path + # itself reports the failure. + if res.context.session.can_send_request: + _require_capability(res.context, marker, key) + if isinstance(marker, Elicit): + return await res.context.elicit(marker.message, marker.schema) + result = await res.context.session.send_request( + _render_request(marker), + _result_type(marker), + metadata=ServerMessageMetadata(related_request_id=res.context.request_id), + ) + return _accepted(result) + + request = _render_request(marker) + q = _request_digest(request) + + # A recorded outcome from a prior round is consulted only here, after the body + # decided to ask, so a `request_state` entry can never stand in for a resolver's + # own computation. A recorded outcome wins over a re-sent answer. + outcome = _restore_outcome(res, key, marker, q) + if outcome is not None: + return outcome + + answer = res.answers.get(key) + # An answer counts only for the rendering recorded when it was asked; an answer to + # an unrecorded or differently-worded question re-asks instead of being consumed. + if answer is not None and res.asked.get(key) != q: + logger.info("Discarding the answer for resolver %r: the question changed since it was asked", key) + answer = None + if answer is None: + _require_capability(res.context, marker, key) + res.pending[key] = request + raise _Pending + if not isinstance(marker, Elicit): + # A no-tool-use answer to a tools request parses as the plain result; validate against the marker's model. + wire = answer.model_dump(mode="json", by_alias=True, exclude_none=True) + try: + result = _result_type(marker).model_validate(wire) + except ValidationError as e: + raise ToolError(f"Resolver {key!r} received a response of the wrong kind") from e + res.persist[key] = _StateEntry(action="accept", data=wire, q=q) + return _accepted(result) + if not isinstance(answer, ElicitResult): + raise ToolError(f"Resolver {key!r} received a non-elicitation response") + if answer.action == "accept": + if answer.content is None: + raise ToolError(f"Resolver {key!r} received an accepted elicitation with no content") + try: + data = marker.schema.model_validate(answer.content) + except ValidationError as e: + raise ToolError( + f"Resolver {key!r} received an accepted elicitation whose content does not match the requested schema" + ) from e + # Persist the exact wire content that just passed validation - never the + # model - so restoring next round revalidates the same bytes the client sent. + res.persist[key] = _StateEntry(action="accept", data=answer.content, q=q) + return AcceptedElicitation(data=data) + if answer.action == "decline": + res.persist[key] = _StateEntry(action="decline", q=q) + return DeclinedElicitation() + res.persist[key] = _StateEntry(action="cancel", q=q) + return CancelledElicitation() + + +def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any: + if isinstance(outcome, AcceptedElicitation): + return outcome.data + raise ToolError(f"Resolver for parameter {name!r} could not resolve: elicitation was {outcome.action}") + + +def _is_marker(value: Any) -> TypeGuard[_Marker]: + return isinstance(value, Elicit | Sample | ListRoots) + + +def _accepted(data: Any) -> AcceptedElicitation[Any]: + """Wrap a resolved value as an accepted outcome without schema validation. + + A resolver may return any type (the schema bound only constrains `Elicit[T]`), + and a value restored from `request_state` is already validated. + """ + return AcceptedElicitation[Any].model_construct(data=data) + + +def _uses_input_required(protocol_version: str | None) -> bool: + """True when this request must elicit via `InputRequiredResult` (>= 2026-07-28). + + Older revisions still carry a standalone `elicitation/create` server-to-client + request, so the framework keeps the synchronous `ctx.elicit()` path for them. + """ + return protocol_version is not None and is_version_at_least(protocol_version, _INPUT_REQUIRED_VERSION) + + +def _require_capability(context: Context[Any, Any], marker: _Marker, key: str) -> None: + """Assert the client declared the capability `marker`'s request needs. + + A bare `elicitation: {}` (the only shape before modes existed) counts as form support; url-only does not. + + Raises: + MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` and a + `requiredCapabilities` payload when the capability is not declared. + """ + capabilities = context.client_capabilities + if isinstance(marker, Elicit): + elicitation = capabilities.elicitation if capabilities is not None else None + if elicitation is not None and (elicitation.form is not None or elicitation.url is None): + return + required = ClientCapabilities(elicitation=ElicitationCapability(form=FormElicitationCapability())) + name = "form elicitation" + elif isinstance(marker, Sample): + sampling = capabilities.sampling if capabilities is not None else None + wants_tools = wants_sampling_tools(marker.params.tools, marker.params.tool_choice) + if sampling is not None and (not wants_tools or sampling.tools is not None): + return + required = ClientCapabilities( + sampling=SamplingCapability(tools=SamplingToolsCapability() if wants_tools else None) + ) + name = "sampling.tools" if wants_tools else "sampling" + else: + if capabilities is not None and capabilities.roots is not None: + return + required = ClientCapabilities(roots=RootsCapability()) + name = "roots" + data = MissingRequiredClientCapabilityErrorData(required_capabilities=required) + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message=f"Client did not declare the {name} capability required by resolver {key!r}", + data=data.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + + +def _render_request(marker: _Marker) -> InputRequest: + """Render a marker as its wire request - the same shape on both transports.""" + if isinstance(marker, Elicit): + json_schema = render_elicitation_schema(marker.schema) + return ElicitRequest(params=ElicitRequestFormParams(message=marker.message, requested_schema=json_schema)) + if isinstance(marker, Sample): + return CreateMessageRequest(params=marker.params) + return ListRootsRequest() + + +def _result_type( + marker: Sample | ListRoots, +) -> type[CreateMessageResult] | type[CreateMessageResultWithTools] | type[ListRootsResult]: + """The result model a `Sample`/`ListRoots` response must validate against.""" + if isinstance(marker, ListRoots): + return ListRootsResult + return ( + CreateMessageResultWithTools + if wants_sampling_tools(marker.params.tools, marker.params.tool_choice) + else CreateMessageResult + ) + + +class _StateEntry(BaseModel): + """One resolver's recorded outcome inside `request_state`.""" + + action: Literal["accept", "decline", "cancel"] + data: Any = None + q: str | None = None + """Digest of the exact rendered question this outcome answered.""" + + +def _request_digest(request: InputRequest) -> str: + """Pin an outcome to the exact rendered question the client was shown. + + A redeploy that rewords or reshapes a question re-asks it instead of reusing the recorded answer. + """ + params = request.params + rendered = compact_json(params.model_dump(mode="json", by_alias=True, exclude_none=True) if params else None) + digest = hashlib.sha256(rendered.encode()).digest()[:16] + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +class _State(BaseModel): + """The decoded `request_state`: resolver progress from earlier rounds.""" + + v: int + outcomes: dict[str, _StateEntry] = {} + asked: dict[str, str] = {} + """Question digest of each elicitation asked last round, keyed by wire key.""" + + +def _decode_state(request_state: str | None) -> _State: + """Decode the per-call resolution progress from `request_state`. + + Parsed with stdlib `json.loads` because `_encode_state` may emit escaped + lone surrogates, which pydantic's JSON parser rejects. The string arrives + boundary-authenticated, so malformed content or a version mismatch is + drift within the operator's own fleet (e.g. a rolling upgrade) and is + treated as "no progress yet". + """ + empty = _State(v=_STATE_VERSION) + if not request_state: + return empty + try: + state = _State.model_validate(json.loads(request_state)) + except ValueError: + return empty + return state if state.v == _STATE_VERSION else empty + + +def _encode_state(outcomes: Mapping[str, _StateEntry], asked: Mapping[str, str]) -> str: + """Encode recorded outcomes and asked-question digests for the next round. + + Outcome entries are already wire-shaped, so encoding is pure wrapping. + """ + state = _State(v=_STATE_VERSION, outcomes=dict(outcomes), asked=dict(asked)) + return compact_json(state.model_dump(mode="json")) + + +def _outcome_from_state(entry: _StateEntry, marker: _Marker) -> ElicitationResult[Any]: + """Rebuild an outcome from a decoded `request_state` entry. + + Raises: + ValidationError: If the entry does not fit the live marker. + """ + if isinstance(marker, Elicit): + if entry.action == "decline": + return DeclinedElicitation() + if entry.action == "cancel": + return CancelledElicitation() + return _accepted(marker.schema.model_validate(entry.data)) + return _accepted(_result_type(marker).model_validate(entry.data)) + + +def _restore_outcome(res: _Resolution, key: str, marker: _Marker, q: str) -> ElicitationResult[Any] | None: + """Restore `key`'s recorded outcome from a prior round, or `None` when absent. + + An entry pinned to a question digest other than `q`, or that fails + validation against the live marker, is dropped as if no progress was + recorded, so the question is asked again. + + Carries the original decoded entry forward unchanged in `res.persist`: if a + later resolver is still pending, the next round's `request_state` is built from + `res.persist`, so an earlier answer must stay there - byte-identical, never + re-derived - or it would be dropped and re-asked. + """ + entry = res.state.get(key) + if entry is None: + return None + if entry.q != q: + del res.state[key] + return None + try: + outcome = _outcome_from_state(entry, marker) + except ValidationError: + del res.state[key] + return None + res.persist[key] = entry + return outcome + + +__all__ = [ + "Resolve", + "Elicit", + "Sample", + "ListRoots", + "ElicitationResult", + "AcceptedElicitation", + "DeclinedElicitation", + "CancelledElicitation", + "find_resolved_parameters", + "build_resolver_plans", + "resolve_arguments", + "returns_input_required", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f54ea44e427164b35dd3487319779b969d762a9b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/__init__.py @@ -0,0 +1,26 @@ +from .base import Resource +from .resource_manager import ResourceManager +from .templates import DEFAULT_RESOURCE_SECURITY, ResourceSecurity, ResourceSecurityError, ResourceTemplate +from .types import ( + BinaryResource, + DirectoryResource, + FileResource, + FunctionResource, + HttpResource, + TextResource, +) + +__all__ = [ + "Resource", + "TextResource", + "BinaryResource", + "FunctionResource", + "FileResource", + "HttpResource", + "DirectoryResource", + "ResourceTemplate", + "ResourceManager", + "ResourceSecurity", + "ResourceSecurityError", + "DEFAULT_RESOURCE_SECURITY", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/base.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/base.py new file mode 100644 index 0000000000000000000000000000000000000000..a1d9cebf3f1037ba3e896696b40bf2b6467e2629 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/base.py @@ -0,0 +1,43 @@ +"""Base classes and interfaces for MCPServer resources.""" + +import abc +from typing import Any + +from mcp_types import Annotations, Icon +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationInfo, + field_validator, +) + + +class Resource(BaseModel, abc.ABC): + """Base class for all resources.""" + + model_config = ConfigDict(validate_default=True, extra="forbid") + + uri: str = Field(default=..., description="URI of the resource") + name: str | None = Field(description="Name of the resource", default=None) + title: str | None = Field(description="Human-readable title of the resource", default=None) + description: str | None = Field(description="Description of the resource", default=None) + mime_type: str = Field(default="text/plain", description="MIME type of the resource content") + icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource") + annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource") + + @field_validator("name", mode="before") + @classmethod + def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: + """Set default name from URI if not provided.""" + if name: + return name + if uri := info.data.get("uri"): + return str(uri) + raise ValueError("Either name or uri must be provided") + + @abc.abstractmethod + async def read(self) -> str | bytes: + """Read the resource content.""" + pass # pragma: no cover diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/resource_manager.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/resource_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..e56e3ba17726440e788b1f9a3d297c44662aeb66 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/resource_manager.py @@ -0,0 +1,137 @@ +"""Resource manager functionality.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from mcp_types import Annotations, Icon, InputRequiredResult +from pydantic import AnyUrl + +from mcp.server.mcpserver.exceptions import ResourceNotFoundError +from mcp.server.mcpserver.resources.base import Resource +from mcp.server.mcpserver.resources.templates import ( + DEFAULT_RESOURCE_SECURITY, + ResourceSecurity, + ResourceSecurityError, + ResourceTemplate, +) +from mcp.server.mcpserver.utilities.logging import get_logger + +if TYPE_CHECKING: + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.context import Context + +logger = get_logger(__name__) + + +class ResourceManager: + """Manages MCPServer resources.""" + + def __init__(self, warn_on_duplicate_resources: bool = True, *, resources: list[Resource] | None = None): + self._resources: dict[str, Resource] = {} + self._templates: dict[str, ResourceTemplate] = {} + self.warn_on_duplicate_resources = warn_on_duplicate_resources + + for resource in resources or (): + self.add_resource(resource) + + def add_resource(self, resource: Resource) -> Resource: + """Add a resource to the manager. + + Args: + resource: A Resource instance to add. + + Returns: + The added resource. If a resource with the same URI already exists, returns the existing resource. + """ + logger.debug( + "Adding resource", + extra={"uri": resource.uri, "type": type(resource).__name__, "resource_name": resource.name}, + ) + existing = self._resources.get(str(resource.uri)) + if existing: + if self.warn_on_duplicate_resources: + logger.warning(f"Resource already exists: {resource.uri}") + return existing + self._resources[str(resource.uri)] = resource + return resource + + def add_template( + self, + fn: Callable[..., Any], + uri_template: str, + name: str | None = None, + title: str | None = None, + description: str | None = None, + mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, + security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, + ) -> ResourceTemplate: + """Add a template from a function.""" + template = ResourceTemplate.from_function( + fn, + uri_template=uri_template, + name=name, + title=title, + description=description, + mime_type=mime_type, + icons=icons, + annotations=annotations, + meta=meta, + security=security, + ) + self._templates[template.uri_template] = template + return template + + async def get_resource( + self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] + ) -> Resource | InputRequiredResult: + """Get resource by URI, checking concrete resources first, then templates. + + A template function may return an `InputRequiredResult` instead of + resource content (the 2026-07-28 multi-round-trip flow); it is passed + through unchanged. + + Raises: + ResourceNotFoundError: If no resource or template matches the URI. + ResourceError: If a matching template fails to create the resource. + + Note: + Pydantic's ``AnyUrl`` normalises percent-encoding and + resolves ``..`` segments during validation, so a value + constructed as ``AnyUrl("file:///a/%2E%2E/b")`` arrives + here as ``file:///b``. The JSON-RPC protocol layer passes + raw ``str`` values and is unaffected, but internal callers + wrapping URIs in ``AnyUrl`` should be aware that security + checks see the already-normalised form. + """ + uri_str = str(uri) + logger.debug("Getting resource", extra={"uri": uri_str}) + + # First check concrete resources + if resource := self._resources.get(uri_str): + return resource + + # Then check templates + for template in self._templates.values(): + try: + params = template.matches(uri_str) + except ResourceSecurityError as e: + raise ResourceNotFoundError(f"Unknown resource: {uri}") from e + if params is not None: + return await template.create_resource(uri_str, params, context=context) + + raise ResourceNotFoundError(f"Unknown resource: {uri}") + + def list_resources(self) -> list[Resource]: + """List all registered resources.""" + logger.debug("Listing resources", extra={"count": len(self._resources)}) + return list(self._resources.values()) + + def list_templates(self) -> list[ResourceTemplate]: + """List all registered templates.""" + logger.debug("Listing templates", extra={"count": len(self._templates)}) + return list(self._templates.values()) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/templates.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..096e821d81fd38e9452f4d08caf27f219a47ecbf --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/templates.py @@ -0,0 +1,251 @@ +"""Resource template functionality.""" + +from __future__ import annotations + +import functools +from collections.abc import Callable, Mapping, Set +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import anyio.to_thread +from mcp_types import Annotations, Icon, InputRequiredResult +from pydantic import BaseModel, Field, validate_call + +from mcp.server.mcpserver.exceptions import ResourceError +from mcp.server.mcpserver.resources.types import FunctionResource, Resource +from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context +from mcp.server.mcpserver.utilities.func_metadata import func_metadata +from mcp.server.mcpserver.utilities.logging import get_logger +from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError +from mcp.shared.path_security import contains_path_traversal, is_absolute_path +from mcp.shared.uri_template import UriTemplate + +logger = get_logger(__name__) + +if TYPE_CHECKING: + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.context import Context + + +@dataclass(frozen=True) +class ResourceSecurity: + """Security policy applied to extracted resource template parameters. + + These checks run after :meth:`~mcp.shared.uri_template.UriTemplate.match` + has extracted and decoded parameter values. They catch path-traversal + and absolute-path injection regardless of how the value was encoded in + the URI (literal, ``%2F``, ``%5C``, ``%2E%2E``). + + Example:: + + # Opt out for a parameter that legitimately contains .. + @mcp.resource( + "git://diff/{+range}", + security=ResourceSecurity(exempt_params={"range"}), + ) + def git_diff(range: str) -> str: ... + """ + + reject_path_traversal: bool = True + """Reject values containing ``..`` as a path component.""" + + reject_absolute_paths: bool = True + """Reject values that look like absolute filesystem paths.""" + + reject_null_bytes: bool = True + """Reject values containing NUL (``\\x00``). Null bytes defeat string + comparisons (``"..\\x00" != ".."``) and can cause truncation in C + extensions or subprocess calls.""" + + exempt_params: Set[str] = field(default_factory=frozenset[str]) + """Parameter names to skip all checks for.""" + + def validate(self, params: Mapping[str, str | list[str]]) -> str | None: + """Check all parameter values against the configured policy. + + Args: + params: Extracted template parameters. List values (from + explode variables) are checked element-wise. + + Returns: + The name of the first parameter that fails, or ``None`` if + all values pass. + """ + for name, value in params.items(): + if name in self.exempt_params: + continue + values = value if isinstance(value, list) else [value] + for v in values: + if self.reject_null_bytes and "\0" in v: + return name + if self.reject_path_traversal and contains_path_traversal(v): + return name + if self.reject_absolute_paths and is_absolute_path(v): + return name + return None + + +DEFAULT_RESOURCE_SECURITY = ResourceSecurity() +"""Secure-by-default policy: traversal, absolute paths, and null bytes rejected.""" + + +class ResourceSecurityError(ValueError): + """Raised when an extracted parameter fails :class:`ResourceSecurity` checks. + + Distinct from a simple ``None`` non-match so that template + iteration can stop at the first security rejection rather than + falling through to a later, possibly more permissive, template. + """ + + def __init__(self, template: str, param: str) -> None: + super().__init__(f"Parameter {param!r} of template {template!r} failed security validation") + self.template = template + self.param = param + + +class ResourceTemplate(BaseModel): + """A template for dynamically creating resources.""" + + uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)") + name: str = Field(description="Name of the resource") + title: str | None = Field(description="Human-readable title of the resource", default=None) + description: str | None = Field(description="Description of what the resource does") + mime_type: str = Field(default="text/plain", description="MIME type of the resource content") + icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template") + annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template") + fn: Callable[..., Any] = Field(exclude=True) + parameters: dict[str, Any] = Field(description="JSON schema for function parameters") + context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") + parsed_template: UriTemplate = Field(exclude=True, description="Parsed RFC 6570 template") + security: ResourceSecurity = Field(exclude=True, description="Path-safety policy for extracted parameters") + + @classmethod + def from_function( + cls, + fn: Callable[..., Any], + uri_template: str, + name: str | None = None, + title: str | None = None, + description: str | None = None, + mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, + context_kwarg: str | None = None, + security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, + ) -> ResourceTemplate: + """Create a template from a function. + + Raises: + InvalidUriTemplate: If ``uri_template`` is malformed or uses + unsupported RFC 6570 features. + """ + func_name = name or fn.__name__ + if func_name == "": + raise ValueError("You must provide a name for lambda functions") # pragma: no cover + + parsed = UriTemplate.parse(uri_template) + + # Find context parameter if it exists + if context_kwarg is None: # pragma: no branch + context_kwarg = find_context_parameter(fn) + + # Get schema from func_metadata, excluding context parameter + func_arg_metadata = func_metadata( + fn, + skip_names=[context_kwarg] if context_kwarg is not None else [], + ) + parameters = func_arg_metadata.arg_model.model_json_schema() + + # ensure the arguments are properly cast + fn = validate_call(fn) + + return cls( + uri_template=uri_template, + name=func_name, + title=title, + description=description or fn.__doc__ or "", + mime_type=mime_type or "text/plain", + icons=icons, + annotations=annotations, + meta=meta, + fn=fn, + parameters=parameters, + context_kwarg=context_kwarg, + parsed_template=parsed, + security=security, + ) + + def matches(self, uri: str) -> dict[str, str | list[str]] | None: + """Check if a URI matches this template and extract parameters. + + Delegates to :meth:`UriTemplate.match` for RFC 6570 extraction, + then applies this template's :class:`ResourceSecurity` policy + (path traversal, absolute paths). + + Returns: + Extracted parameters on success, or ``None`` if the URI + doesn't match the template. + + Raises: + ResourceSecurityError: If the URI matches but an extracted + parameter fails security validation. Raising (rather + than returning ``None``) prevents the resource manager + from silently falling through to a later, possibly more + permissive, template. + """ + params = self.parsed_template.match(uri) + if params is None: + return None + failed = self.security.validate(params) + if failed is not None: + raise ResourceSecurityError(self.uri_template, failed) + return params + + async def create_resource( + self, + uri: str, + params: dict[str, Any], + context: Context[LifespanContextT, RequestT], + ) -> Resource | InputRequiredResult: + """Create a resource from the template with the given parameters. + + An `InputRequiredResult` returned by the template function is passed + through unchanged (the 2026-07-28 multi-round-trip flow); the retry's + answers arrive on `ctx.input_responses`, with `ctx.request_state` + carrying the echoed opaque state. + + Raises: + ResourceError: If creating the resource fails. + """ + try: + # Add context to params if needed + params = inject_context(self.fn, params, context, self.context_kwarg) + + fn = self.fn + if is_async_callable(fn): + result = await fn(**params) + else: + result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params)) + + if isinstance(result, InputRequiredResult): + return result + + return FunctionResource( + uri=uri, # type: ignore + name=self.name, + title=self.title, + description=self.description, + mime_type=self.mime_type, + icons=self.icons, + annotations=self.annotations, + meta=self.meta, + fn=lambda: result, # Capture result in closure + ) + except (ResourceError, MCPError): + raise + except Exception as exc: + logger.exception(f"Error creating resource from template {uri}") + raise ResourceError(f"Error creating resource from template {uri}") from exc diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/types.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/types.py new file mode 100644 index 0000000000000000000000000000000000000000..2edf34233745567138c47cdcec78a5b6fe9c117b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/resources/types.py @@ -0,0 +1,245 @@ +"""Concrete resource implementations.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from functools import partial +from pathlib import Path +from typing import Any + +import anyio +import anyio.to_thread +import httpx2 +import pydantic +import pydantic_core +from mcp_types import Annotations, Icon, InputRequiredResult +from pydantic import Field, validate_call + +from mcp.server.mcpserver.resources.base import Resource +from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError + +# `application/*` types that are textual but predate the `+json`/`+xml` +# structured-syntax suffixes, so the suffix rule below can't catch them. +_TEXTUAL_APPLICATION_TYPES = frozenset({"application/json", "application/xml"}) + + +def _default_file_encoding(mime_type: str) -> str | None: + """The encoding a file of this mime type is decoded with by default. + + A declared `charset=` parameter wins. Otherwise textual types (`text/*`, JSON, + XML) are `utf-8-sig` โ€” UTF-8 that also tolerates a byte-order mark โ€” and + everything else is bytes (None). + """ + essence, *params = (part.strip() for part in mime_type.split(";")) + for param in params: + name, _, value = param.partition("=") + if name.strip().lower() == "charset" and value: + return value.strip().strip('"') + essence = essence.lower() + if essence.startswith("text/") or essence.endswith(("+json", "+xml")) or essence in _TEXTUAL_APPLICATION_TYPES: + return "utf-8-sig" + return None + + +class TextResource(Resource): + """A resource that reads from a string.""" + + text: str = Field(description="Text content of the resource") + + async def read(self) -> str: + """Read the text content.""" + return self.text + + +class BinaryResource(Resource): + """A resource that reads from bytes.""" + + data: bytes = Field(description="Binary content of the resource") + + async def read(self) -> bytes: + """Read the binary content.""" + return self.data # pragma: no cover + + +class FunctionResource(Resource): + """A resource that defers data loading by wrapping a function. + + The function is only called when the resource is read, allowing for lazy loading + of potentially expensive data. This is particularly useful when listing resources, + as the function won't be called until the resource is actually accessed. + + The function can return: + - str for text content (default) + - bytes for binary content + - other types will be converted to JSON + """ + + fn: Callable[[], Any] = Field(exclude=True) + + async def read(self) -> str | bytes: + """Read the resource by calling the wrapped function.""" + try: + fn = self.fn + if is_async_callable(fn): + result = await fn() + else: + result = await anyio.to_thread.run_sync(self.fn) + + if isinstance(result, InputRequiredResult): + # A static resource function can never read the retry's + # input_responses (it takes no Context), so this can only be a + # mistake โ€” reject it instead of JSON-dumping it as content. + raise ValueError( + "static resources cannot return InputRequiredResult; only resource " + "template functions participate in the multi-round-trip flow" + ) + if isinstance(result, Resource): # pragma: no cover + return await result.read() + elif isinstance(result, bytes): + return result + elif isinstance(result, str): + return result + else: + return pydantic_core.to_json(result, fallback=str, indent=2).decode() + except MCPError: + raise + except Exception as e: + raise ValueError(f"Error reading resource {self.uri}: {e}") + + @classmethod + def from_function( + cls, + fn: Callable[..., Any], + uri: str, + name: str | None = None, + title: str | None = None, + description: str | None = None, + mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, + ) -> FunctionResource: + """Create a FunctionResource from a function.""" + func_name = name or fn.__name__ + if func_name == "": # pragma: no cover + raise ValueError("You must provide a name for lambda functions") + + # ensure the arguments are properly cast + fn = validate_call(fn) + + return cls( + uri=uri, + name=func_name, + title=title, + description=description or fn.__doc__ or "", + mime_type=mime_type or "text/plain", + fn=fn, + icons=icons, + annotations=annotations, + meta=meta, + ) + + +class FileResource(Resource): + """A resource that reads from a file. + + The file is decoded with `encoding` and served as text, or read as bytes and + served as a base64 blob when `encoding` is None. When `encoding` is omitted it + defaults to the `charset` declared in `mime_type`, else `"utf-8-sig"` for + textual mime types (`text/*`, JSON, XML) and None for everything else; pass + it explicitly to override either way. + """ + + path: Path = Field(description="Path to the file") + encoding: str | None = Field( + default_factory=lambda data: _default_file_encoding(data["mime_type"]), + description="Text encoding used to decode the file, or None to serve its bytes as a blob", + ) + + @pydantic.field_validator("path") + @classmethod + def validate_absolute_path(cls, path: Path) -> Path: + """Ensure path is absolute.""" + if not path.is_absolute(): + raise ValueError("Path must be absolute") + return path + + @pydantic.field_validator("encoding") + @classmethod + def validate_text_encoding(cls, encoding: str | None) -> str | None: + """Ensure the encoding names a usable text codec, so a mistake fails at construction not at read.""" + if encoding is not None: + # Decoding a probe byte rejects both unknown names and codecs that + # aren't text encodings (base64_codec, rot13, ...) via LookupError. + try: + b"x".decode(encoding) + except LookupError as e: + raise ValueError(str(e)) from e + except UnicodeError: + pass # a real text encoding; the probe byte just doesn't decode in it + return encoding + + async def read(self) -> str | bytes: + """Read the file content.""" + try: + if self.encoding is None: + return await anyio.to_thread.run_sync(self.path.read_bytes) + return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding)) + except Exception as e: + raise ValueError(f"Error reading file {self.path}: {e}") + + +class HttpResource(Resource): + """A resource that reads from an HTTP endpoint.""" + + url: str = Field(description="URL to fetch content from") + mime_type: str = Field(default="application/json", description="MIME type of the resource content") + + async def read(self) -> str | bytes: + """Read the HTTP content.""" + async with httpx2.AsyncClient() as client: # pragma: no cover + response = await client.get(self.url) + response.raise_for_status() + return response.text + + +class DirectoryResource(Resource): + """A resource that lists files in a directory.""" + + path: Path = Field(description="Path to the directory") + recursive: bool = Field(default=False, description="Whether to list files recursively") + pattern: str | None = Field(default=None, description="Optional glob pattern to filter files") + mime_type: str = Field(default="application/json", description="MIME type of the resource content") + + @pydantic.field_validator("path") + @classmethod + def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover + """Ensure path is absolute.""" + if not path.is_absolute(): + raise ValueError("Path must be absolute") + return path + + def list_files(self) -> list[Path]: # pragma: no cover + """List files in the directory.""" + if not self.path.exists(): + raise FileNotFoundError(f"Directory not found: {self.path}") + if not self.path.is_dir(): + raise NotADirectoryError(f"Not a directory: {self.path}") + + try: + if self.pattern: + return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern)) + return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*")) + except Exception as e: + raise ValueError(f"Error listing directory {self.path}: {e}") + + async def read(self) -> str: # Always returns JSON string # pragma: no cover + """Read the directory listing.""" + try: + files = await anyio.to_thread.run_sync(self.list_files) + file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()] + return json.dumps({"files": file_list}, indent=2) + except Exception as e: + raise ValueError(f"Error reading directory {self.path}: {e}") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/server.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/server.py new file mode 100644 index 0000000000000000000000000000000000000000..7bf1993e297366408665a64b85d4277f45530171 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/server.py @@ -0,0 +1,1344 @@ +"""MCPServer - A more ergonomic interface for MCP servers.""" + +from __future__ import annotations + +import base64 +import inspect +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import Any, Generic, Literal, TypeVar, overload + +import anyio +import pydantic_core +from mcp_types import ( + INTERNAL_ERROR, + INVALID_PARAMS, + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, + Annotations, + BlobResourceContents, + CallToolRequestParams, + CallToolResult, + ClientCapabilities, + CompleteRequestParams, + CompleteResult, + Completion, + GetPromptRequestParams, + GetPromptResult, + Icon, + InputRequiredResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + MissingRequiredClientCapabilityErrorData, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, + TextContent, + TextResourceContents, + ToolAnnotations, +) +from mcp_types import Prompt as MCPPrompt +from mcp_types import PromptArgument as MCPPromptArgument +from mcp_types import Resource as MCPResource +from mcp_types import ResourceTemplate as MCPResourceTemplate +from mcp_types import Tool as MCPTool +from pydantic import BaseModel +from pydantic.networks import AnyUrl +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Mount, Route +from starlette.types import Receive, Scope, Send + +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.caching import CacheableMethod, CacheHint +from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.server.extension import ( + Extension, + MethodBinding, + RequestHandler, + compose_tool_call_handler, + validate_extension_identifier, +) +from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.server.lowlevel.server import LifespanResultT, Server +from mcp.server.lowlevel.server import lifespan as default_lifespan +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError +from mcp.server.mcpserver.prompts import Prompt, PromptManager +from mcp.server.mcpserver.resources import ( + DEFAULT_RESOURCE_SECURITY, + FunctionResource, + Resource, + ResourceManager, + ResourceSecurity, +) +from mcp.server.mcpserver.tools import Tool, ToolManager +from mcp.server.mcpserver.utilities.context_injection import find_context_parameter +from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger +from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity +from mcp.server.sse import SseServerTransport +from mcp.server.stdio import stdio_server +from mcp.server.streamable_http import EventStore +from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager +from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.exceptions import MCPError +from mcp.shared.uri_template import UriTemplate + +logger = get_logger(__name__) + +_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) + + +class Settings(BaseModel, Generic[LifespanResultT]): + """MCPServer settings, as passed to the `MCPServer` constructor.""" + + # Server settings + debug: bool + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + # resource settings + warn_on_duplicate_resources: bool + + # tool settings + warn_on_duplicate_tools: bool + + # prompt settings + warn_on_duplicate_prompts: bool + + dependencies: list[str] + """List of dependencies to install in the server environment. Used by the `mcp install` and `mcp dev` CLI.""" + + lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None + """An async context manager that will be called when the server is started.""" + + auth: AuthSettings | None + + +_MISSING_AUDIENCE = ( + "request_state_security is configured but this server has no name. Sealed\n" + "requestState carries the server name as an audience claim, so state minted by\n" + "another service that shares the same keys is rejected; unnamed servers would\n" + "all stamp the same placeholder and the check would mean nothing. Name the\n" + 'server (MCPServer("my-service", ...)) or set RequestStateSecurity(audience=...).' +) + + +def lifespan_wrapper( + app: MCPServer[LifespanResultT], + lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]], +) -> Callable[[Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]]: + @asynccontextmanager + async def wrap(_: Server[LifespanResultT]) -> AsyncIterator[LifespanResultT]: + async with lifespan(app) as context: + yield context + + return wrap + + +class MCPServer(Generic[LifespanResultT]): + def __init__( + self, + name: str | None = None, + title: str | None = None, + description: str | None = None, + instructions: str | None = None, + website_url: str | None = None, + icons: list[Icon] | None = None, + version: str = "", + auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, + token_verifier: TokenVerifier | None = None, + *, + tools: list[Tool] | None = None, + resources: list[Resource] | None = None, + extensions: Sequence[Extension] | None = None, + debug: bool = False, + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", + warn_on_duplicate_resources: bool = True, + warn_on_duplicate_tools: bool = True, + warn_on_duplicate_prompts: bool = True, + dependencies: list[str] | None = None, + lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, + auth: AuthSettings | None = None, + resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, + request_state_security: RequestStateSecurity | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + subscriptions: SubscriptionBus | None = None, + middleware: Sequence[ServerMiddleware[Any]] | None = None, + ): + self._resource_security = resource_security + self.settings = Settings( + debug=debug, + log_level=log_level, + warn_on_duplicate_resources=warn_on_duplicate_resources, + warn_on_duplicate_tools=warn_on_duplicate_tools, + warn_on_duplicate_prompts=warn_on_duplicate_prompts, + dependencies=dependencies or [], + lifespan=lifespan, + auth=auth, + ) + self.dependencies = self.settings.dependencies + + self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools) + self._resource_manager = ResourceManager( + resources=resources, warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources + ) + self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts) + # The subscriptions/listen fan-out seam (2026-07-28). The default bus is + # in-process; pass an `SubscriptionBus` implementation over an external pub/sub + # backend to fan events out across replicas. + self._subscriptions: SubscriptionBus = subscriptions if subscriptions is not None else InMemorySubscriptionBus() + self._lowlevel_server = Server( + name=name or "mcp-server", + title=title, + description=description, + instructions=instructions, + website_url=website_url, + icons=icons, + version=version, + cache_hints=cache_hints, + on_list_tools=self._handle_list_tools, + on_call_tool=self._handle_call_tool, + on_list_resources=self._handle_list_resources, + on_read_resource=self._handle_read_resource, + on_list_resource_templates=self._handle_list_resource_templates, + on_list_prompts=self._handle_list_prompts, + on_get_prompt=self._handle_get_prompt, + on_subscriptions_listen=ListenHandler(self._subscriptions), + # TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an MCPServer and Server. + # We need to create a Lifespan type that is a generic on the server type, like Starlette does. + lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore + ) + # Ordering: inside OpenTelemetry (spans record the sealed wire form). + # Extension interceptors run at the handler layer, inside this + # boundary, so they see plaintext. + if request_state_security is None: + security = RequestStateSecurity.ephemeral() + else: + # A supplied policy usually means shared keys, where the audience claim is + # what separates services; an unnamed server would stamp the placeholder. + if not name and request_state_security.audience is None: + raise ValueError(_MISSING_AUDIENCE) + security = request_state_security + self._lowlevel_server.middleware.append(RequestStateBoundary(security, default_audience=self.name)) + # User middleware runs inside the SDK's built-ins (OpenTelemetry, then the + # request-state boundary), outermost-first in the order given. + self._lowlevel_server.middleware.extend(middleware or ()) + # Validate auth configuration + if self.settings.auth is not None: + if auth_server_provider and token_verifier: # pragma: no cover + raise ValueError("Cannot specify both auth_server_provider and token_verifier") + if not auth_server_provider and not token_verifier: # pragma: no cover + raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled") + elif auth_server_provider or token_verifier: + raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings") + + self._auth_server_provider = auth_server_provider + self._token_verifier = token_verifier + + # Create token verifier from provider if needed (backwards compatibility) + if auth_server_provider and not token_verifier: + self._token_verifier = ProviderTokenVerifier(auth_server_provider) + self._custom_starlette_routes: list[Route] = [] + + # Configure logging + configure_logging(self.settings.log_level) + + self._extensions: list[Extension] = [] + for extension in extensions or (): + self._apply_extension(extension) + self._install_extension_interceptor() + + @property + def name(self) -> str: + return self._lowlevel_server.name + + @property + def middleware(self) -> list[ServerMiddleware[Any]]: + """The middleware chain wrapping every inbound message, outermost-first. + + The same list as the low-level `Server.middleware`: append an + `async (ctx, call_next)` callable to observe, refuse, or rewrite + messages before they reach a handler. Provisional - the signature is + expected to change before v2 is final; see the middleware guide. + """ + return self._lowlevel_server.middleware + + @property + def title(self) -> str | None: + return self._lowlevel_server.title + + @property + def description(self) -> str | None: + return self._lowlevel_server.description + + @property + def instructions(self) -> str | None: + return self._lowlevel_server.instructions + + @property + def website_url(self) -> str | None: + return self._lowlevel_server.website_url + + @property + def icons(self) -> list[Icon] | None: + return self._lowlevel_server.icons + + @property + def version(self) -> str: + return self._lowlevel_server.version + + @property + def session_manager(self) -> StreamableHTTPSessionManager: + """Get the StreamableHTTP session manager. + + This is exposed to enable advanced use cases like mounting multiple + MCPServer instances in a single FastAPI application. + + Raises: + RuntimeError: If called before streamable_http_app() has been called. + """ + return self._lowlevel_server.session_manager + + def _apply_extension(self, extension: Extension) -> None: + """Apply one opt-in extension's contributions through the public surface. + + Registers its tools/resources/methods and advertises its settings under + `ServerCapabilities.extensions[extension.identifier]`. Extensions are fixed + at construction, so this is private; the `tools/call` interceptor is + composed once afterwards by `_install_extension_interceptor`. + """ + identifier = getattr(extension, "identifier", None) + validate_extension_identifier(identifier, owner=type(extension).__name__) + if any(e.identifier == identifier for e in self._extensions): + raise ValueError(f"Extension {identifier!r} is already registered") + self._extensions.append(extension) + + for tool in extension.tools(): + self.add_tool(tool.fn, meta=tool.meta, **tool.kwargs) + for resource in extension.resources(): + self.add_resource(resource.resource) + for method in extension.methods(): + if self._lowlevel_server.get_request_handler(method.method) is not None: + raise ValueError( + f"Extension {identifier!r} binds method {method.method!r}, which is already " + "registered; extension methods are additive and cannot replace another handler" + ) + handler = _version_gated(method) if method.protocol_versions is not None else method.handler + self._lowlevel_server.add_request_handler(method.method, method.params_type, handler) + + self._lowlevel_server.extensions[extension.identifier] = extension.settings() + + def _install_extension_interceptor(self) -> None: + """Wrap the `tools/call` handler with every extension's interceptor. + + Installed only when at least one extension overrides `intercept_tool_call`, + so a server with purely additive extensions keeps the bare handler. The + chain wraps the handler itself, below the runner's outbound envelope + pass, so a short-circuiting interceptor's result is sieved and stamped + exactly like a handler result. + """ + if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions): + self._lowlevel_server.add_request_handler( + "tools/call", + CallToolRequestParams, + compose_tool_call_handler(self._extensions, self._handle_call_tool), + ) + + @overload + def run(self, transport: Literal["stdio"] = ...) -> None: ... + + @overload + def run( + self, + transport: Literal["sse"], + *, + host: str = ..., + port: int = ..., + sse_path: str = ..., + message_path: str = ..., + transport_security: TransportSecuritySettings | None = ..., + ) -> None: ... + + @overload + def run( + self, + transport: Literal["streamable-http"], + *, + host: str = ..., + port: int = ..., + streamable_http_path: str = ..., + json_response: bool = ..., + stateless_http: bool = ..., + event_store: EventStore | None = ..., + retry_interval: int | None = ..., + max_request_body_size: int = ..., + transport_security: TransportSecuritySettings | None = ..., + ) -> None: ... + + def run( + self, + transport: Literal["stdio", "sse", "streamable-http"] = "stdio", + **kwargs: Any, + ) -> None: + """Run the MCP server. Note this is a synchronous function. + + Args: + transport: Transport protocol to use ("stdio", "sse", or "streamable-http") + **kwargs: Transport-specific options (see overloads for details) + """ + TRANSPORTS = Literal["stdio", "sse", "streamable-http"] + if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover + raise ValueError(f"Unknown transport: {transport}") + + match transport: + case "stdio": + anyio.run(self.run_stdio_async) + case "sse": # pragma: no cover + anyio.run(lambda: self.run_sse_async(**kwargs)) + case "streamable-http": # pragma: no cover + anyio.run(lambda: self.run_streamable_http_async(**kwargs)) + + async def _handle_list_tools( + self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListToolsResult: + return ListToolsResult(tools=await self.list_tools()) + + async def _handle_call_tool( + self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions) + try: + return await self.call_tool(params.name, params.arguments or {}, context) + except MCPError: + raise + except Exception as e: + return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True) + + async def _handle_list_resources( + self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListResourcesResult: + return ListResourcesResult(resources=await self.list_resources()) + + async def _handle_read_resource( + self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams + ) -> ReadResourceResult | InputRequiredResult: + context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions) + try: + results = await self.read_resource(params.uri, context) + except ResourceNotFoundError as err: + raise MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)}) + except ResourceError as err: + raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)}) + if isinstance(results, InputRequiredResult): + return results + contents: list[TextResourceContents | BlobResourceContents] = [] + for item in results: + if isinstance(item.content, bytes): + contents.append( + BlobResourceContents( + uri=params.uri, + blob=base64.b64encode(item.content).decode(), + mime_type=item.mime_type or "application/octet-stream", + _meta=item.meta, + ) + ) + else: + contents.append( + TextResourceContents( + uri=params.uri, + text=item.content, + mime_type=item.mime_type or "text/plain", + _meta=item.meta, + ) + ) + return ReadResourceResult(contents=contents) + + async def _handle_list_resource_templates( + self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListResourceTemplatesResult: + return ListResourceTemplatesResult(resource_templates=await self.list_resource_templates()) + + async def _handle_list_prompts( + self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListPromptsResult: + return ListPromptsResult(prompts=await self.list_prompts()) + + async def _handle_get_prompt( + self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams + ) -> GetPromptResult | InputRequiredResult: + context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions) + return await self.get_prompt(params.name, params.arguments, context) + + async def list_tools(self) -> list[MCPTool]: + """List all available tools.""" + tools = self._tool_manager.list_tools() + return [ + MCPTool( + name=info.name, + title=info.title, + description=info.description, + input_schema=info.parameters, + output_schema=info.output_schema, + annotations=info.annotations, + icons=info.icons, + _meta=info.meta, + ) + for info in tools + ] + + async def call_tool( + self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None + ) -> CallToolResult | InputRequiredResult: + """Call a tool by name with arguments.""" + if context is None: + context = Context(mcp_server=self, subscriptions=self._subscriptions) + return await self._tool_manager.call_tool(name, arguments, context, convert_result=True) + + async def list_resources(self) -> list[MCPResource]: + """List all available resources.""" + + resources = self._resource_manager.list_resources() + return [ + MCPResource( + uri=resource.uri, + name=resource.name or "", + title=resource.title, + description=resource.description, + mime_type=resource.mime_type, + icons=resource.icons, + annotations=resource.annotations, + _meta=resource.meta, + ) + for resource in resources + ] + + async def list_resource_templates(self) -> list[MCPResourceTemplate]: + templates = self._resource_manager.list_templates() + return [ + MCPResourceTemplate( + uri_template=template.uri_template, + name=template.name, + title=template.title, + description=template.description, + mime_type=template.mime_type, + icons=template.icons, + annotations=template.annotations, + _meta=template.meta, + ) + for template in templates + ] + + async def read_resource( + self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None + ) -> Iterable[ReadResourceContents] | InputRequiredResult: + """Read a resource by URI. + + An `InputRequiredResult` returned by a resource template function is + passed through unchanged (the 2026-07-28 multi-round-trip flow); the + retry's answers arrive on `ctx.input_responses`, with + `ctx.request_state` carrying the echoed opaque state. + + Raises: + ResourceNotFoundError: If no resource or template matches the URI. + ResourceError: If template creation or resource reading fails. + """ + if context is None: + context = Context(mcp_server=self, subscriptions=self._subscriptions) + resource = await self._resource_manager.get_resource(uri, context) + if isinstance(resource, InputRequiredResult): + return resource + + try: + content = await resource.read() + return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] + except MCPError: + raise + except Exception as exc: + logger.exception(f"Error getting resource {uri}") + # If an exception happens when reading the resource, we should not leak the exception to the client. + raise ResourceError(f"Error reading resource {uri}") from exc + + def add_tool( + self, + fn: Callable[..., Any], + name: str | None = None, + title: str | None = None, + description: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, + structured_output: bool | None = None, + ) -> None: + """Add a tool to the server. + + The tool function can optionally request a Context object by adding a parameter + with the Context type annotation. See the @tool decorator for examples. + + Args: + fn: The function to register as a tool + name: Optional name for the tool (defaults to function name) + title: Optional human-readable title for the tool + description: Optional description of what the tool does + annotations: Optional ToolAnnotations providing additional tool information + icons: Optional list of icons for the tool + meta: Optional metadata dictionary for the tool + structured_output: Controls whether the tool's output is structured or unstructured + - If None, auto-detects based on the function's return type annotation + - If True, creates a structured tool (return type annotation permitting) + - If False, unconditionally creates an unstructured tool + """ + self._tool_manager.add_tool( + fn, + name=name, + title=title, + description=description, + annotations=annotations, + icons=icons, + meta=meta, + structured_output=structured_output, + ) + + def remove_tool(self, name: str) -> None: + """Remove a tool from the server by name. + + Args: + name: The name of the tool to remove + + Raises: + ToolError: If the tool does not exist + """ + self._tool_manager.remove_tool(name) + + def tool( + self, + name: str | None = None, + title: str | None = None, + description: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, + structured_output: bool | None = None, + ) -> Callable[[_CallableT], _CallableT]: + """Decorator to register a tool. + + Tools can optionally request a Context object by adding a parameter with the + Context type annotation. The context provides access to MCP capabilities like + logging, progress reporting, and resource access. + + Args: + name: Optional name for the tool (defaults to function name) + title: Optional human-readable title for the tool + description: Optional description of what the tool does + annotations: Optional ToolAnnotations providing additional tool information + icons: Optional list of icons for the tool + meta: Optional metadata dictionary for the tool + structured_output: Controls whether the tool's output is structured or unstructured + - If None, auto-detects based on the function's return type annotation + - If True, creates a structured tool (return type annotation permitting) + - If False, unconditionally creates an unstructured tool + + Example: + ```python + @server.tool() + def my_tool(x: int) -> str: + return str(x) + ``` + + ```python + @server.tool() + async def tool_with_context(x: int, ctx: Context) -> str: + await ctx.info(f"Processing {x}") + return str(x) + ``` + + ```python + @server.tool() + async def async_tool(x: int, context: Context) -> str: + await context.report_progress(50, 100) + return str(x) + ``` + """ + # Check if user passed function directly instead of calling decorator + if callable(name): + raise TypeError( + "The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool" + ) + + def decorator(fn: _CallableT) -> _CallableT: + self.add_tool( + fn, + name=name, + title=title, + description=description, + annotations=annotations, + icons=icons, + meta=meta, + structured_output=structured_output, + ) + return fn + + return decorator + + def completion(self): + """Decorator to register a completion handler. + + The completion handler receives: + - ref: PromptReference or ResourceTemplateReference + - argument: CompletionArgument with name and partial value + - context: Optional CompletionContext with previously resolved arguments + + Example: + ```python + @mcp.completion() + async def handle_completion(ref, argument, context): + if isinstance(ref, ResourceTemplateReference): + # Return completions based on ref, argument, and context + return Completion(values=["option1", "option2"]) + return None + ``` + """ + + def decorator(func: _CallableT) -> _CallableT: + async def handler( + ctx: ServerRequestContext[LifespanResultT], params: CompleteRequestParams + ) -> CompleteResult: + result = await func(params.ref, params.argument, params.context) + return CompleteResult( + completion=result if result is not None else Completion(values=[], total=None, has_more=None), + ) + + self._lowlevel_server.add_request_handler("completion/complete", CompleteRequestParams, handler) + return func + + return decorator + + def add_resource(self, resource: Resource) -> None: + """Add a resource to the server. + + Args: + resource: A Resource instance to add + """ + self._resource_manager.add_resource(resource) + + def resource( + self, + uri: str, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, + security: ResourceSecurity | None = None, + ) -> Callable[[_CallableT], _CallableT]: + """Decorator to register a function as a resource. + + The function will be called when the resource is read to generate its content. + The function can return: + - str for text content + - bytes for binary content + - an InputRequiredResult (template resources only; passed through + unchanged for the 2026-07-28 multi-round-trip flow โ€” read + `ctx.input_responses` on the retry) + - other types will be converted to JSON + + If the URI contains parameters (e.g. "resource://{param}"), it is + registered as a template resource. Otherwise it is registered as a + static resource; function parameters on a static URI raise an error. + + Args: + uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") + name: Optional name for the resource + title: Optional human-readable title for the resource + description: Optional description of the resource + mime_type: Optional MIME type for the resource + icons: Optional list of icons for the resource + annotations: Optional annotations for the resource + meta: Optional metadata dictionary for the resource + security: Path-safety policy for extracted template parameters. + Defaults to the server's ``resource_security`` setting. + Only applies to template resources. + + Example: + ```python + @server.resource("resource://my-resource") + def get_data() -> str: + return "Hello, world!" + + @server.resource("resource://my-resource") + async def get_data() -> str: + data = await fetch_data() + return f"Hello, world! {data}" + + @server.resource("resource://{city}/weather") + def get_weather(city: str) -> str: + return f"Weather for {city}" + + @server.resource("resource://{city}/weather") + async def get_weather(city: str) -> str: + data = await fetch_weather(city) + return f"Weather for {city}: {data}" + ``` + + Raises: + InvalidUriTemplate: If ``uri`` is not a valid RFC 6570 template. + ValueError: If URI template parameters don't match the + function's parameters, or if a parameter bound to a + ``{?...}``/``{&...}`` query variable has no default + (the client may omit it). + TypeError: If the decorator is applied without being called + (``@resource`` instead of ``@resource("uri")``). + """ + # Check if user passed function directly instead of calling decorator + if callable(uri): + raise TypeError( + "The @resource decorator was used incorrectly. " + "Did you forget to call it? Use @resource('uri') instead of @resource" + ) + + # Parse once, early โ€” surfaces malformed-template errors at + # decoration time with a clear position, and gives us correct + # variable names for all RFC 6570 operators. + parsed = UriTemplate.parse(uri) + uri_params = set(parsed.variable_names) + + def decorator(fn: _CallableT) -> _CallableT: + sig = inspect.signature(fn) + context_param = find_context_parameter(fn) + func_params = {p for p in sig.parameters.keys() if p != context_param} + + # Template/static is decided purely by the URI: variables + # present means template, none means static. + if uri_params: + if uri_params != func_params: + raise ValueError( + f"Mismatch between URI parameters {uri_params} and function parameters {func_params}" + ) + + # A {?...}/{&...} query variable is optional on the wire: + # match() omits it from the extracted parameters when the + # client leaves it out of the URI. The handler parameter + # bound to it must therefore have a Python default; without + # one, the author only finds out on the first request that + # omits it, as an opaque internal error. + missing_defaults = sorted( + name + for name in parsed.query_variable_names + if sig.parameters[name].default is inspect.Parameter.empty + ) + if missing_defaults: + raise ValueError( + f"Resource {uri!r}: query parameter(s) {missing_defaults} have no " + f"default value. A client may omit a {{?...}}/{{&...}} query " + f"parameter, so the matching handler parameter must declare a " + f"default." + ) + + # Register as template + self._resource_manager.add_template( + fn=fn, + uri_template=uri, + name=name, + title=title, + description=description, + mime_type=mime_type, + icons=icons, + annotations=annotations, + security=security if security is not None else self._resource_security, + meta=meta, + ) + else: + if func_params: + raise ValueError( + f"Resource {uri!r} has no URI template variables, but the " + f"handler declares parameters {func_params}. Add matching " + f"{{...}} variables to the URI or remove the parameters." + ) + if context_param is not None: + raise ValueError( + f"Resource {uri!r} has no URI template variables, but the " + f"handler declares a Context parameter. Context injection " + f"for static resources is not supported. " + f"Add a template variable to the URI or remove the " + f"Context parameter." + ) + # Register as regular resource + resource = FunctionResource.from_function( + fn=fn, + uri=uri, + name=name, + title=title, + description=description, + mime_type=mime_type, + icons=icons, + annotations=annotations, + meta=meta, + ) + self.add_resource(resource) + return fn + + return decorator + + def add_prompt(self, prompt: Prompt) -> None: + """Add a prompt to the server. + + Args: + prompt: A Prompt instance to add + """ + self._prompt_manager.add_prompt(prompt) + + def remove_prompt(self, name: str) -> None: + """Remove a prompt from the server by name. + + Args: + name: The name of the prompt to remove + + Raises: + ValueError: If the prompt does not exist + """ + self._prompt_manager.remove_prompt(name) + + def prompt( + self, + name: str | None = None, + title: str | None = None, + description: str | None = None, + icons: list[Icon] | None = None, + ) -> Callable[[_CallableT], _CallableT]: + """Decorator to register a prompt. + + The function returns the prompt messages (a string, `Message`, dict, + or a sequence of these), or an `InputRequiredResult` to request + client input first (the 2026-07-28 multi-round-trip flow โ€” read + `ctx.input_responses` on the retry). + + Args: + name: Optional name for the prompt (defaults to function name) + title: Optional human-readable title for the prompt + description: Optional description of what the prompt does + icons: Optional list of icons for the prompt + + Example: + ```python + @server.prompt() + def analyze_table(table_name: str) -> list[Message]: + schema = read_table_schema(table_name) + return [ + { + "role": "user", + "content": f"Analyze this schema:\n{schema}" + } + ] + + @server.prompt() + async def analyze_file(path: str) -> list[Message]: + content = await read_file(path) + return [ + { + "role": "user", + "content": { + "type": "resource", + "resource": { + "uri": f"file://{path}", + "text": content + } + } + } + ] + ``` + """ + # Check if user passed function directly instead of calling decorator + if callable(name): + raise TypeError( + "The @prompt decorator was used incorrectly. " + "Did you forget to call it? Use @prompt() instead of @prompt" + ) + + def decorator(func: _CallableT) -> _CallableT: + prompt = Prompt.from_function(func, name=name, title=title, description=description, icons=icons) + self.add_prompt(prompt) + return func + + return decorator + + def custom_route( + self, + path: str, + methods: list[str], + name: str | None = None, + include_in_schema: bool = True, + ): + """Decorator to register a custom HTTP route on the MCP server. + + Allows adding arbitrary HTTP endpoints outside the standard MCP protocol, + which can be useful for OAuth callbacks, health checks, or admin APIs. + The handler function must be an async function that accepts a Starlette + Request and returns a Response. + + Routes using this decorator will not require authorization. It is intended + for uses that are either a part of authorization flows or intended to be + public such as health check endpoints. + + Args: + path: URL path for the route (e.g., "/oauth/callback") + methods: List of HTTP methods to support (e.g., ["GET", "POST"]) + name: Optional name for the route (to reference this route with + Starlette's reverse URL lookup feature) + include_in_schema: Whether to include in OpenAPI schema, defaults to True + + Example: + ```python + @server.custom_route("/health", methods=["GET"]) + async def health_check(request: Request) -> Response: + return JSONResponse({"status": "ok"}) + ``` + """ + + def decorator( + func: Callable[[Request], Awaitable[Response]], + ) -> Callable[[Request], Awaitable[Response]]: + self._custom_starlette_routes.append( + Route(path, endpoint=func, methods=methods, name=name, include_in_schema=include_in_schema) + ) + return func + + return decorator + + async def run_stdio_async(self) -> None: + """Run the server using stdio transport.""" + async with stdio_server() as (read_stream, write_stream): + await self._lowlevel_server.run( + read_stream, + write_stream, + self._lowlevel_server.create_initialization_options(), + ) + + async def run_sse_async( # pragma: no cover + self, + *, + host: str = "127.0.0.1", + port: int = 8000, + sse_path: str = "/sse", + message_path: str = "/messages/", + transport_security: TransportSecuritySettings | None = None, + ) -> None: + """Run the server using SSE transport.""" + import uvicorn + + starlette_app = self.sse_app( + sse_path=sse_path, + message_path=message_path, + transport_security=transport_security, + host=host, + ) + + config = uvicorn.Config( + starlette_app, + host=host, + port=port, + log_level=self.settings.log_level.lower(), + ) + server = uvicorn.Server(config) + await server.serve() + + async def run_streamable_http_async( # pragma: no cover + self, + *, + host: str = "127.0.0.1", + port: int = 8000, + streamable_http_path: str = "/mcp", + json_response: bool = False, + stateless_http: bool = False, + event_store: EventStore | None = None, + retry_interval: int | None = None, + max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, + transport_security: TransportSecuritySettings | None = None, + ) -> None: + """Run the server using StreamableHTTP transport.""" + import uvicorn + + starlette_app = self.streamable_http_app( + streamable_http_path=streamable_http_path, + json_response=json_response, + stateless_http=stateless_http, + event_store=event_store, + retry_interval=retry_interval, + max_request_body_size=max_request_body_size, + transport_security=transport_security, + host=host, + ) + + config = uvicorn.Config( + starlette_app, + host=host, + port=port, + log_level=self.settings.log_level.lower(), + ) + server = uvicorn.Server(config) + await server.serve() + + def sse_app( + self, + *, + sse_path: str = "/sse", + message_path: str = "/messages/", + transport_security: TransportSecuritySettings | None = None, + host: str = "127.0.0.1", + ) -> Starlette: + """Return an instance of the SSE server app.""" + # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) + if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], + allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], + ) + + sse = SseServerTransport(message_path, security_settings=transport_security) + + async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover + # Add client ID from auth context into request context if available + + async with sse.connect_sse(scope, receive, send) as streams: + await self._lowlevel_server.run( + streams[0], streams[1], self._lowlevel_server.create_initialization_options() + ) + return Response() + + # Create routes + routes: list[Route | Mount] = [] + middleware: list[Middleware] = [] + required_scopes: list[str] = [] + + # Set up auth if configured + if self.settings.auth: # pragma: no cover + required_scopes = self.settings.auth.required_scopes or [] + + # Add auth middleware if token verifier is available + if self._token_verifier: + middleware = [ + # extract auth info from request (but do not require it) + Middleware( + AuthenticationMiddleware, + backend=BearerAuthBackend(self._token_verifier), + ), + # Add the auth context middleware to store + # authenticated user in a contextvar + Middleware(AuthContextMiddleware), + ] + + # Add auth endpoints if auth server provider is configured + if self._auth_server_provider: + from mcp.server.auth.routes import create_auth_routes + + routes.extend( + create_auth_routes( + provider=self._auth_server_provider, + issuer_url=self.settings.auth.issuer_url, + service_documentation_url=self.settings.auth.service_documentation_url, + client_registration_options=self.settings.auth.client_registration_options, + revocation_options=self.settings.auth.revocation_options, + identity_assertion_enabled=self.settings.auth.identity_assertion_enabled, + ) + ) + + # When auth is configured, require authentication + if self._token_verifier: # pragma: no cover + # Determine resource metadata URL + resource_metadata_url = None + if self.settings.auth and self.settings.auth.resource_server_url: + from mcp.server.auth.routes import build_resource_metadata_url + + # Build compliant metadata URL for WWW-Authenticate header + resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url) + + # Auth is enabled, wrap the endpoints with RequireAuthMiddleware + routes.append( + Route( + sse_path, + endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url), + methods=["GET"], + ) + ) + routes.append( + Mount( + message_path, + app=RequireAuthMiddleware(sse.handle_post_message, required_scopes, resource_metadata_url), + ) + ) + else: + # Auth is disabled, no need for RequireAuthMiddleware + # Since handle_sse is an ASGI app, we need to create a compatible endpoint + async def sse_endpoint(request: Request) -> Response: # pragma: no cover + # Convert the Starlette request to ASGI parameters + return await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage] + + routes.append( + Route( + sse_path, + endpoint=sse_endpoint, + methods=["GET"], + ) + ) + routes.append( + Mount( + message_path, + app=sse.handle_post_message, + ) + ) + # Add protected resource metadata endpoint if configured as RS + if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover + from mcp.server.auth.routes import create_protected_resource_routes + + routes.extend( + create_protected_resource_routes( + resource_url=self.settings.auth.resource_server_url, + authorization_servers=[self.settings.auth.issuer_url], + scopes_supported=self.settings.auth.required_scopes, + ) + ) + + # mount these routes last, so they have the lowest route matching precedence + routes.extend(self._custom_starlette_routes) + + # Create Starlette app with routes and middleware + return Starlette(debug=self.settings.debug, routes=routes, middleware=middleware) + + def streamable_http_app( + self, + *, + streamable_http_path: str = "/mcp", + json_response: bool = False, + stateless_http: bool = False, + event_store: EventStore | None = None, + retry_interval: int | None = None, + max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, + transport_security: TransportSecuritySettings | None = None, + host: str = "127.0.0.1", + ) -> Starlette: + """Return an instance of the StreamableHTTP server app.""" + return self._lowlevel_server.streamable_http_app( + streamable_http_path=streamable_http_path, + json_response=json_response, + stateless_http=stateless_http, + event_store=event_store, + retry_interval=retry_interval, + max_request_body_size=max_request_body_size, + transport_security=transport_security, + host=host, + auth=self.settings.auth, + token_verifier=self._token_verifier, + auth_server_provider=self._auth_server_provider, + custom_starlette_routes=self._custom_starlette_routes, + debug=self.settings.debug, + ) + + async def list_prompts(self) -> list[MCPPrompt]: + """List all available prompts.""" + prompts = self._prompt_manager.list_prompts() + return [ + MCPPrompt( + name=prompt.name, + title=prompt.title, + description=prompt.description, + arguments=[ + MCPPromptArgument( + name=arg.name, + description=arg.description, + required=arg.required, + ) + for arg in (prompt.arguments or []) + ], + icons=prompt.icons, + ) + for prompt in prompts + ] + + async def get_prompt( + self, name: str, arguments: dict[str, Any] | None = None, context: Context[LifespanResultT, Any] | None = None + ) -> GetPromptResult | InputRequiredResult: + """Get a prompt by name with arguments. + + An `InputRequiredResult` returned by the prompt function is passed + through unchanged (the 2026-07-28 multi-round-trip flow); the retry's + answers arrive on `ctx.input_responses`, with `ctx.request_state` + carrying the echoed opaque state. + """ + if context is None: + context = Context(mcp_server=self, subscriptions=self._subscriptions) + try: + prompt = self._prompt_manager.get_prompt(name) + if not prompt: + raise ValueError(f"Unknown prompt: {name}") + + rendered = await prompt.render(arguments, context) + if isinstance(rendered, InputRequiredResult): + return rendered + + return GetPromptResult( + description=prompt.description, + messages=pydantic_core.to_jsonable_python(rendered), + ) + except MCPError: + raise + except Exception as e: + logger.exception(f"Error getting prompt {name}") + raise ValueError(str(e)) from e + + +def _version_gated(method: MethodBinding) -> RequestHandler: + """Wrap a method handler so a request at a disallowed protocol version is rejected. + + The low-level `_request_handlers` dict is keyed by method only, so per-version + scoping is enforced here rather than at the runner's boundary table. + """ + versions = method.protocol_versions + assert versions is not None + + async def gated(ctx: ServerRequestContext[Any, Any], params: Any) -> HandlerResult: + if ctx.protocol_version not in versions: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method.method) + return await method.handler(ctx, params) + + return gated + + +def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: str) -> None: + """Assert the connected client declared support for `identifier`. + + Call this from an extension's handler or `intercept_tool_call` before + offering extension-specific behaviour. Raises `MCPError` with the + `-32021` (missing required client capability) code and a + `requiredCapabilities` payload when the client did not declare the + extension, per SEP-2133. + + Args: + ctx: The current request context. + identifier: The extension identifier the client must have declared. + + Raises: + MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client + did not advertise `identifier`. + """ + capabilities = ctx.session.client_capabilities + declared = capabilities.extensions if capabilities else None + if not declared or identifier not in declared: + data = MissingRequiredClientCapabilityErrorData( + required_capabilities=ClientCapabilities(extensions={identifier: {}}) + ) + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message=f"Client did not declare required extension {identifier!r}", + data=data.model_dump(by_alias=True, mode="json", exclude_none=True), + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ae9c6561920aec8455b3d336bed7db79e3527dff --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/__init__.py @@ -0,0 +1,4 @@ +from .base import Tool +from .tool_manager import ToolManager + +__all__ = ["Tool", "ToolManager"] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/base.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/base.py new file mode 100644 index 0000000000000000000000000000000000000000..23248707a3d3cf46124583f8f0ac1acf8c50e304 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/base.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from collections.abc import Callable, Hashable +from functools import cached_property +from typing import TYPE_CHECKING, Any + +from mcp_types import Icon, InputRequiredResult, ToolAnnotations +from pydantic import BaseModel, Field + +from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError +from mcp.server.mcpserver.resolve import ( + build_resolver_plans, + find_resolved_parameters, + resolve_arguments, + returns_input_required, +) +from mcp.server.mcpserver.utilities.context_injection import find_context_parameter +from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata +from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError +from mcp.shared.tool_name_validation import validate_and_warn_tool_name + +if TYPE_CHECKING: + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.context import Context + + +class Tool(BaseModel): + """Internal tool registration info.""" + + fn: Callable[..., Any] = Field(exclude=True) + name: str = Field(description="Name of the tool") + title: str | None = Field(None, description="Human-readable title of the tool") + description: str = Field(description="Description of what the tool does") + parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") + fn_metadata: FuncMetadata = Field( + description="Metadata about the function including a pydantic model for tool arguments" + ) + is_async: bool = Field(description="Whether the tool is async") + context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") + resolved_params: dict[str, Any] = Field( + default_factory=lambda: {}, + exclude=True, + description="Parameters filled by resolvers, mapped to (Resolve, wants_union)", + ) + resolver_plans: dict[Hashable, Any] = Field( + default_factory=lambda: {}, exclude=True, description="Static per-resolver parameter plans" + ) + annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool") + icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool") + + @cached_property + def output_schema(self) -> dict[str, Any] | None: + return self.fn_metadata.output_schema + + @classmethod + def from_function( + cls, + fn: Callable[..., Any], + name: str | None = None, + title: str | None = None, + description: str | None = None, + context_kwarg: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, + structured_output: bool | None = None, + ) -> Tool: + """Create a Tool from a function.""" + func_name = name or fn.__name__ + + validate_and_warn_tool_name(func_name) + + if func_name == "": + raise ValueError("You must provide a name for lambda functions") + + func_doc = description or fn.__doc__ or "" + is_async = is_async_callable(fn) + + if context_kwarg is None: # pragma: no branch + context_kwarg = find_context_parameter(fn) + + resolved_params = find_resolved_parameters(fn) + if resolved_params and returns_input_required(fn): + raise InvalidSignature( + f"Tool {func_name!r} combines Resolve(...) parameters with an InputRequiredResult " + "return; a call has one input_required channel, so the multi-round flow is driven " + "either by resolvers or by the tool body, not both" + ) + + skip_names = [context_kwarg] if context_kwarg is not None else [] + skip_names.extend(resolved_params) + + func_arg_metadata = func_metadata( + fn, + skip_names=skip_names, + structured_output=structured_output, + ) + parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) + + # Match `model_dump_one_level`'s kwarg keys (alias when present, else field name) + # so a by-name resolver param resolves to a key that exists at call time. + tool_arg_names = {field.alias or name for name, field in func_arg_metadata.arg_model.model_fields.items()} + resolver_plans = build_resolver_plans(resolved_params, tool_arg_names) + + return cls( + fn=fn, + name=func_name, + title=title, + description=func_doc, + parameters=parameters, + fn_metadata=func_arg_metadata, + is_async=is_async, + context_kwarg=context_kwarg, + resolved_params=dict(resolved_params), + resolver_plans=resolver_plans, + annotations=annotations, + icons=icons, + meta=meta, + ) + + async def run( + self, + arguments: dict[str, Any], + context: Context[LifespanContextT, RequestT], + convert_result: bool = False, + ) -> Any: + """Run the tool with arguments. + + Raises: + ToolError: If the tool function raises during execution. + """ + try: + pass_directly: dict[str, Any] = {} + if self.context_kwarg is not None: + pass_directly[self.context_kwarg] = context + + # Resolvers see the same validated arguments the tool body receives: + # validate once and reuse it, so a `default_factory`/stateful validator + # can't hand a by-name resolver a different value than the body. + pre_validated: dict[str, Any] | None = None + if self.resolved_params: + pre_validated = self.fn_metadata.validate_arguments(arguments) + resolved = await resolve_arguments(self.resolved_params, self.resolver_plans, pre_validated, context) + if isinstance(resolved, InputRequiredResult): + # A resolver still needs client input (>= 2026-07-28): surface the + # batched questions instead of running the tool body this round. + return self.fn_metadata.convert_result(resolved) if convert_result else resolved + pass_directly |= resolved + + result = await self.fn_metadata.call_fn_with_arg_validation( + self.fn, + self.is_async, + arguments, + pass_directly or None, + pre_validated=pre_validated, + ) + + # Registration rejects the annotated form of this combination; this covers + # a body that returns an InputRequiredResult without declaring it. + if self.resolved_params and isinstance(result, InputRequiredResult): + raise ToolError( + "the tool returned an InputRequiredResult but its parameters use Resolve(...); " + "a call has one input_required channel, so the multi-round flow is driven " + "either by resolvers or by the tool body, not both" + ) + + if convert_result: + result = self.fn_metadata.convert_result(result) + + return result + except MCPError: + # `MCPError` (and subclasses such as `UrlElicitationRequiredError`) + # carries a JSON-RPC `ErrorData(code, message, data)` and means + # "respond with a protocol error" - re-raise so the kernel surfaces + # it as a top-level JSON-RPC error rather than wrapping it as a + # `CallToolResult(isError=True)` execution failure. + raise + except Exception as e: + raise ToolError(f"Error executing tool {self.name}: {e}") from e diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/tool_manager.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/tool_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..9e7910ea935fda3ef35bfcbeeb91c888387e9a85 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/tools/tool_manager.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from mcp_types import Icon, ToolAnnotations + +from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.tools.base import Tool +from mcp.server.mcpserver.utilities.logging import get_logger + +if TYPE_CHECKING: + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.context import Context + +logger = get_logger(__name__) + + +class ToolManager: + """Manages MCPServer tools.""" + + def __init__(self, warn_on_duplicate_tools: bool = True, *, tools: list[Tool] | None = None): + self._tools: dict[str, Tool] = {} + for tool in tools or (): + if warn_on_duplicate_tools and tool.name in self._tools: + logger.warning(f"Tool already exists: {tool.name}") + self._tools[tool.name] = tool + + self.warn_on_duplicate_tools = warn_on_duplicate_tools + + def get_tool(self, name: str) -> Tool | None: + """Get tool by name.""" + return self._tools.get(name) + + def list_tools(self) -> list[Tool]: + """List all registered tools.""" + return list(self._tools.values()) + + def add_tool( + self, + fn: Callable[..., Any], + name: str | None = None, + title: str | None = None, + description: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, + structured_output: bool | None = None, + ) -> Tool: + """Add a tool to the server.""" + tool = Tool.from_function( + fn, + name=name, + title=title, + description=description, + annotations=annotations, + icons=icons, + meta=meta, + structured_output=structured_output, + ) + existing = self._tools.get(tool.name) + if existing: + if self.warn_on_duplicate_tools: + logger.warning(f"Tool already exists: {tool.name}") + return existing + self._tools[tool.name] = tool + return tool + + def remove_tool(self, name: str) -> None: + """Remove a tool by name.""" + if name not in self._tools: + raise ToolError(f"Unknown tool: {name}") + del self._tools[name] + + async def call_tool( + self, + name: str, + arguments: dict[str, Any], + context: Context[LifespanContextT, RequestT], + convert_result: bool = False, + ) -> Any: + """Call a tool by name with arguments.""" + tool = self.get_tool(name) + if not tool: + raise ToolError(f"Unknown tool: {name}") + + return await tool.run(arguments, context, convert_result=convert_result) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b9d00d54c5284dd7ef152b213014bfd807ee79ec --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/__init__.py @@ -0,0 +1 @@ +"""MCPServer utility modules.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/context_injection.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/context_injection.py new file mode 100644 index 0000000000000000000000000000000000000000..ac7ab82d05b24478cd09eb76bbec4a824a0af0a8 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/context_injection.py @@ -0,0 +1,68 @@ +"""Context injection utilities for MCPServer.""" + +from __future__ import annotations + +import inspect +import typing +from collections.abc import Callable +from typing import Any + +from mcp.server.mcpserver.context import Context + + +def find_context_parameter(fn: Callable[..., Any]) -> str | None: + """Find the parameter that should receive the Context object. + + Searches through the function's signature to find a parameter + with a Context type annotation. + + Args: + fn: The function to inspect + + Returns: + The name of the context parameter, or None if not found + """ + # Get type hints to properly resolve string annotations + try: + hints = typing.get_type_hints(fn) + except Exception: # pragma: lax no cover + # If we can't resolve type hints, we can't find the context parameter + return None + + # Check each parameter's type hint + for param_name, annotation in hints.items(): + # Handle direct Context type + if inspect.isclass(annotation) and issubclass(annotation, Context): + return param_name + + # Handle generic types like Optional[Context] + origin = typing.get_origin(annotation) + if origin is not None: + args = typing.get_args(annotation) + for arg in args: + if inspect.isclass(arg) and issubclass(arg, Context): + return param_name + + return None + + +def inject_context( + fn: Callable[..., Any], + kwargs: dict[str, Any], + context: Any | None, + context_kwarg: str | None, +) -> dict[str, Any]: + """Inject context into function kwargs if needed. + + Args: + fn: The function that will be called + kwargs: The current keyword arguments + context: The context object to inject (if any) + context_kwarg: The name of the parameter to inject into + + Returns: + Updated kwargs with context injected if applicable + """ + if context_kwarg is not None and context is not None: + return {**kwargs, context_kwarg: context} + return kwargs diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/func_metadata.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/func_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..be4afb4e9b85de7c4bdcf0022726334c73ab9297 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/func_metadata.py @@ -0,0 +1,574 @@ +import functools +import inspect +import json +from collections.abc import Awaitable, Callable, Sequence +from itertools import chain +from types import GenericAlias +from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints + +import anyio +import anyio.to_thread +import pydantic_core +from mcp_types import CallToolResult, ContentBlock, InputRequiredResult, TextContent +from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model +from pydantic.fields import FieldInfo +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind +from typing_extensions import is_typeddict +from typing_inspection.introspection import ( + UNKNOWN, + AnnotationSource, + ForbiddenQualifier, + inspect_annotation, + is_union_origin, +) + +from mcp.server.mcpserver.exceptions import InvalidSignature +from mcp.server.mcpserver.utilities.logging import get_logger +from mcp.server.mcpserver.utilities.types import Audio, Image + +logger = get_logger(__name__) + + +def _is_input_required_type(obj: Any) -> bool: + return isinstance(obj, type) and issubclass(obj, InputRequiredResult) + + +class StrictJsonSchema(GenerateJsonSchema): + """A JSON schema generator that raises exceptions instead of emitting warnings. + + This is used to detect non-serializable types during schema generation. + """ + + def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: + # Raise an exception instead of emitting a warning + raise ValueError(f"JSON schema warning: {kind} - {detail}") + + +class ArgModelBase(BaseModel): + """A model representing the arguments to a function.""" + + def model_dump_one_level(self) -> dict[str, Any]: + """Return a dict of the model's fields, one level deep. + + That is, sub-models etc are not dumped - they are kept as Pydantic models. + """ + kwargs: dict[str, Any] = {} + for field_name, field_info in self.__class__.model_fields.items(): + value = getattr(self, field_name) + # Use the alias if it exists, otherwise use the field name + output_name = field_info.alias if field_info.alias else field_name + kwargs[output_name] = value + return kwargs + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class FuncMetadata(BaseModel): + arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] + output_schema: dict[str, Any] | None = None + output_model: Annotated[type[BaseModel], WithJsonSchema(None)] | None = None + wrap_output: bool = False + + def validate_arguments(self, arguments_to_validate: dict[str, Any]) -> dict[str, Any]: + """Validate raw arguments into a one-level kwargs dict (no function call). + + Used to feed resolver dependency injection the validated tool arguments + before the tool function itself runs. + """ + arguments_pre_parsed = self.pre_parse_json(arguments_to_validate) + arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed) + return arguments_parsed_model.model_dump_one_level() + + async def call_fn_with_arg_validation( + self, + fn: Callable[..., Any | Awaitable[Any]], + fn_is_async: bool, + arguments_to_validate: dict[str, Any], + arguments_to_pass_directly: dict[str, Any] | None, + pre_validated: dict[str, Any] | None = None, + ) -> Any: + """Call the given function with arguments validated and injected. + + Arguments are first attempted to be parsed from JSON, then validated against + the argument model, before being passed to the function. Pass `pre_validated` + (the output of `validate_arguments`) to reuse an earlier validation pass - + validating twice can re-run `default_factory`/stateful validators and hand the + function different values than a caller already observed. + """ + # Copy so a caller-provided `pre_validated` dict is never mutated in place. + arguments_parsed_dict = dict( + pre_validated if pre_validated is not None else self.validate_arguments(arguments_to_validate) + ) + + arguments_parsed_dict |= arguments_to_pass_directly or {} + + if fn_is_async: + return await fn(**arguments_parsed_dict) + else: + return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict)) + + def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult: + """Convert a function call result into a `CallToolResult`. + + An `InputRequiredResult` is passed through unchanged so the multi-round + flow surfaces on the wire as `resultType: "input_required"` rather than + being JSON-dumped into a text block. + + Note: we build unstructured content here **even though the lowlevel server + tool call handler provides generic backwards compatibility serialization of + structured content**. This is for MCPServer backwards compatibility: we need to + retain MCPServer's ad hoc conversion logic for constructing unstructured output + from function return values, whereas the lowlevel server simply serializes + the structured output. + """ + if isinstance(result, InputRequiredResult): + return result + if isinstance(result, CallToolResult): + if self.output_schema is not None: + assert self.output_model is not None, "Output model must be set if output schema is defined" + self.output_model.model_validate(result.structured_content) + return result + + unstructured_content = _convert_to_content(result) + + if self.output_schema is None: + return CallToolResult(content=unstructured_content) + + if self.wrap_output: + result = {"result": result} + + assert self.output_model is not None, "Output model must be set if output schema is defined" + validated = self.output_model.model_validate(result) + structured_content = validated.model_dump(mode="json", by_alias=True) + + return CallToolResult(content=unstructured_content, structured_content=structured_content) + + def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: + """Pre-parse data from JSON. + + Return a dict with the same keys as input but with values parsed from JSON + if appropriate. + + This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside + a string rather than an actual list. Claude Desktop is prone to this - in fact + it seems incapable of NOT doing this. For sub-models, it tends to pass + dicts (JSON objects) as JSON strings, which can be pre-parsed here. + """ + new_data = data.copy() # Shallow copy + + # Build a mapping from input keys (including aliases) to field info + key_to_field_info: dict[str, FieldInfo] = {} + for field_name, field_info in self.arg_model.model_fields.items(): + # Map both the field name and its alias (if any) to the field info + key_to_field_info[field_name] = field_info + if field_info.alias: + key_to_field_info[field_info.alias] = field_info + + for data_key, data_value in data.items(): + if data_key not in key_to_field_info: + continue + + field_info = key_to_field_info[data_key] + if isinstance(data_value, str) and field_info.annotation is not str: + try: + pre_parsed = json.loads(data_value) + except json.JSONDecodeError: + continue # Not JSON - skip + if isinstance(pre_parsed, str | int | float): + # This is likely that the raw value is e.g. `"hello"` which we + # Should really be parsed as '"hello"' in Python - but if we parse + # it as JSON it'll turn into just 'hello'. So we skip it. + continue + new_data[data_key] = pre_parsed + assert new_data.keys() == data.keys() + return new_data + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + +def func_metadata( + func: Callable[..., Any], + skip_names: Sequence[str] = (), + structured_output: bool | None = None, +) -> FuncMetadata: + """Given a function, return metadata including a Pydantic model representing its signature. + + The use case for this is + ``` + meta = func_metadata(func) + validated_args = meta.arg_model.model_validate(some_raw_data_dict) + return func(**validated_args.model_dump_one_level()) + ``` + + **critically** it also provides a pre-parse helper to attempt to parse things from + JSON. + + Args: + func: The function to convert to a Pydantic model + skip_names: A list of parameter names to skip. These will not be included in + the model. + structured_output: Controls whether the tool's output is structured or unstructured + - If None, auto-detects based on the function's return type annotation + - If True, creates a structured tool (return type annotation permitting) + - If False, unconditionally creates an unstructured tool + + If structured, creates a Pydantic model for the function's result based on its annotation. + Supports various return types: + - BaseModel subclasses (used directly) + - Primitive types (str, int, float, bool, bytes, None) - wrapped in a + model with a 'result' field + - TypedDict - converted to a Pydantic model with same fields + - Dataclasses and other annotated classes - converted to Pydantic models + - Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field + + Returns: + A FuncMetadata object containing: + - arg_model: A Pydantic model representing the function's arguments + - output_model: A Pydantic model for the return type if the output is structured + - wrap_output: Whether the function result needs to be wrapped in `{"result": ...}` for structured output. + """ + try: + sig = inspect.signature(func, eval_str=True) + except NameError as e: # pragma: no cover + # This raise could perhaps be skipped, and we (MCPServer) just call + # model_rebuild right before using it ๐Ÿคท + raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e + params = sig.parameters + dynamic_pydantic_model_params: dict[str, Any] = {} + for param in params.values(): + if param.name.startswith("_"): # pragma: no cover + raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'") + if param.name in skip_names: + continue + + annotation = param.annotation if param.annotation is not inspect.Parameter.empty else Any + field_name = param.name + field_kwargs: dict[str, Any] = {} + field_metadata: list[Any] = [] + + if param.annotation is inspect.Parameter.empty: + field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"})) + # Check if the parameter name conflicts with BaseModel attributes + # This is necessary because Pydantic warns about shadowing parent attributes + if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)): + # Use an alias to avoid the shadowing warning + field_kwargs["alias"] = field_name + # Use a prefixed field name + field_name = f"field_{field_name}" + + if param.default is not inspect.Parameter.empty: + dynamic_pydantic_model_params[field_name] = ( + Annotated[(annotation, *field_metadata, Field(**field_kwargs))], + param.default, + ) + else: + dynamic_pydantic_model_params[field_name] = Annotated[(annotation, *field_metadata, Field(**field_kwargs))] + + arguments_model = create_model( + f"{func.__name__}Arguments", + __base__=ArgModelBase, + **dynamic_pydantic_model_params, + ) + + if structured_output is False: + return FuncMetadata(arg_model=arguments_model) + + # set up structured output support based on return type annotation + + if sig.return_annotation is inspect.Parameter.empty and structured_output is True: + raise InvalidSignature(f"Function {func.__name__}: return annotation required for structured output") + + try: + inspected_return_ann = inspect_annotation(sig.return_annotation, annotation_source=AnnotationSource.FUNCTION) + except ForbiddenQualifier as e: + raise InvalidSignature(f"Function {func.__name__}: return annotation contains an invalid type qualifier") from e + + return_type_expr = inspected_return_ann.type + + # `AnnotationSource.FUNCTION` allows no type qualifier to be used, so `return_type_expr` is guaranteed to *not* be + # unknown (i.e. a bare `Final`). + assert return_type_expr is not UNKNOWN + + if _is_input_required_type(return_type_expr): + # A tool annotated to return only InputRequiredResult never produces structured content. + return FuncMetadata(arg_model=arguments_model) + + # The annotation fed to schema derivation. Starts as the raw return annotation (preserving any + # Annotated[...] wrapper) and is narrowed below if InputRequiredResult arms are stripped. + effective_annotation: Any = sig.return_annotation + + if is_union_origin(get_origin(return_type_expr)): + args = get_args(return_type_expr) + # InputRequiredResult is a control-flow signal, not data: strip it so the residual arms + # drive schema derivation. convert_result short-circuits on an InputRequiredResult instance + # before output validation, so the schema only ever sees the data arms at runtime. + residual = tuple(a for a in args if not _is_input_required_type(a)) + if not residual: + return FuncMetadata(arg_model=arguments_model) + if len(residual) != len(args): + # PEP 604 has no syntax for "union of a runtime tuple"; Union[...] is the only spelling. + effective_annotation = residual[0] if len(residual) == 1 else Union[residual] # noqa: UP007 + # Re-normalize so the residual is processed exactly as if it had been the declared + # return annotation: unwraps a top-level Annotated[...] arm and re-derives metadata, + # so the CallToolResult/BaseModel/TypedDict dispatch below sees the bare type. + inspected_return_ann = inspect_annotation(effective_annotation, annotation_source=AnnotationSource.FUNCTION) + return_type_expr = inspected_return_ann.type + if len(residual) > 1 and any( + isinstance(a, type) and issubclass(a, CallToolResult) for a in residual if a is not type(None) + ): + raise InvalidSignature( + f"Function {func.__name__}: CallToolResult cannot be used in Union or Optional types. " + "To return empty results, use: CallToolResult(content=[])" + ) + + original_annotation: Any + # if the typehint is CallToolResult, the user either intends to return without validation + # or they provided validation as Annotated metadata + if isinstance(return_type_expr, type) and issubclass(return_type_expr, CallToolResult): + if inspected_return_ann.metadata: + return_type_expr = inspected_return_ann.metadata[0] + if len(inspected_return_ann.metadata) >= 2: + # Reconstruct the original annotation, by preserving the remaining metadata, + # i.e. from `Annotated[CallToolResult, ReturnType, Gt(1)]` to + # `Annotated[ReturnType, Gt(1)]`: + original_annotation = Annotated[ + (return_type_expr, *inspected_return_ann.metadata[1:]) + ] # pragma: no cover + else: + # We only had `Annotated[CallToolResult, ReturnType]`, treat the original annotation + # as being `ReturnType`: + original_annotation = return_type_expr + else: + return FuncMetadata(arg_model=arguments_model) + else: + original_annotation = effective_annotation + + output_model, output_schema, wrap_output = _try_create_model_and_schema( + original_annotation, return_type_expr, func.__name__ + ) + + if output_model is None and structured_output is True: + # Model creation failed or produced warnings - no structured output + raise InvalidSignature( + f"Function {func.__name__}: return type {return_type_expr} is not serializable for structured output" + ) + + return FuncMetadata( + arg_model=arguments_model, + output_schema=output_schema, + output_model=output_model, + wrap_output=wrap_output, + ) + + +def _try_create_model_and_schema( + original_annotation: Any, + type_expr: Any, + func_name: str, +) -> tuple[type[BaseModel] | None, dict[str, Any] | None, bool]: + """Try to create a model and schema for the given annotation without warnings. + + Args: + original_annotation: The original return annotation (may be wrapped in `Annotated`). + type_expr: The underlying type expression derived from the return annotation + (`Annotated` and type qualifiers were stripped). + func_name: The name of the function. + + Returns: + tuple of (model or None, schema or None, wrap_output) + Model and schema are None if warnings occur or creation fails. + wrap_output is True if the result needs to be wrapped in {"result": ...} + """ + model = None + wrap_output = False + + # First handle special case: None + if type_expr is None: + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + + # Handle GenericAlias types (list[str], dict[str, int], Union[str, int], etc.) + elif isinstance(type_expr, GenericAlias): + origin = get_origin(type_expr) + + # Special case: dict with string keys can use RootModel + if origin is dict: + args = get_args(type_expr) + if len(args) == 2 and args[0] is str: + # TODO: should we use the original annotation? We are losing any potential `Annotated` + # metadata for Pydantic here: + model = _create_dict_model(func_name, type_expr) + else: + # dict with non-str keys needs wrapping + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + else: + # All other generic types need wrapping (list, tuple, Union, Optional, etc.) + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + + # Handle regular type objects + elif isinstance(type_expr, type): + type_annotation = cast(type[Any], type_expr) + + # Case 1: BaseModel subclasses (can be used directly) + if issubclass(type_annotation, BaseModel): + model = type_annotation + + # Case 2: TypedDicts: + elif is_typeddict(type_annotation): + model = _create_model_from_typeddict(type_annotation) + + # Case 3: Primitive types that need wrapping + elif type_annotation in (str, int, float, bool, bytes, type(None)): + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + + # Case 4: Other class types (dataclasses, regular classes with annotations) + else: + type_hints = get_type_hints(type_annotation) + if type_hints: + # Classes with type hints can be converted to Pydantic models + model = _create_model_from_class(type_annotation, type_hints) + # Classes without type hints are not serializable - model remains None + + # Handle any other types not covered above + else: + # This includes typing constructs that aren't GenericAlias in Python 3.10 + # (e.g., Union, Optional in some Python versions) + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + + if model: + # If we successfully created a model, try to get its schema + # Use StrictJsonSchema to raise exceptions instead of warnings + try: + schema = model.model_json_schema(schema_generator=StrictJsonSchema) + except ( + PydanticUserError, + TypeError, + ValueError, + pydantic_core.SchemaError, + pydantic_core.ValidationError, + ) as e: + # These are expected errors when a type can't be converted to a Pydantic schema + # PydanticUserError: When Pydantic can't handle the type (e.g. PydanticInvalidForJsonSchema); + # subclasses TypeError on pydantic <2.13 and RuntimeError on pydantic >=2.13 + # ValueError: When there are issues with the type definition (including our custom warnings) + # SchemaError: When Pydantic can't build a schema + # ValidationError: When validation fails + logger.info(f"Cannot create schema for type {type_expr} in {func_name}: {type(e).__name__}: {e}") + return None, None, False + + return model, schema, wrap_output + + return None, None, False + + +_no_default = object() + + +def _create_model_from_class(cls: type[Any], type_hints: dict[str, Any]) -> type[BaseModel]: + """Create a Pydantic model from an ordinary class. + + The created model will: + - Have the same name as the class + - Have fields with the same names and types as the class's fields + - Include all fields whose type does not include None in the set of required fields + + Precondition: cls must have type hints (i.e., `type_hints` is non-empty) + """ + model_fields: dict[str, Any] = {} + for field_name, field_type in type_hints.items(): + if field_name.startswith("_"): # pragma: no cover + continue + + default = getattr(cls, field_name, _no_default) + if default is _no_default: + model_fields[field_name] = field_type + else: + model_fields[field_name] = (field_type, default) + + return create_model(cls.__name__, __config__=ConfigDict(from_attributes=True), **model_fields) + + +def _create_model_from_typeddict(td_type: type[Any]) -> type[BaseModel]: + """Create a Pydantic model from a TypedDict. + + The created model will have the same name and fields as the TypedDict. + """ + type_hints = get_type_hints(td_type) + required_keys = getattr(td_type, "__required_keys__", set(type_hints.keys())) + + model_fields: dict[str, Any] = {} + for field_name, field_type in type_hints.items(): + if field_name not in required_keys: + # For optional TypedDict fields, set default=None + # This makes them not required in the Pydantic model + # The model should use exclude_unset=True when dumping to get TypedDict semantics + model_fields[field_name] = (field_type, None) + else: + model_fields[field_name] = field_type + + return create_model(td_type.__name__, **model_fields) + + +def _create_wrapped_model(func_name: str, annotation: Any) -> type[BaseModel]: + """Create a model that wraps a type in a 'result' field. + + This is used for primitive types, generic types like list/dict, etc. + """ + model_name = f"{func_name}Output" + + return create_model(model_name, result=annotation) + + +def _create_dict_model(func_name: str, dict_annotation: Any) -> type[BaseModel]: + """Create a RootModel for dict[str, T] types.""" + # TODO(Marcelo): We should not rely on RootModel for this. + from pydantic import RootModel # noqa: TID251 + + class DictModel(RootModel[dict_annotation]): + pass + + # Give it a meaningful name + DictModel.__name__ = f"{func_name}DictOutput" + DictModel.__qualname__ = f"{func_name}DictOutput" + + return DictModel + + +def _convert_to_content(result: Any) -> list[ContentBlock]: + """Convert a result to a sequence of content objects. + + Note: This conversion logic comes from previous versions of MCPServer and is being + retained for purposes of backwards compatibility. It produces different unstructured + output than the lowlevel server tool call handler, which just serializes structured + content verbatim. + """ + if result is None: # pragma: no cover + return [] + + if isinstance(result, ContentBlock): + return [result] + + if isinstance(result, Image): + return [result.to_image_content()] + + if isinstance(result, Audio): + return [result.to_audio_content()] + + if isinstance(result, list | tuple): + return list( + chain.from_iterable( + _convert_to_content(item) + for item in result # type: ignore + ) + ) + + if not isinstance(result, str): + result = pydantic_core.to_json(result, fallback=str, indent=2).decode() + + return [TextContent(type="text", text=result)] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/logging.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..04ca38853bbac0d2ec92d1128e45af1870d66df5 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/logging.py @@ -0,0 +1,39 @@ +"""Logging utilities for MCPServer.""" + +import logging +from typing import Literal + + +def get_logger(name: str) -> logging.Logger: + """Get a logger nested under MCP namespace. + + Args: + name: The name of the logger. + + Returns: + A configured logger instance. + """ + return logging.getLogger(name) + + +def configure_logging( + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", +) -> None: + """Configure logging for MCP. + + Args: + level: The log level to use. + """ + handlers: list[logging.Handler] = [] + try: + from rich.console import Console + from rich.logging import RichHandler + + handlers.append(RichHandler(console=Console(stderr=True), rich_tracebacks=True)) + except ImportError: # pragma: no cover + pass + + if not handlers: # pragma: no cover + handlers.append(logging.StreamHandler()) + + logging.basicConfig(level=level, format="%(message)s", handlers=handlers) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/types.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/types.py new file mode 100644 index 0000000000000000000000000000000000000000..937a7fa9b6dda63692914378433830213e25e5a8 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/mcpserver/utilities/types.py @@ -0,0 +1,101 @@ +"""Common types used across MCPServer.""" + +import base64 +from pathlib import Path + +from mcp_types import AudioContent, ImageContent + + +class Image: + """Helper class for returning images from tools.""" + + def __init__( + self, + path: str | Path | None = None, + data: bytes | None = None, + format: str | None = None, + ): + if path is None and data is None: # pragma: no cover + raise ValueError("Either path or data must be provided") + if path is not None and data is not None: # pragma: no cover + raise ValueError("Only one of path or data can be provided") + + self.path = Path(path) if path else None + self.data = data + self._format = format + self._mime_type = self._get_mime_type() + + def _get_mime_type(self) -> str: + """Get MIME type from format or guess from file extension.""" + if self._format: + return f"image/{self._format.lower()}" + + if self.path: + suffix = self.path.suffix.lower() + return { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + }.get(suffix, "application/octet-stream") + return "image/png" # default for raw binary data + + def to_image_content(self) -> ImageContent: + """Convert to MCP ImageContent.""" + if self.path: + with open(self.path, "rb") as f: + data = base64.b64encode(f.read()).decode() + elif self.data is not None: + data = base64.b64encode(self.data).decode() + else: # pragma: no cover + raise ValueError("No image data available") + + return ImageContent(type="image", data=data, mime_type=self._mime_type) + + +class Audio: + """Helper class for returning audio from tools.""" + + def __init__( + self, + path: str | Path | None = None, + data: bytes | None = None, + format: str | None = None, + ): + if not bool(path) ^ bool(data): # pragma: no cover + raise ValueError("Either path or data can be provided") + + self.path = Path(path) if path else None + self.data = data + self._format = format + self._mime_type = self._get_mime_type() + + def _get_mime_type(self) -> str: + """Get MIME type from format or guess from file extension.""" + if self._format: + return f"audio/{self._format.lower()}" + + if self.path: + suffix = self.path.suffix.lower() + return { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + ".aac": "audio/aac", + ".m4a": "audio/mp4", + }.get(suffix, "application/octet-stream") + return "audio/wav" # default for raw binary data + + def to_audio_content(self) -> AudioContent: + """Convert to MCP AudioContent.""" + if self.path: + with open(self.path, "rb") as f: + data = base64.b64encode(f.read()).decode() + elif self.data is not None: + data = base64.b64encode(self.data).decode() + else: # pragma: no cover + raise ValueError("No audio data available") + + return AudioContent(type="audio", data=data, mime_type=self._mime_type) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/models.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/models.py new file mode 100644 index 0000000000000000000000000000000000000000..6b129165a129e6ad2f401949cb272d66c706eda6 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/models.py @@ -0,0 +1,17 @@ +"""This module provides simplified types to use with the server for managing prompts +and tools. +""" + +from mcp_types import Icon, ServerCapabilities +from pydantic import BaseModel + + +class InitializationOptions(BaseModel): + server_name: str + server_version: str + title: str | None = None + description: str | None = None + capabilities: ServerCapabilities + instructions: str | None = None + website_url: str | None = None + icons: list[Icon] | None = None diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/request_state.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/request_state.py new file mode 100644 index 0000000000000000000000000000000000000000..ad1abe8c36101e4381696c14cc25b8707be2ec8a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/request_state.py @@ -0,0 +1,454 @@ +"""Integrity protection for the multi-round-trip `requestState` (MCP 2026-07-28). + +The spec requires servers to treat the client-echoed `requestState` as +attacker-controlled: `RequestStateBoundary` seals every outgoing value and +verifies every inbound echo, so handlers only ever see plaintext they minted. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import math +import os +import time +from collections.abc import Callable, Mapping, Sequence +from dataclasses import replace +from typing import Any, NoReturn, Protocol, cast + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from mcp_types import INTERNAL_ERROR, INVALID_PARAMS +from mcp_types.methods import INPUT_REQUIRED_METHODS, is_input_required + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import principal_components +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.shared.exceptions import MCPError + +__all__ = [ + "AESGCMRequestStateCodec", + "InvalidRequestState", + "RequestStateBoundary", + "RequestStateCodec", + "RequestStateSecurity", + "authenticated_principal", +] + +logger = logging.getLogger(__name__) + + +class InvalidRequestState(Exception): + """A sealed `requestState` token failed verification. + + The message is a log-only reason code; the boundary never puts it on the wire. + """ + + +class RequestStateCodec(Protocol): + """Authenticated crypto over the framework's request-state envelope. + + The framework stamps and re-verifies every envelope claim (expiry, request + binding, principal); a codec only provides integrity and, ideally, + confidentiality (a sign-only codec leaves the payload client-readable). + + Requirements: `unseal(seal(payload))` round-trips, and `unseal` raises + `InvalidRequestState` for any token it did not mint unmodified; tokens + never name their algorithm (version with a format prefix bound under the + authentication tag, RFC 8725); comparisons are constant-time. Both methods + are synchronous, so cache key material rather than calling a KMS per token. + """ + + def seal(self, payload: bytes) -> str: + """Return an opaque URL-safe token protecting `payload`.""" + ... + + def unseal(self, token: str) -> bytes: + """Reverse `seal`. + + Raises: + InvalidRequestState: Malformed, unauthentic, or unknown-key token. + """ + ... + + +def authenticated_principal(ctx: ServerRequestContext[Any, Any]) -> str | None: + """Default principal binding: the authenticated (client, issuer, subject) identity. + + Uses the same components session ownership uses, so two users of one OAuth + client are distinct principals whenever the token verifier supplies a + subject, and the binding degrades to the client identity when it does not. + Returns `None` (state not principal-bound) on unauthenticated transports. + """ + token = get_access_token() + if token is None: + return None + return compact_json(principal_components(token)) + + +class RequestStateSecurity: + """Policy for protecting `requestState`: codec, TTL, principal, audience. + + Exactly one of `keys` or `codec`: + + RequestStateSecurity(keys=[secret]) # built-in AES-256-GCM + RequestStateSecurity(codec=MyKmsCodec()) # bring your own crypto + RequestStateSecurity.ephemeral() # process-local key + + `keys` is the rotation ring: `keys[0]` seals, every key unseals. + Zero-downtime rotation, each phase fully rolled out before the next: + `keys=[old, new]`, then `keys=[new, old]`, then `keys=[new]` after one TTL. + + The boundary enforces expiry, request binding, audience, and principal for + every codec, fail-closed in both directions. `audience=None` defers to the + boundary's `default_audience` (`MCPServer` passes its server name). + """ + + codec: RequestStateCodec + ttl: float + bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None + audience: str | None + + def __init__( + self, + *, + keys: Sequence[bytes | bytearray | str] | None = None, + codec: RequestStateCodec | None = None, + ttl: float = 600.0, + bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None = authenticated_principal, + audience: str | None = None, + ) -> None: + if (keys is None) == (codec is None): + raise ValueError("RequestStateSecurity takes exactly one of keys= or codec=") + if not (math.isfinite(ttl) and ttl > 0): + raise ValueError(f"request-state ttl must be a positive finite number, got {ttl!r}") + if keys is not None: + self.codec = AESGCMRequestStateCodec(keys) + else: + assert codec is not None + self.codec = codec + self.ttl = ttl + self.bind_principal = bind_principal + self.audience = audience + + @classmethod + def ephemeral(cls, *, ttl: float = 600.0, audience: str | None = None) -> RequestStateSecurity: + """Protection under a key generated now and held only by this process. + + This is the policy `MCPServer` installs when `request_state_security=` + is omitted; call it yourself on the lowlevel tier or to set `ttl`/ + `audience`. Suits single-process deployments (stdio, one HTTP worker): + state minted before a restart or by another worker is rejected. + Multi-instance deployments must share a key via `keys=[...]`. + """ + return cls(keys=[os.urandom(32)], ttl=ttl, audience=audience) + + +_KDF_INFO = b"mcp/request-state/v1/aes-256-gcm" +_KID_INFO = b"mcp/request-state/v1/kid:" +_TOKEN_PREFIX = "v1." +_KID_LEN = 4 +_NONCE_LEN = 12 + + +def compact_json(value: Any, *, sort_keys: bool = False) -> str: + """Canonical JSON for everything the state path digests or seals. + + ASCII output keeps the encode total: a lone surrogate in client-supplied + text escapes instead of raising. Anything consuming this must parse with + stdlib `json.loads`, which accepts those escapes (pydantic's JSON parser + does not). + """ + return json.dumps(value, sort_keys=sort_keys, separators=(",", ":")) + + +def _b64u(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _b64u_decode(text: str) -> bytes: + """Strict inverse of `_b64u`: only the canonical unpadded encoding decodes.""" + raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + if _b64u(raw) != text: + raise ValueError("non-canonical base64url") + return raw + + +def _derive_key(secret: bytes) -> bytes: + """Stretch an operator secret (>= 32 bytes, any format) into the AES-256 key.""" + return HKDF(algorithm=SHA256(), length=32, salt=None, info=_KDF_INFO).derive(secret) + + +class AESGCMRequestStateCodec: + """Built-in codec: AES-256-GCM under key(s) derived with HKDF-SHA256. + + Tokens are encrypted, not merely signed, so clients cannot read the state. + `keys[0]` seals; all keys unseal (rotation, see `RequestStateSecurity`). + Each token carries a 4-byte non-secret key fingerprint for an O(1) ring + lookup, and the "v1." prefix and fingerprint are bound into the GCM + associated data, so a token cannot be replayed into another format version + or ring slot. Key bytes are copied at construction. + """ + + def __init__(self, keys: Sequence[bytes | bytearray | str]) -> None: + for i, key in enumerate(cast("Sequence[object]", keys)): + if not isinstance(key, bytes | bytearray | str): + # Never coerce: bytes(32) would silently build an all-zero key. + raise TypeError( + f"request-state keys must be bytes, bytearray, or str; keys[{i}] is {type(key).__name__}" + ) + material = [k.encode() if isinstance(k, str) else bytes(k) for k in keys] + if not material: + raise ValueError("AESGCMRequestStateCodec requires at least one key") + for i, k in enumerate(material): + if len(k) < 32: + raise ValueError( + f"request-state keys must be at least 32 bytes of secret randomness; " + f"keys[{i}] is {len(k)} bytes. " + 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' + ) + self._ring: dict[bytes, AESGCM] = {} + self._mint_kid = b"" + for i, secret in enumerate(material): + key = _derive_key(secret) + kid = hashlib.sha256(_KID_INFO + key).digest()[:_KID_LEN] + if kid in self._ring: + raise ValueError(f"keys[{i}] duplicates an earlier ring key") + self._ring[kid] = AESGCM(key) + if i == 0: + self._mint_kid = kid + + def seal(self, payload: bytes) -> str: + kid = self._mint_kid + nonce = os.urandom(_NONCE_LEN) + sealed = self._ring[kid].encrypt(nonce, payload, _TOKEN_PREFIX.encode() + kid) + return _TOKEN_PREFIX + _b64u(kid + nonce + sealed) + + def unseal(self, token: str) -> bytes: + if not token.startswith(_TOKEN_PREFIX): + raise InvalidRequestState("malformed") + try: + raw = _b64u_decode(token[len(_TOKEN_PREFIX) :]) + except ValueError as exc: + raise InvalidRequestState("malformed") from exc + if len(raw) < _KID_LEN + _NONCE_LEN + 16: + raise InvalidRequestState("malformed") + kid, nonce, sealed = raw[:_KID_LEN], raw[_KID_LEN : _KID_LEN + _NONCE_LEN], raw[_KID_LEN + _NONCE_LEN :] + aead = self._ring.get(kid) + if aead is None: + raise InvalidRequestState("unknown key") + try: + return aead.decrypt(nonce, sealed, _TOKEN_PREFIX.encode() + kid) + except InvalidTag: + raise InvalidRequestState("seal") from None + + +# The multi-round-trip carriers: the only methods whose results may carry `requestState`. +_MRTR_METHODS = INPUT_REQUIRED_METHODS +_ENVELOPE_VERSION = 1 +_FUTURE_SKEW = 60.0 +_PRINCIPAL_LABEL = b"mcp/request-state/principal:" + +_RoundBinding = tuple[str, str, str | None] +"""The (target, args-digest, principal) one round's envelope binds, computed once per round.""" + + +def _reject(method: str, reason: str) -> NoReturn: + """Refuse a round: frozen wire error, real reason to the server log only.""" + logger.warning("requestState rejected on %s: %s", method, reason) + raise MCPError( + code=INVALID_PARAMS, + message="Invalid or expired requestState", + data={"reason": "invalid_request_state"}, + ) + + +def _request_identity(method: str, params: Mapping[str, Any] | None) -> tuple[str, str]: + """Salient (target, args-digest) for the request a token binds to. + + Per-method allowlist, never a denylist: a future wire field cannot silently join the digest. + """ + p: Mapping[str, Any] = params or {} + args: dict[str, Any] = {} + if method == "resources/read": + target = str(p.get("uri", "")) + else: + target, args = str(p.get("name", "")), p.get("arguments") or args + return target, _b64u(hashlib.sha256(compact_json(args, sort_keys=True).encode()).digest()[:16]) + + +def _principal_claim(principal: str) -> str: + salt = os.urandom(8) + tag = hashlib.sha256(_PRINCIPAL_LABEL + salt + _principal_bytes(principal)).digest()[:16] + return _b64u(salt + tag) + + +def _principal_matches(claim: str, principal: str) -> bool: + try: + raw = _b64u_decode(claim) + except ValueError: + return False + # A wrong-length claim never matches: compare_digest handles mismatched sizes. + expected = hashlib.sha256(_PRINCIPAL_LABEL + raw[:8] + _principal_bytes(principal)).digest()[:16] + return hmac.compare_digest(raw[8:], expected) + + +def _principal_bytes(principal: str) -> bytes: + # The digest input is one-way and never decoded, so surrogatepass keeps it total. + return principal.encode("utf-8", "surrogatepass") + + +def _bound_principal( + security: RequestStateSecurity, + ctx: ServerRequestContext[Any, Any], + fail: Callable[[str], NoReturn], +) -> str | None: + """Run `bind_principal` under the deny-on-error discipline, in one place for both directions. + + `fail` converts a failure into the calling direction's wire shape: the + frozen rejection when verifying, the sanitized internal error when sealing. + """ + try: + principal = security.bind_principal(ctx) if security.bind_principal is not None else None + except Exception: # deny-on-error: a raising principal binding must fail closed + logger.exception("bind_principal raised while processing requestState on %s", ctx.method) + fail("principal binding error") + # The declared return type is str | None, but a user callback can ignore it. + if principal is not None and not isinstance(cast("object", principal), str): + fail(f"bind_principal returned {type(principal).__name__}, expected str or None") + return principal + + +class RequestStateBoundary: + """Server middleware sealing/unsealing `requestState` at the wire boundary. + + Acts only on the multi-round-trip carriers (tools/call, prompts/get, + resources/read); every other method passes through untouched. + + Inbound state is verified (codec unseal plus claims check) and replaced + with the plaintext the server minted before any interceptor or handler + runs; failure answers -32602 with the frozen message "Invalid or expired + requestState", the real reason going to the server log only. Outbound, an + `input_required` result carrying `requestState` is sealed in a fresh + claims envelope; handlers and resolvers never call the codec. + + `default_audience` seeds the audience claim when the policy sets none, and + must be stated explicitly: it is the service identity that stops state + minted by another service sharing the same keys. `MCPServer` installs this + middleware with its server name by default (under an ephemeral policy + unless `request_state_security=` supplies one); lowlevel `Server` users + append one to `server.middleware`, passing their server's name (or `None` + to deliberately leave tokens audience-free). + """ + + def __init__(self, security: RequestStateSecurity, *, default_audience: str | None) -> None: + self._security = security + self._audience = security.audience if security.audience is not None else default_audience + + async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + if ctx.method not in _MRTR_METHODS: + return await call_next(ctx) + binding: _RoundBinding | None = None + if ctx.params is not None and ctx.params.get("requestState") is not None: + # An explicit JSON null counts as absent: stripping the field is already in any client's power. + plaintext, binding = self._unseal(ctx) + ctx = replace(ctx, params={**ctx.params, "requestState": plaintext}) + result = await call_next(ctx) + return self._seal_result(ctx, result, binding) + + def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBinding]: + assert ctx.params is not None + wire = ctx.params["requestState"] + if not isinstance(wire, str): + _reject(ctx.method, "non-string requestState") + security = self._security + try: + payload = security.codec.unseal(wire) + except InvalidRequestState as exc: + _reject(ctx.method, str(exc)) + except Exception: # deny-on-error: a buggy custom codec must fail closed + logger.exception("requestState codec raised during unseal on %s", ctx.method) + _reject(ctx.method, "codec error") + try: + claims = json.loads(payload) + version, iat, exp, inner = claims["v"], claims["iat"], claims["exp"], claims["s"] + except (ValueError, KeyError, TypeError): + _reject(ctx.method, "malformed") + if version != _ENVELOPE_VERSION or not isinstance(inner, str): + _reject(ctx.method, "malformed") + now = time.time() + # Accept-conditions are stated positively so a NaN claim fails the comparison and rejects. + if not isinstance(iat, int | float) or not (iat <= now + _FUTURE_SKEW): + _reject(ctx.method, "minted in the future") + if not isinstance(exp, int | float) or not (now < exp): + _reject(ctx.method, "expired") + target, args_digest = _request_identity(ctx.method, ctx.params) + if claims.get("m") != ctx.method or claims.get("t") != target or claims.get("a") != args_digest: + _reject(ctx.method, "request binding") + if claims.get("aud") != self._audience: + _reject(ctx.method, "audience") + + def fail_verify(reason: str) -> NoReturn: + _reject(ctx.method, reason) + + principal = _bound_principal(security, ctx, fail_verify) + claim = claims.get("p") + if (claim is None) != (principal is None): + _reject(ctx.method, "principal drift") + if claim is not None and principal is not None: + if not isinstance(claim, str) or not _principal_matches(claim, principal): + _reject(ctx.method, "principal") + return inner, (target, args_digest, principal) + + def _seal_result( + self, ctx: ServerRequestContext[Any, Any], result: HandlerResult, binding: _RoundBinding | None + ) -> HandlerResult: + # Spec-path results arrive as wire mappings; a short-circuiting middleware may return a model. + if not is_input_required(result): + return result + state = result.get("requestState") if isinstance(result, Mapping) else result.request_state + if state is None: + return result + if isinstance(result, Mapping): + if not isinstance(state, str): + # Only a short-circuiting middleware can put a non-string here; nothing to seal. + return result + return {**result, "requestState": self._seal(ctx, state, binding)} + return result.model_copy(update={"request_state": self._seal(ctx, state, binding)}) + + def _seal(self, ctx: ServerRequestContext[Any, Any], state: str, binding: _RoundBinding | None = None) -> str: + security = self._security + if binding is None: + + def fail_seal(reason: str) -> NoReturn: + logger.error("refusing to seal requestState on %s: %s", ctx.method, reason) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") + + target, args_digest = _request_identity(ctx.method, ctx.params) + binding = (target, args_digest, _bound_principal(security, ctx, fail_seal)) + target, args_digest, principal = binding + now = time.time() + claims: dict[str, Any] = { + "v": _ENVELOPE_VERSION, + "iat": now, + "exp": now + security.ttl, + "m": ctx.method, + "t": target, + "a": args_digest, + "s": state, + } + if self._audience is not None: + claims["aud"] = self._audience + if principal is not None: + claims["p"] = _principal_claim(principal) + payload = compact_json(claims).encode() + try: + return security.codec.seal(payload) + except Exception: # deny-on-error: a raising custom codec must not leak its failure + logger.exception("requestState codec raised during seal on %s", ctx.method) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") from None diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/runner.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..26e8efbe57199cd9f599eda0b8fc3d1b989a7815 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/runner.py @@ -0,0 +1,872 @@ +"""`ServerRunner` - the per-connection handler kernel. + +`ServerRunner` bridges the dispatch layer (`on_request` / `on_notify`, untyped +dicts) and the user's handler layer (typed `Context`, typed params). It is a +pure kernel: it holds a pre-populated `Connection` and reads +`connection.protocol_version` / `connection.outbound` as facts. Driving a +dispatcher loop and tearing down the connection live in the free-function +drivers (`serve_connection`, `serve_loop`, `serve_dual_era_loop`, `serve_one`); +the entry constructs the `Connection`, the driver tears it down. + +`ServerRunner` holds a `Server` directly - `Server` is the registry. +""" + +from __future__ import annotations + +import contextvars +import logging +from collections.abc import AsyncIterator, Awaitable, Mapping +from contextlib import asynccontextmanager +from dataclasses import KW_ONLY, dataclass, replace +from functools import cached_property, partial +from typing import TYPE_CHECKING, Any, Generic, cast + +import anyio +import anyio.abc +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + CORE_RESULT_TYPES, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, + CacheableResult, + ErrorData, + Implementation, + InitializeRequestParams, + InitializeResult, + JSONRPCRequest, + RequestId, + RequestParams, + RequestParamsMeta, + UnsupportedProtocolVersionErrorData, +) +from mcp_types import methods as _methods +from mcp_types.version import ( + HANDSHAKE_PROTOCOL_VERSIONS, + LATEST_HANDSHAKE_VERSION, + LATEST_MODERN_VERSION, + MODERN_PROTOCOL_VERSIONS, +) +from pydantic import BaseModel, ValidationError +from typing_extensions import TypeVar + +from mcp.server.caching import apply_cache_hint +from mcp.server.connection import Connection, NotifyOnlyOutbound +from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared._context_streams import ContextReceiveStream +from mcp.shared._stream_protocols import ReadStream, WriteStream +from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest +from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data +from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage +from mcp.shared.transport_context import TransportContext + +if TYPE_CHECKING: + from mcp.server.lowlevel.server import Server + +__all__ = [ + "CallNext", + "ServerMiddleware", + "ServerRunner", + "aclose_shielded", + "modern_on_request", + "serve_connection", + "serve_dual_era_loop", + "serve_loop", + "serve_one", +] + +logger = logging.getLogger(__name__) + +LifespanT = TypeVar("LifespanT", default=Any) + + +_INIT_EXEMPT: frozenset[str] = frozenset({"ping"}) + +_EXIT_STACK_CLOSE_TIMEOUT: float = 5 +"""Bound for `aclose_shielded`'s exit-stack unwind; a hung cleanup callback +must not wedge shutdown.""" + + +def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None: + """Lift `_meta` from raw params; `None` when absent or malformed, so + context construction is independent of params validity.""" + if not params or "_meta" not in params: + return None + try: + return RequestParams.model_validate(params, by_name=False).meta + except ValidationError: + return None + + +def _dump_result(result: Any) -> dict[str, Any]: + if result is None: + return {} + if isinstance(result, ErrorData): + # ErrorData is a JSON-RPC error, not a success result. Handler returns + # already raise in `_inner`; this catches middleware returning one. + raise MCPError.from_error_data(result) + if isinstance(result, BaseModel): + return result.model_dump(by_alias=True, mode="json", exclude_none=True) + if isinstance(result, dict): + # Copied so callers own the returned dict: handlers and middleware may + # retain the object they returned, and the outbound pipeline shapes the + # wire form without reaching into anything the handler still holds. + return dict(cast(dict[str, Any], result)) + raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None") + + +async def aclose_shielded(connection: Connection) -> None: + """Unwind ``connection.exit_stack`` under a shielded, bounded scope. + + Called from a driver's ``finally``: the shield lets per-connection cleanup + callbacks run even when the driver itself is being cancelled, the + `_EXIT_STACK_CLOSE_TIMEOUT` bound stops a hung callback wedging shutdown, + and a raising callback is logged-and-swallowed so it never masks the + driver's own exception. + """ + with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as scope: + try: + await connection.exit_stack.aclose() + except Exception: + logger.exception("connection exit_stack cleanup raised") + if scope.cancelled_caught: + logger.warning( + "connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks", + _EXIT_STACK_CLOSE_TIMEOUT, + ) + + +def _apply_middleware( + middleware: ServerMiddleware[Any], call_next: CallNext, ctx: ServerRequestContext[Any, Any] +) -> Awaitable[HandlerResult]: + """Adapt one middleware to the `CallNext` shape: bind `call_next`, take + `ctx` at call time so a rewritten context flows down the chain.""" + return middleware(ctx, call_next) + + +@dataclass +class ServerRunner(Generic[LifespanT]): + """Per-connection handler kernel. One instance per client connection.""" + + server: Server[LifespanT] + connection: Connection + lifespan_state: LifespanT + _: KW_ONLY + init_options: InitializationOptions | None = None + """`InitializeResult` payload. Defaults to `server.create_initialization_options()`.""" + + @cached_property + def on_request(self) -> OnRequest: + return self._on_request + + @cached_property + def on_notify(self) -> OnNotify: + return self._on_notify + + async def _on_request( + self, + dctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> dict[str, Any]: + meta = _extract_meta(params) + version = self.connection.protocol_version + ctx = self._make_context(dctx, method, params, meta, version) + + async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: + # Read method/params off `ctx` so a middleware that rewrote them via + # `call_next(replace(ctx, ...))` reaches lookup and the handler. + method, params = ctx.method, ctx.params + # Pinned compat: spec methods are surface-validated before lookup, + # so malformed params are INVALID_PARAMS even with no handler + # registered. Custom methods miss the monolith map and fall through + # to `entry.params_type` exactly as before. + if method in _methods.SPEC_CLIENT_METHODS: + try: + _methods.validate_client_request(method, version, params) + except KeyError: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None + # TODO(L29): the 2026-07-28 spec drops the handshake; this branch and + # the gate become a per-version legacy path then. Initialize runs inline + # (read loop parked), so awaiting the peer anywhere on this path deadlocks. + if method == "initialize": + return self._serialize(method, version, self._handle_initialize(params)) + # Methods without a handler are METHOD_NOT_FOUND regardless of + # initialization state: JSON-RPC 2.0 reserves -32601 for "not + # available on this server", and clients probing a server before + # the handshake key off that code. The init gate below therefore + # only ever applies to methods the server actually serves. + entry = self.server.get_request_handler(method) + if entry is None: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) + if not self.connection.initialize_accepted and method not in _INIT_EXEMPT: + # Pinned compat: the same error shape the union validation produced. + raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") + # Absent params validate as {} (required fields still reject), so + # the handler receives the model with its defaults, never None. + typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False) + result = await entry.handler(ctx, typed_params) + if isinstance(result, ErrorData): + # Raise inside the chain so middleware observes the failure. + raise MCPError.from_error_data(result) + # Shape for the wire inside the chain so the OpenTelemetry span (the + # outermost middleware) records a failing handler return shape too. + return self._serialize(method, version, result) + + call = self._compose_server_middleware(_inner) + # `_inner` already produced the wire dict; a middleware that short-circuited + # without `call_next` is trusted to return its own well-formed result - + # including its response envelope. The pipeline never patches it up after + # the fact. + result = _dump_result(await call(ctx)) + if method == "initialize": + # Commit only on chain success, so a middleware veto leaves no state. + # Race-free: the read loop is parked until this call returns. + # TODO: this re-reads the wire `params`, so a middleware that rewrote + # `ctx.params` (or `ctx.method`, or short-circuited without `call_next`) + # can leave `connection.protocol_version` out of step with the + # `InitializeResult` `_inner` produced. Resolve when `initialize` becomes + # a built-in handler so commit and result derive from one negotiation. + self.connection.client_params, self.connection.protocol_version = self._negotiate_initialize(params) + return result + + async def _on_notify( + self, + dctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> None: + meta = _extract_meta(params) + version = self.connection.protocol_version + ctx = self._make_context(dctx, method, params, meta, version) + + async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> None: + method, params = ctx.method, ctx.params + if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS: + try: + _methods.validate_client_notification(method, version, params) + except KeyError: + logger.debug("dropped %r: not defined at %s", method, version) + return + except ValidationError: + logger.warning("dropped %r: malformed params", method) + return + if method == "notifications/initialized": + # Surface validation above already rejected a malformed body, so + # commit; fall through so a registered handler observes an + # initialized connection. + self.connection.initialized.set() + elif not self.connection.initialize_accepted: + logger.debug("dropped %s: received before initialization", method) + return + entry = self.server.get_notification_handler(method) + if entry is None: + logger.debug("no handler for notification %s", method) + return + # Same absent-params contract as requests. + try: + typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False) + except ValidationError: + logger.warning("dropped %r: malformed params", method) + return + await entry.handler(ctx, typed_params) + + call = self._compose_server_middleware(_inner) + try: + await call(ctx) + except Exception: + # A crashing handler must not cancel the dispatcher's task group; + # middleware saw the raise out of call_next() first. + logger.exception("notification handler for %r raised", method) + + def _compose_server_middleware(self, inner: CallNext) -> CallNext: + """Wrap `inner` in `Server.middleware`, outermost-first. + + Shared by `_on_request` and `_on_notify` so the same middleware chain + observes every inbound message. The composed callable takes the `ctx` + at call time, so a middleware can rewrite it for the rest of the chain. + """ + call = inner + for middleware in reversed(self.server.middleware): + call = partial(_apply_middleware, middleware, call) + return call + + def _make_context( + self, + dctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + meta: RequestParamsMeta | None, + protocol_version: str, + ) -> ServerRequestContext[LifespanT, Any]: + # TODO(L54): remove for Context rework. Reads the SHTTP per-request + # data off the raw `dctx.message_metadata` carrier; replace with the + # per-transport context once that lands. + md = dctx.message_metadata + if isinstance(md, ServerMessageMetadata): + request = md.request_context + close_sse_stream = md.close_sse_stream + close_standalone_sse_stream = md.close_standalone_sse_stream + else: + request = close_sse_stream = close_standalone_sse_stream = None + # Per-request session: `dctx` is the request-scoped channel (auto-threads + # its own request_id on streamable HTTP); the standalone channel is read + # off `connection.outbound`. `related_request_id` on the public API selects. + # `meta` carries a request's log-level opt-in for the session's log gate. A + # notification has no request to opt in (and no response stream to carry + # the log entry), so its `_meta` never opens the gate. + session = ServerSession(dctx, self.connection, request_meta=meta if dctx.request_id is not None else None) + return ServerRequestContext( + session=session, + lifespan_context=self.lifespan_state, + method=method, + params=params, + request_id=dctx.request_id, + meta=meta, + protocol_version=protocol_version, + request=request, + close_sse_stream=close_sse_stream, + close_standalone_sse_stream=close_standalone_sse_stream, + ) + + def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[str, Any]: + """Shape a handler result into its wire form: the outbound counterpart + of the inbound classification ladder. + + One pass owns the whole response envelope, in order: cache hints fill + `ttlMs`/`cacheScope` the handler left unset, core-vocabulary spec-method + results are validated and sieved by the per-version surface (a claimed + extension `resultType` shape is the extension's to own), and 2026-era + results get the `serverInfo` `_meta` stamp (spec #3002). Runs inside the + middleware chain so the OpenTelemetry span observes a failing return + shape (unsupported type, malformed spec result) as an error rather + than closing on a request that the client sees fail - and so a + middleware that short-circuits without `call_next` owns its result, + envelope included. + """ + # MRTR carve-out: `input_required` interim results, typed or mapping, never get hints. + if (hint := self.server.cache_hints.get(method)) is not None: + if isinstance(result, CacheableResult): + result = apply_cache_hint(result, hint) + elif isinstance(result, Mapping) and not _methods.is_input_required(result): + # Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence. + result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result} + dumped = _dump_result(result) + # A modern-era extension `resultType` (outside the core vocabulary) marks + # a claimed shape owned by the extension that defined it: the per-version + # surface doesn't describe it, so the sieve applies to core results only. + # Legacy connections sieve everything - claimed shapes are 2026-era + # vocabulary and cannot be delivered on a legacy wire (mirrors the + # client-side ResultClaim rule). + # TODO(L56): reject extension resultType values unless the corresponding + # extension is in this request's _meta clientCapabilities.extensions; the + # explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively. + result_type = dumped.get("resultType") + core_shape = ( + version not in MODERN_PROTOCOL_VERSIONS + or not isinstance(result_type, str) + or result_type in CORE_RESULT_TYPES + ) + if method in _methods.SPEC_CLIENT_METHODS and core_shape: + try: + dumped = _methods.serialize_server_result(method, version, dumped) + except ValidationError: + # Server bug, not client fault. Detail stays in the server log: + # pydantic messages echo the result body. + logger.exception("handler for %r returned an invalid result", method) + raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None + if version in MODERN_PROTOCOL_VERSIONS and dumped.get("resultType") is None: + # Spec 2026-07-28: `Result.resultType` is required - servers MUST + # include it (the absent-means-complete bridge is for clients of + # older servers only). The sieve guarantees it for core methods; + # this covers everything else: custom methods, extension methods, + # and empty results. + dumped["resultType"] = "complete" + return self._stamp_server_info(version, dumped) + + def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str, Any]: + """Fill the `serverInfo` `_meta` stamp on a 2026-era result (spec #3002). + + A handler-authored value wins; an explicit `null` reads as absent and + is stamped over, mirroring the request-side `clientInfo` posture (a + `null` is not a valid `Implementation`, so presence means a value). A + non-mapping `_meta` is the handler's to own, and handshake-era results + are never stamped. `result` is + pipeline-owned (`_dump_result` copies dicts; the spec-method sieve + re-dumps), but `_meta` may still be the handler's object, so the stamp + replaces it rather than writing into it. `server_info_stamp` is a + fresh dict per access, so the response never aliases server state. + """ + if version not in MODERN_PROTOCOL_VERSIONS: + return result + raw_meta = result.get("_meta") + if raw_meta is None: + result["_meta"] = {SERVER_INFO_META_KEY: self.server.server_info_stamp} + elif isinstance(raw_meta, dict): + meta = cast("dict[str, Any]", raw_meta) + if meta.get(SERVER_INFO_META_KEY) is None: + result["_meta"] = {**meta, SERVER_INFO_META_KEY: self.server.server_info_stamp} + return result + + @staticmethod + def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]: + """Validate `initialize` params and pick the protocol version.""" + init = InitializeRequestParams.model_validate(params or {}, by_name=False) + requested = init.protocol_version + negotiated = requested if requested in HANDSHAKE_PROTOCOL_VERSIONS else LATEST_HANDSHAKE_VERSION + return init, negotiated + + def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult: + """Build the `initialize` result; state commits later in `_on_request`.""" + _, negotiated = self._negotiate_initialize(params) + opts = self.init_options if self.init_options is not None else self.server.create_initialization_options() + return InitializeResult( + protocol_version=negotiated, + capabilities=opts.capabilities, + server_info=Implementation( + name=opts.server_name, + title=opts.title, + description=opts.description, + version=opts.server_version, + website_url=opts.website_url, + icons=opts.icons, + ), + instructions=opts.instructions, + ) + + +async def serve_connection( + server: Server[LifespanT], + dispatcher: Dispatcher[Any], + *, + connection: Connection, + lifespan_state: LifespanT, + init_options: InitializationOptions | None = None, + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, +) -> None: + """Drive ``dispatcher`` until the underlying channel closes. + + The loop-mode driver: builds the kernel, hands `on_request`/`on_notify` + to `dispatcher.run()`, and tears down `connection.exit_stack` (shielded) + on the way out. The entry constructs the `Connection`; this only consumes + it. + """ + runner = ServerRunner(server, connection, lifespan_state, init_options=init_options) + try: + await dispatcher.run(runner.on_request, runner.on_notify, task_status=task_status) + finally: + await aclose_shielded(connection) + + +async def serve_loop( + server: Server[LifespanT], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + lifespan_state: LifespanT, + session_id: str | None = None, + init_options: InitializationOptions | None = None, + raise_exceptions: bool = False, +) -> None: + """Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes. + + Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to + `serve_connection`. The streamable-HTTP manager (which owns its lifespan + and serves the modern era on the single-exchange entry instead) calls + this; `Server.run` drives `serve_dual_era_loop`, which extends the same + dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with + era routing. + """ + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, + write_stream, + raise_handler_exceptions=raise_exceptions, + # Handle `initialize` inline so a client that pipelines it with the + # next request (spec: SHOULD NOT, not MUST NOT) sees the initialized + # state instead of failing the init-gate. + inline_methods=frozenset({"initialize"}), + ) + connection = Connection.for_loop(dispatcher, session_id=session_id) + await serve_connection( + server, dispatcher, connection=connection, lifespan_state=lifespan_state, init_options=init_options + ) + + +def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool: + """Whether `params._meta` carries the reserved protocol-version key. + + The `io.modelcontextprotocol/protocolVersion` key exists only in + 2026-07-28+ envelopes and its prefix is spec-reserved, so legacy traffic + never mints it (a bare `_meta` is not evidence - legacy requests carry + `progressToken` there). The version key alone is the signal, not the full + required pair, so a half-built envelope still routes modern and gets the + classifier's INVALID_PARAMS naming the missing key. + """ + if not params: + return False + meta = params.get("_meta") + return isinstance(meta, Mapping) and PROTOCOL_VERSION_META_KEY in meta + + +def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, Any]: + """Error data for an `initialize` arriving on a modern-locked connection. + + The typed -32022 payload when the client's proposed version is parseable; + otherwise just the supported list (the point is naming what we serve). + """ + requested = (params or {}).get("protocolVersion") + if isinstance(requested, str): + return UnsupportedProtocolVersionErrorData( + supported=list(MODERN_PROTOCOL_VERSIONS), requested=requested + ).model_dump(mode="json") + return {"supported": list(MODERN_PROTOCOL_VERSIONS)} + + +def modern_error_data(exc: Exception) -> ErrorData: + """Map a modern request's handler exception to its wire `ErrorData`. + + The exception-to-wire fact shared by the modern entries (the + single-exchange HTTP path and the dual-era stream loop), so an identical + modern request fails identically on every transport: `MCPError` and + `ValidationError` map via the shared `handler_exception_to_error_data` + ladder; anything else is logged server-side and surfaced as a generic + INTERNAL_ERROR so handler internals never reach the wire. + """ + error = handler_exception_to_error_data(exc) + if error is not None: + return error + logger.exception("modern request handler raised") + return ErrorData(code=INTERNAL_ERROR, message="Internal server error") + + +@dataclass +class _NoServerRequestsDispatchContext: + """Delegating `DispatchContext` that refuses server-initiated requests. + + Wraps the loop dispatcher's per-message context for modern-era dispatch: + the modern protocol forbids server-initiated JSON-RPC requests, so + `send_raw_request` refuses while notifications and progress still ride + the duplex pipe. + """ + + _inner: DispatchContext[TransportContext] + + @property + def transport(self) -> TransportContext: + # Mask the per-message flag so the transport metadata agrees with this + # wrapper's denial: the modern HTTP entry builds its context with + # can_send_request=False, while the loop's default builder says True. + transport = self._inner.transport + return replace(transport, can_send_request=False) if transport.can_send_request else transport + + @property + def can_send_request(self) -> bool: + return False + + @property + def request_id(self) -> RequestId | None: + return self._inner.request_id + + @property + def message_metadata(self) -> MessageMetadata: + return self._inner.message_metadata + + @property + def cancel_requested(self) -> anyio.Event: + return self._inner.cancel_requested + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + await self._inner.notify(method, params, opts) + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + await self._inner.progress(progress, total, message) + + +async def serve_dual_era_loop( + server: Server[LifespanT], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + lifespan_state: LifespanT, + session_id: str | None = None, + init_options: InitializationOptions | None = None, + raise_exceptions: bool = False, +) -> None: + """Drive `server` over a duplex stream pair, in the era the client opens with. + + The client's first request decides the connection's protocol era, once: + a request carrying the 2026-07-28 per-request `_meta` envelope opens a + modern connection, and anything else - the `initialize` handshake, which + does not exist at 2026 versions even when a client stamps the envelope on + it - opens a legacy one. The deciding frame is replayed into the chosen + serving loop along with everything the client sent before it. A later + claim from the other era is refused: `initialize` on a modern connection + gets UNSUPPORTED_PROTOCOL_VERSION naming the served versions, and an + enveloped request on a legacy connection gets INVALID_REQUEST. + """ + # This loop owns both streams from the moment it is called, so the write + # stream is closed even if the client leaves before sending any request. + try: + async with _replay_from_opening_request(read_stream) as (opening, replayed): + opens_modern = ( + opening is not None and opening.method != "initialize" and _has_modern_envelope(opening.params) + ) + if opens_modern: + await _serve_modern_stream( + server, replayed, write_stream, lifespan_state=lifespan_state, raise_exceptions=raise_exceptions + ) + else: + await _serve_legacy_stream( + server, + replayed, + write_stream, + lifespan_state=lifespan_state, + session_id=session_id, + init_options=init_options, + raise_exceptions=raise_exceptions, + ) + finally: + await write_stream.aclose() + + +_PRE_REQUEST_REPLAY_LIMIT: int = 8 +"""How many frames arriving ahead of the client's first request are kept +for the chosen era's loop (a bare `notifications/initialized` is the one that +matters); further ones are dropped and never decide the era.""" + + +def _sender_context(stream: ReadStream[Any]) -> contextvars.Context: + """The per-message sender context a context-aware stream carries, else the current one.""" + ctx = getattr(stream, "last_context", None) + return ctx if ctx is not None else contextvars.copy_context() + + +@asynccontextmanager +async def _replay_from_opening_request( + read_stream: ReadStream[SessionMessage | Exception], +) -> AsyncIterator[tuple[JSONRPCRequest | None, ReadStream[SessionMessage | Exception]]]: + """Peek at the client's first request without consuming it. + + Yields that request together with a stream that replays it - preceded by + up to `_PRE_REQUEST_REPLAY_LIMIT` earlier frames - and relays the rest of + `read_stream` behind it, sender contexts included. The request is `None` + if the channel closes before one arrives. + """ + lead: list[tuple[contextvars.Context, SessionMessage | Exception]] = [] + opening_request: JSONRPCRequest | None = None + replay_send, replay_receive = anyio.create_memory_object_stream[ + tuple[contextvars.Context, SessionMessage | Exception] + ]() + replayed = ContextReceiveStream(replay_receive) + + async def replay_then_relay() -> None: + async with replay_send: + for envelope in lead: + await replay_send.send(envelope) + try: + async for item in read_stream: + await replay_send.send((_sender_context(read_stream), item)) + except anyio.ClosedResourceError: + # Receive end closed under us (stateless SHTTP teardown); same as EOF. + logger.debug("read stream closed by transport; treating as EOF") + + # This helper takes ownership of `read_stream` from the serving loop, so + # every exit - including cancellation while awaiting the first request - + # closes it and the replay channel. + try: + try: + async for item in read_stream: + if isinstance(item, SessionMessage) and isinstance(item.message, JSONRPCRequest): + opening_request = item.message + elif len(lead) >= _PRE_REQUEST_REPLAY_LIMIT: + logger.debug("dropped a frame received before the first request: %r", item) + continue + lead.append((_sender_context(read_stream), item)) + if opening_request is not None: + break + except anyio.ClosedResourceError: + # Receive end closed under us (stateless SHTTP teardown); same as EOF. + logger.debug("read stream closed by transport; treating as EOF") + async with anyio.create_task_group() as tg: + tg.start_soon(replay_then_relay) + yield opening_request, replayed + tg.cancel_scope.cancel() + finally: + await read_stream.aclose() + replay_send.close() + replay_receive.close() + + +async def _serve_legacy_stream( + server: Server[LifespanT], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + lifespan_state: LifespanT, + session_id: str | None, + init_options: InitializationOptions | None, + raise_exceptions: bool, +) -> None: + """Serve a 2025 handshake connection; enveloped requests are refused.""" + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, + write_stream, + raise_handler_exceptions=raise_exceptions, + # `initialize` inline for the same pipelining reason as `serve_loop`. + inline_methods=frozenset({"initialize"}), + ) + connection = Connection.for_loop(dispatcher, session_id=session_id) + runner = ServerRunner(server, connection, lifespan_state, init_options=init_options) + + async def on_request( + dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + if method != "initialize" and _has_modern_envelope(params): + raise MCPError( + code=INVALID_REQUEST, + message="this connection serves the handshake protocol era; " + "requests carrying the 2026-07-28 envelope are not accepted on it", + ) + return await runner.on_request(dctx, method, params) + + try: + await dispatcher.run(on_request, runner.on_notify) + finally: + await aclose_shielded(connection) + + +async def _serve_modern_stream( + server: Server[LifespanT], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + lifespan_state: LifespanT, + raise_exceptions: bool, +) -> None: + """Serve a 2026-07-28 connection: every request carries its own envelope.""" + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, write_stream, raise_handler_exceptions=raise_exceptions + ) + outbound = NotifyOnlyOutbound(dispatcher) + + async def on_request( + dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + if method == "initialize": + raise MCPError( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="connection is serving the 2026-07-28 protocol; the initialize handshake is not accepted", + data=_initialize_after_modern_data(params), + ) + route = classify_inbound_request({"method": method, "params": params}) + if isinstance(route, InboundLadderRejection): + raise MCPError(code=route.code, message=route.message, data=route.data) + connection = Connection.from_envelope( + route.protocol_version, route.client_info, route.client_capabilities, outbound=outbound + ) + try: + return await serve_one( + server, + _NoServerRequestsDispatchContext(dctx), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) + except (MCPError, ValidationError): + # The dispatcher's shared ladder maps these to the wire error. + raise + except Exception as exc: + if raise_exceptions: + raise + error = modern_error_data(exc) + raise MCPError(code=error.code, message=error.message, data=error.data) from exc + + async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: + # The envelope is request-only, so a notification runs at the latest + # served version; the modern protocol has nothing version-specific here. + connection = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=outbound) + notify_runner = ServerRunner(server, connection, lifespan_state) + try: + await notify_runner.on_notify(_NoServerRequestsDispatchContext(dctx), method, params) + finally: + await aclose_shielded(connection) + + await dispatcher.run(on_request, on_notify) + + +async def serve_one( + server: Server[LifespanT], + dctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + *, + connection: Connection, + lifespan_state: LifespanT, +) -> dict[str, Any]: + """Handle a single request ``(method, params)`` and return its result dict. + + The single-exchange driver: builds the kernel, runs `on_request` once under + `dctx`, and tears down `connection.exit_stack` (shielded) on the way out. + The entry constructs the (born-ready) `Connection` and the `dctx`; this + only consumes them. + + Raises whatever the handler chain raises (`MCPError` / `ValidationError` / + unmapped); callers own the exception-to-wire mapping. + """ + runner = ServerRunner(server, connection, lifespan_state) + try: + return await runner.on_request(dctx, method, params) + finally: + await aclose_shielded(connection) + + +def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> OnRequest: + """Return an `OnRequest` callback that serves each call via `serve_one` with a fresh per-request `Connection`. + + Wire this into the server side of a `DirectDispatcher` peer-pair to drive an + in-process server on the modern per-request-envelope path (each request + carries protocol version, client info, and capabilities in `params._meta`; + no `initialize` handshake). The dispatch context is wrapped in the + server-requests denial, so the modern prohibition on server-initiated + JSON-RPC requests holds on this entry like on the others. Like `serve_one`, + this raises whatever the handler chain raises - the dispatcher owns the + exception-to-error mapping. + """ + + async def handle( + dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + meta = (params or {}).get("_meta", {}) + connection = Connection.from_envelope( + meta.get(PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION), + meta.get(CLIENT_INFO_META_KEY), + meta.get(CLIENT_CAPABILITIES_META_KEY), + ) + return await serve_one( + server, + _NoServerRequestsDispatchContext(dctx), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) + + return handle diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/session.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/session.py new file mode 100644 index 0000000000000000000000000000000000000000..bb446415e8b879d957b4417d992e406d4842b192 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/session.py @@ -0,0 +1,489 @@ +"""`ServerSession`: server-to-client requests and notifications. + +A per-request proxy built by the kernel for each inbound request. Exposes the +request-scoped outbound channel and the connection's standalone channel. +Handlers reach it as `ctx.session` and use the typed helpers (`elicit_form`, +`send_log_message`, ...) to call back to the client. +""" + +import logging +from typing import Any, TypeVar, overload + +import mcp_types as types +from mcp_types import methods as _methods +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from pydantic import AnyUrl, BaseModel +from typing_extensions import deprecated + +from mcp.server.connection import Connection, allowed_log_levels +from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages, wants_sampling_tools +from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT +from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.shared.message import ServerMessageMetadata + +__all__ = ["ServerSession"] + +logger = logging.getLogger(__name__) +# `send_log_message`'s `logger` parameter (public API, the spec's logger-name +# field) shadows the module logger inside that method; this alias keeps it +# reachable there. +_logger = logger + +ResultT = TypeVar("ResultT", bound=BaseModel) + + +class ServerSession: + """Per-request proxy for server-to-client requests and notifications. + + Built once per inbound request by the kernel's `_make_context`. Holds two + `Outbound` channels: the request-scoped one (the per-request + `DispatchContext`, which on streamable HTTP routes onto the originating + POST's response stream) and the connection's standalone channel + (`connection.outbound`). `related_request_id` on the public methods is the + selector โ€” present means request-scoped, absent means standalone โ€” and + never crosses the `Outbound` Protocol. + """ + + def __init__( + self, + request_outbound: DispatchContext[Any], + connection: Connection, + *, + request_meta: types.RequestParamsMeta | None = None, + ) -> None: + self._request_outbound = request_outbound + self._connection = connection + # The per-request log-delivery contract, fixed at construction: on + # 2026-07-28+ the inbound request's `_meta` log-level opt-in decides + # which `notifications/message` levels may be sent for this request + # (and they ride this request's stream only); on handshake versions + # every level may be sent (`logging/setLevel`-era semantics). + self._log_is_request_scoped = connection.protocol_version in MODERN_PROTOCOL_VERSIONS + self._allowed_log_levels = allowed_log_levels(connection.protocol_version, request_meta) + + @property + def client_params(self) -> types.InitializeRequestParams | None: + """The client's `initialize` request params; `None` when no client info was supplied.""" + return self._connection.client_params + + @property + def client_capabilities(self) -> types.ClientCapabilities | None: + """The capabilities the client declared; `None` when none were declared. + + Prefer this over `client_params.capabilities`: on 2026-07-28+ the + request envelope declares capabilities while client info stays + optional, so capabilities can be present without `client_params`. + """ + return self._connection.client_capabilities + + @property + def can_send_request(self) -> bool: + """Whether this request's channel can currently deliver a server-initiated request.""" + return self._request_outbound.can_send_request + + @property + def protocol_version(self) -> str: + """The protocol version this connection speaks. + + Populated at `Connection` construction and overwritten once the + handshake commits on the loop path; never `None`. + """ + return self._connection.protocol_version + + async def send_request( + self, + request: types.ServerRequest, + result_type: type[ResultT], + request_read_timeout_seconds: float | None = None, + metadata: ServerMessageMetadata | None = None, + progress_callback: ProgressFnT | None = None, + ) -> ResultT: + """Send a typed server-to-client request and validate the result. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: The connection has no back-channel for + server-initiated requests (raised by the held `Outbound`). + pydantic.ValidationError: The peer's result does not match `result_type`. + """ + related = metadata.related_request_id if metadata is not None else None + channel = self._request_outbound if related is not None else self._connection.outbound + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {} + if request_read_timeout_seconds is not None: + opts["timeout"] = request_read_timeout_seconds + if progress_callback is not None: + opts["on_progress"] = progress_callback + result = await channel.send_raw_request(data["method"], data.get("params"), opts or None) + try: + _methods.validate_client_result(request.method, self.protocol_version, result) + except KeyError: + pass + return result_type.model_validate(result, by_name=False) + + async def send_notification( + self, + notification: types.ServerNotification, + related_request_id: types.RequestId | None = None, + ) -> None: + """Send a typed server-to-client notification.""" + await self._notify(notification, request_scoped=related_request_id is not None) + + async def _notify(self, notification: types.ServerNotification, *, request_scoped: bool) -> None: + channel = self._request_outbound if request_scoped else self._connection.outbound + data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) + await channel.notify(data["method"], data.get("params")) + + def check_client_capability(self, capability: types.ClientCapabilities) -> bool: + """Check if the client supports a specific capability.""" + return self._connection.check_capability(capability) + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def send_log_message( + self, + level: types.LoggingLevel, + data: Any, + logger: str | None = None, + related_request_id: types.RequestId | None = None, + ) -> None: + """Send a log message notification. + + On 2026-07-28+ delivery is a per-request opt-in: nothing is sent + unless this request's `_meta` carried the reserved log-level key, and + entries below the requested level are dropped (debug-logged). What is + sent rides this request's stream regardless of `related_request_id` - + the spec forbids `notifications/message` on any stream but the one + carrying the response. Handshake versions send unconditionally on the + channel `related_request_id` selects, as before. + """ + if level not in self._allowed_log_levels: + _logger.debug("dropped notifications/message at %r: not opted in at that level on this request", level) + return + await self._notify( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams( + level=level, + data=data, + logger=logger, + ), + ), + request_scoped=self._log_is_request_scoped or related_request_id is not None, + ) + + async def send_resource_updated(self, uri: str | AnyUrl) -> None: + """Send a resource updated notification.""" + await self.send_notification( + types.ResourceUpdatedNotification( + params=types.ResourceUpdatedNotificationParams(uri=str(uri)), + ) + ) + + @overload + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def create_message( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: None = None, + tool_choice: None = None, + related_request_id: types.RequestId | None = None, + ) -> types.CreateMessageResult: + """Overload: Without tools or tool_choice, returns single content.""" + ... + + @overload + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def create_message( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: list[types.Tool], + tool_choice: types.ToolChoice | None = None, + related_request_id: types.RequestId | None = None, + ) -> types.CreateMessageResultWithTools: + """Overload: With tools, returns array-capable content.""" + ... + + @overload + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def create_message( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: list[types.Tool] | None = None, + tool_choice: types.ToolChoice, + related_request_id: types.RequestId | None = None, + ) -> types.CreateMessageResultWithTools: + """Overload: With tool_choice, returns array-capable content.""" + ... + + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def create_message( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: list[types.Tool] | None = None, + tool_choice: types.ToolChoice | None = None, + related_request_id: types.RequestId | None = None, + ) -> types.CreateMessageResult | types.CreateMessageResultWithTools: + """Send a sampling/create_message request. + + Args: + messages: The conversation messages to send. + max_tokens: Maximum number of tokens to generate. + system_prompt: Optional system prompt. + include_context: Optional context inclusion setting. + Should only be set to "thisServer" or "allServers" + if the client has sampling.context capability. + temperature: Optional sampling temperature. + stop_sequences: Optional stop sequences. + metadata: Optional metadata to pass through to the LLM provider. + model_preferences: Optional model selection preferences. + tools: Optional list of tools the LLM can use during sampling. + Requires client to have sampling.tools capability. + tool_choice: Optional control over tool usage behavior. + Requires client to have sampling.tools capability. + related_request_id: Optional ID of a related request. + + Returns: + The sampling result from the client. + + Raises: + MCPError: If tools are provided but client doesn't support them. + ValueError: If tool_use or tool_result message structure is invalid. + NoBackChannelError: The connection has no back-channel for + server-initiated requests. + """ + validate_sampling_tools(self.client_capabilities, tools, tool_choice) + validate_tool_use_result_messages(messages) + + request = types.CreateMessageRequest( + params=types.CreateMessageRequestParams( + messages=messages, + system_prompt=system_prompt, + include_context=include_context, + temperature=temperature, + max_tokens=max_tokens, + stop_sequences=stop_sequences, + metadata=metadata, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + ), + ) + metadata_obj = ServerMessageMetadata(related_request_id=related_request_id) + + if wants_sampling_tools(tools, tool_choice): + return await self.send_request( + request=request, + result_type=types.CreateMessageResultWithTools, + metadata=metadata_obj, + ) + return await self.send_request( + request=request, + result_type=types.CreateMessageResult, + metadata=metadata_obj, + ) + + @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def list_roots(self) -> types.ListRootsResult: + """Send a roots/list request. + + Raises: + NoBackChannelError: The connection has no back-channel for + server-initiated requests. + """ + return await self.send_request( + types.ListRootsRequest(), + types.ListRootsResult, + ) + + async def elicit( + self, + message: str, + requested_schema: types.ElicitRequestedSchema, + related_request_id: types.RequestId | None = None, + ) -> types.ElicitResult: + """Send a form mode elicitation/create request. + + Args: + message: The message to present to the user. + requested_schema: Schema defining the expected response structure. + related_request_id: Optional ID of the request that triggered this elicitation. + + Returns: + The client's response. + + Note: + This method is deprecated in favor of elicit_form(). It remains for + backward compatibility but new code should use elicit_form(). + """ + return await self.elicit_form(message, requested_schema, related_request_id) + + async def elicit_form( + self, + message: str, + requested_schema: types.ElicitRequestedSchema, + related_request_id: types.RequestId | None = None, + ) -> types.ElicitResult: + """Send a form mode elicitation/create request. + + Args: + message: The message to present to the user. + requested_schema: Schema defining the expected response structure. + related_request_id: Optional ID of the request that triggered this elicitation. + + Returns: + The client's response with form data. + + Raises: + NoBackChannelError: The connection has no back-channel for + server-initiated requests. + """ + return await self.send_request( + types.ElicitRequest( + params=types.ElicitRequestFormParams( + message=message, + requested_schema=requested_schema, + ), + ), + types.ElicitResult, + metadata=ServerMessageMetadata(related_request_id=related_request_id), + ) + + async def elicit_url( + self, + message: str, + url: str, + elicitation_id: str, + related_request_id: types.RequestId | None = None, + ) -> types.ElicitResult: + """Send a URL mode elicitation/create request. + + This directs the user to an external URL for out-of-band interactions + like OAuth flows, credential collection, or payment processing. + + Args: + message: Human-readable explanation of why the interaction is needed. + url: The URL the user should navigate to. + elicitation_id: Unique identifier for tracking this elicitation. + related_request_id: Optional ID of the request that triggered this elicitation. + + Returns: + The client's response indicating acceptance, decline, or cancellation. + + Raises: + NoBackChannelError: The connection has no back-channel for + server-initiated requests. + """ + return await self.send_request( + types.ElicitRequest( + params=types.ElicitRequestURLParams( + message=message, + url=url, + elicitation_id=elicitation_id, + ), + ), + types.ElicitResult, + metadata=ServerMessageMetadata(related_request_id=related_request_id), + ) + + async def send_ping(self) -> types.EmptyResult: + """Send a ping request.""" + return await self.send_request( + types.PingRequest(), + types.EmptyResult, + ) + + async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + """Report progress for the inbound request this session is scoped to. + + A no-op when the caller did not request progress. Dispatcher-agnostic: + on JSON-RPC the held `DispatchContext` emits ``notifications/progress`` + against the caller's token; on the in-process direct dispatcher it + invokes the caller's callback directly. + """ + await self._request_outbound.progress(progress, total, message) + + async def send_progress_notification( + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, + related_request_id: str | None = None, + ) -> None: + """Send a progress notification.""" + await self.send_notification( + types.ProgressNotification( + params=types.ProgressNotificationParams( + progress_token=progress_token, + progress=progress, + total=total, + message=message, + ), + ), + related_request_id, + ) + + async def send_resource_list_changed(self) -> None: + """Send a resource list changed notification.""" + await self.send_notification(types.ResourceListChangedNotification()) + + async def send_tool_list_changed(self) -> None: + """Send a tool list changed notification.""" + await self.send_notification(types.ToolListChangedNotification()) + + async def send_prompt_list_changed(self) -> None: + """Send a prompt list changed notification.""" + await self.send_notification(types.PromptListChangedNotification()) + + async def send_elicit_complete( + self, + elicitation_id: str, + related_request_id: types.RequestId | None = None, + ) -> None: + """Send an elicitation completion notification. + + This should be sent when a URL mode elicitation has been completed + out-of-band to inform the client that it may retry any requests + that were waiting for this elicitation. + + Args: + elicitation_id: The unique identifier of the completed elicitation + related_request_id: Optional ID of the request that triggered this notification + """ + await self.send_notification( + types.ElicitCompleteNotification( + params=types.ElicitCompleteNotificationParams(elicitation_id=elicitation_id) + ), + related_request_id, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/sse.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/sse.py new file mode 100644 index 0000000000000000000000000000000000000000..4d02fc4a73acb3c73ad68c02254986c4f3b4c30b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/sse.py @@ -0,0 +1,262 @@ +"""SSE Server Transport Module + +This module implements a Server-Sent Events (SSE) transport layer for MCP servers. + +Example: + ```python + # Create an SSE transport at an endpoint + sse = SseServerTransport("/messages/") + + # Create Starlette routes for SSE and message handling + routes = [ + Route("/sse", endpoint=handle_sse, methods=["GET"]), + Mount("/messages/", app=sse.handle_post_message), + ] + + # Define handler functions + async def handle_sse(request): + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as streams: + await app.run( + streams[0], streams[1], app.create_initialization_options() + ) + # Return empty response to avoid NoneType error + return Response() + + # Create and run Starlette app + starlette_app = Starlette(routes=routes) + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + ``` + +Note: The handle_sse function must return a Response to avoid a +"TypeError: 'NoneType' object is not callable" error when client disconnects. The example above returns +an empty Response() after the SSE connection ends to fix this. + +See SseServerTransport class documentation for more details. +""" + +import logging +from contextlib import asynccontextmanager +from typing import Any +from urllib.parse import quote +from uuid import UUID, uuid4 + +import anyio +import mcp_types as types +from pydantic import ValidationError +from sse_starlette import EventSourceResponse +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Receive, Scope, Send + +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context +from mcp.server.transport_security import ( + TransportSecurityMiddleware, + TransportSecuritySettings, +) +from mcp.shared._context_streams import ContextSendStream, create_context_streams +from mcp.shared.message import ServerMessageMetadata, SessionMessage + +logger = logging.getLogger(__name__) + + +class SseServerTransport: + """SSE server transport for MCP. This class provides two ASGI applications, + suitable for use with a framework like Starlette and a server like Hypercorn: + + 1. connect_sse() is an ASGI application which receives incoming GET requests, + and sets up a new SSE stream to send server messages to the client. + 2. handle_post_message() is an ASGI application which receives incoming POST + requests, which should contain client messages that link to a + previously-established SSE session. + """ + + _endpoint: str + _read_stream_writers: dict[UUID, ContextSendStream[SessionMessage | Exception]] + # Identity of the credential that created each session; requests for a + # session must present the same credential. + _session_owners: dict[UUID, AuthorizationContext] + _security: TransportSecurityMiddleware + + def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None: + """Creates a new SSE server transport, which will direct the client to POST + messages to the relative path given. + + Args: + endpoint: A relative path where messages should be posted + (e.g., "/messages/"). + security_settings: Optional security settings for DNS rebinding protection. + + Note: + We use relative paths instead of full URLs for several reasons: + 1. Security: Prevents cross-origin requests by ensuring clients only connect + to the same origin they established the SSE connection with + 2. Flexibility: The server can be mounted at any path without needing to + know its full URL + 3. Portability: The same endpoint configuration works across different + environments (development, staging, production) + + Raises: + ValueError: If the endpoint is a full URL instead of a relative path + """ + + super().__init__() + + # Validate that endpoint is a relative path and not a full URL + if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint: + raise ValueError( + f"Given endpoint: {endpoint} is not a relative path (e.g., '/messages/'), " + "expecting a relative path (e.g., '/messages/')." + ) + + # Ensure endpoint starts with a forward slash + if not endpoint.startswith("/"): + endpoint = "/" + endpoint + + self._endpoint = endpoint + self._read_stream_writers = {} + self._session_owners = {} + self._security = TransportSecurityMiddleware(security_settings) + logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}") + + @asynccontextmanager + async def connect_sse(self, scope: Scope, receive: Receive, send: Send): + if scope["type"] != "http": + logger.error("connect_sse received non-HTTP request") + raise ValueError("connect_sse can only handle HTTP requests") + + # Validate request headers for DNS rebinding protection + request = Request(scope, receive) + error_response = await self._security.validate_request(request, is_post=False) + if error_response: + await error_response(scope, receive, send) + raise ValueError("Request validation failed") + + logger.debug("Setting up SSE connection") + + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) + + session_id = uuid4() + user = scope.get("user") + if isinstance(user, AuthenticatedUser): + self._session_owners[session_id] = authorization_context(user) + self._read_stream_writers[session_id] = read_stream_writer + logger.debug(f"Created new session with ID: {session_id}") + + # Determine the full path for the message endpoint to be sent to the client. + # scope['root_path'] is the prefix where the current Starlette app + # instance is mounted. + # e.g., "" if top-level, or "/api_prefix" if mounted under "/api_prefix". + root_path = scope.get("root_path", "") + + # self._endpoint is the path *within* this app, e.g., "/messages". + # Concatenating them gives the full absolute path from the server root. + # e.g., "" + "/messages" -> "/messages" + # e.g., "/api_prefix" + "/messages" -> "/api_prefix/messages" + full_message_path_for_client = root_path.rstrip("/") + self._endpoint + + # This is the URI (path + query) the client will use to POST messages. + client_post_uri_data = f"{quote(full_message_path_for_client)}?session_id={session_id.hex}" + + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, Any]](0) + + async def sse_writer(): + logger.debug("Starting SSE writer") + async with sse_stream_writer, write_stream_reader: + await sse_stream_writer.send({"event": "endpoint", "data": client_post_uri_data}) + logger.debug(f"Sent endpoint event: {client_post_uri_data}") + + async for session_message in write_stream_reader: + logger.debug(f"Sending message via SSE: {session_message}") + await sse_stream_writer.send( + { + "event": "message", + "data": session_message.message.model_dump_json(by_alias=True, exclude_unset=True), + } + ) + + try: + async with anyio.create_task_group() as tg: + + async def response_wrapper(scope: Scope, receive: Receive, send: Send): + """The EventSourceResponse returning signals a client close / disconnect. + In this case we close our side of the streams to signal the client that + the connection has been closed. + """ + await EventSourceResponse(content=sse_stream_reader, data_sender_callable=sse_writer)( + scope, receive, send + ) + await read_stream_writer.aclose() + await write_stream_reader.aclose() + await sse_stream_reader.aclose() + logging.debug(f"Client session disconnected {session_id}") + + logger.debug("Starting SSE response task") + tg.start_soon(response_wrapper, scope, receive, send) + + logger.debug("Yielding read and write streams") + yield (read_stream, write_stream) + finally: + self._read_stream_writers.pop(session_id, None) + self._session_owners.pop(session_id, None) + + async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: + logger.debug("Handling POST message") + request = Request(scope, receive) + + # Validate request headers for DNS rebinding protection + error_response = await self._security.validate_request(request, is_post=True) + if error_response: + return await error_response(scope, receive, send) + + session_id_param = request.query_params.get("session_id") + if session_id_param is None: + logger.warning("Received request without session_id") + response = Response("session_id is required", status_code=400) + return await response(scope, receive, send) + + try: + session_id = UUID(hex=session_id_param) + logger.debug(f"Parsed session ID: {session_id}") + except ValueError: + logger.warning(f"Received invalid session ID: {session_id_param}") + response = Response("Invalid session ID", status_code=400) + return await response(scope, receive, send) + + writer = self._read_stream_writers.get(session_id) + if not writer: + logger.warning(f"Could not find session for ID: {session_id}") + response = Response("Could not find session", status_code=404) + return await response(scope, receive, send) + + user = scope.get("user") + requestor = authorization_context(user) if isinstance(user, AuthenticatedUser) else None + if requestor != self._session_owners.get(session_id): + # A session can only be used with the credential that created it. + # Respond exactly as if the session did not exist. + logger.warning("Rejecting message for session %s: credential does not match", session_id) + response = Response("Could not find session", status_code=404) + return await response(scope, receive, send) + + body = await request.body() + logger.debug(f"Received JSON: {body}") + + try: + message = types.jsonrpc_message_adapter.validate_json(body, by_name=False) + logger.debug(f"Validated client message: {message}") + except ValidationError as err: + logger.exception("Failed to parse message") + response = Response("Could not parse message", status_code=400) + await response(scope, receive, send) + await writer.send(err) + return + + # Pass the ASGI scope for framework-agnostic access to request data + metadata = ServerMessageMetadata(request_context=request) + session_message = SessionMessage(message, metadata=metadata) + logger.debug(f"Sending session message to writer: {session_message}") + response = Response("Accepted", status_code=202) + await response(scope, receive, send) + await writer.send(session_message) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/stdio.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/stdio.py new file mode 100644 index 0000000000000000000000000000000000000000..de8bbae5f18f31de5ee419776f64e23ea4d92d4c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/stdio.py @@ -0,0 +1,217 @@ +"""Stdio server transport for MCP. + +Example: + ```python + async def run_server(): + async with stdio_server() as (read_stream, write_stream): + server = await create_my_server() + await server.run(read_stream, write_stream, init_options) + + anyio.run(run_server) + ``` +""" + +import os +import sys +import threading +from collections.abc import Callable +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass +from io import TextIOWrapper +from typing import BinaryIO, Literal, TextIO + +import anyio +import anyio.lowlevel +import mcp_types as types + +from mcp.os.win32.utilities import rebind_std_handle_to_fd +from mcp.shared._context_streams import create_context_streams +from mcp.shared.message import SessionMessage + +if sys.platform != "win32": # pragma: no branch + import fcntl # pragma: lax no cover - POSIX-only line, uncovered on Windows runners + +# Stream-claim contract (design and attack log in PR #3117): +# - _claims is the single authority for who owns fd 0/1; mutated only under the +# lock, only by acquire's insert and release's deregister. +# - private_fd is recorded the instant the wire duplicate exists, before fd is +# ever moved, and is never closed while the claim is registered. +# - Release deregisters only after dup2(private_fd, fd) restores the wire; a +# failed release keeps the claim, so successors are refused, never fed a +# diverted descriptor. Every failure lands on that safe side. +_claims: dict[int, "_StreamClaim"] = {} +_claims_lock = threading.Lock() + + +@dataclass +class _StreamClaim: + fd: int + private_fd: int | None = None + + +class _UnownedTextWrapper(TextIOWrapper): + """Text layer whose close never closes the underlying buffer. + + The buffer is not the transport's to close: in the in-place paths it is the + sys stream's own buffer, and closing it at garbage collection destroyed + sys.stdout for the rest of the process (issue #1933). + """ + + def close(self) -> None: + with suppress(ValueError): + self.detach() + + +def _is_backed_by_fd(stream: TextIO, fd: int) -> bool: + try: + return stream.buffer.fileno() == fd + except (AttributeError, OSError, ValueError): + return False + + +def _dup_above_std(fd: int) -> int: + """Duplicate fd onto a descriptor that cannot land in the standard range.""" + if sys.platform == "win32": # pragma: no cover + duplicate = os.dup(fd) + if duplicate <= 2: + os.close(duplicate) + raise OSError(f"duplicate of fd {fd} landed in the standard range") + return duplicate + return fcntl.fcntl(fd, fcntl.F_DUPFD_CLOEXEC, 3) # pragma: lax no cover - POSIX-only + + +def _open_stdin_diversion() -> int: + return os.open(os.devnull, os.O_RDONLY) + + +def _open_stdout_diversion() -> int: + try: + return os.dup(2) + except OSError: + return os.open(os.devnull, os.O_WRONLY) + + +def _restore_fd(fd: int, private_fd: int) -> bool: + """Point fd back at the wire; the Windows handle rebind never affects the outcome.""" + try: + os.dup2(private_fd, fd) + except OSError: + return False + if sys.platform == "win32": # pragma: no cover + with suppress(OSError): + rebind_std_handle_to_fd(fd) + return True + + +def _claim_fd( + fd: int, stream: TextIO, mode: Literal["rb", "wb"], open_diversion: Callable[[], int] +) -> tuple[BinaryIO, Callable[[], None] | None]: + """Claim a standard stream: divert fd and serve the wire from a private duplicate. + + Best-effort: when descriptors cannot be duplicated or diverted, serves the + sys stream's buffer in place, exactly as v1 did, with the claim held. + + Raises: + RuntimeError: fd is already claimed by another transport in this process. + """ + if not _is_backed_by_fd(stream, fd): + return stream.buffer, None + claim = _StreamClaim(fd) + with _claims_lock: + if fd in _claims: + raise RuntimeError(f"another stdio_server() in this process has already claimed fd {fd}") + _claims[fd] = claim + + def release() -> None: + if claim.private_fd is None or _restore_fd(fd, claim.private_fd): + with _claims_lock: + del _claims[fd] + + try: + private_fd = _dup_above_std(fd) + except OSError: + return stream.buffer, release + claim.private_fd = private_fd + + try: + diversion_fd = open_diversion() + except OSError: + return stream.buffer, release + try: + os.dup2(diversion_fd, fd) + except OSError: + # The divert did not land; ensure fd carries the wire (a Windows dup2 can + # close its target before failing), then serve it in place through the + # shared buffer, since two writers on one pipe would tear frames. + with suppress(OSError): + os.close(diversion_fd) + _restore_fd(fd, private_fd) + return stream.buffer, release + with suppress(OSError): + os.close(diversion_fd) + if sys.platform == "win32": # pragma: no cover + with suppress(OSError): + rebind_std_handle_to_fd(fd) + + # closefd=False: a worker thread can still block on this descriptor after + # the transport exits, so it must never be closed and recycled under it. + return os.fdopen(private_fd, mode, closefd=False), release + + +@asynccontextmanager +async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None): + """Serve MCP over the process's stdin and stdout. + + While serving, fd 0 points at the null device and fd 1 at stderr, so handlers + and children read EOF and their stray output misses the wire; both descriptors + are restored on exit. Explicit streams skip the claim, and a second concurrent + stdio_server() raises RuntimeError. + """ + # Re-wrap the binary buffers as UTF-8 text; the std handles' platform encodings are unreliable. + restore_stdin: Callable[[], None] | None = None + restore_stdout: Callable[[], None] | None = None + try: + if not stdin: + stdin_buffer, restore_stdin = _claim_fd(0, sys.stdin, "rb", _open_stdin_diversion) + stdin = anyio.wrap_file(_UnownedTextWrapper(stdin_buffer, encoding="utf-8", errors="replace")) + if not stdout: + stdout_buffer, restore_stdout = _claim_fd(1, sys.stdout, "wb", _open_stdout_diversion) + stdout = anyio.wrap_file(_UnownedTextWrapper(stdout_buffer, encoding="utf-8")) + + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) + + async def stdin_reader(): + try: + async with read_stream_writer: + async for line in stdin: + try: + message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) + except Exception as exc: + await read_stream_writer.send(exc) + continue + + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + except anyio.ClosedResourceError: # pragma: no cover + await anyio.lowlevel.checkpoint() + + async def stdout_writer(): + try: + async with write_stream_reader: + async for session_message in write_stream_reader: + json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) + await stdout.write(json + "\n") + await stdout.flush() + except anyio.ClosedResourceError: # pragma: no cover + await anyio.lowlevel.checkpoint() + + async with anyio.create_task_group() as tg: + tg.start_soon(stdin_reader) + tg.start_soon(stdout_writer) + yield read_stream, write_stream + finally: + if restore_stdout is not None: + restore_stdout() + if restore_stdin is not None: + restore_stdin() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/streamable_http.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/streamable_http.py new file mode 100644 index 0000000000000000000000000000000000000000..1a4e9939a45972eba039ad84fa9125d0d2309076 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/streamable_http.py @@ -0,0 +1,1092 @@ +"""StreamableHTTP Server Transport Module + +This module implements an HTTP transport layer with Streamable HTTP. + +The transport handles bidirectional communication using HTTP requests and +responses, with streaming support for long-running operations. +""" + +import logging +import re +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from functools import partial +from http import HTTPStatus +from typing import Any, Final + +import anyio +import pydantic_core +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp_types import ( + DEFAULT_NEGOTIATED_VERSION, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + PARSE_ERROR, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResponse, + RequestId, + jsonrpc_message_adapter, +) +from mcp_types.version import is_version_at_least +from pydantic import ValidationError +from sse_starlette import EventSourceResponse +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Receive, Scope, Send + +from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings +from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams +from mcp.shared._stream_protocols import ReadStream, WriteStream +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +from mcp.shared.message import CloseSSEStreamCallback, ServerMessageMetadata, SessionMessage + +logger = logging.getLogger(__name__) + + +# Header names +MCP_SESSION_ID_HEADER = "mcp-session-id" +LAST_EVENT_ID_HEADER = "last-event-id" + +# Content types +CONTENT_TYPE_JSON = "application/json" +CONTENT_TYPE_SSE = "text/event-stream" + +# Special key for the standalone GET stream +GET_STREAM_KEY = "_GET_stream" + +# Buffer for the per-request `_request_streams` so the serial `message_router` +# can deposit a response and move on instead of head-of-line blocking the +# whole session on a lazily-started `sse_writer`. See #1764. +REQUEST_STREAM_BUFFER_SIZE: Final = 16 + +# Error code answering a request that settled without a response (e.g. it was +# cancelled) on this 2025-era wire, which ends a request's stream only with a +# response. Mirrors LSP's RequestCancelled; not sent by the 2026 transports, where +# the spec forbids answering a cancelled request. See +# `StreamableHTTPServerTransport._terminate_unanswered_request`. +REQUEST_CANCELLED: Final = -32800 + +# Session ID validation pattern (visible ASCII characters ranging from 0x21 to 0x7E) +# Pattern ensures entire string contains only valid characters by using ^ and $ anchors +SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$") + +# Type aliases +StreamId = str +EventId = str +# An SSE event-dict as accepted by sse-starlette (`event`, `data`, `id`, `retry`). +SSEEvent = dict[str, Any] + + +def check_accept_headers(request: Request) -> tuple[bool, bool]: + """Return (has_json, has_sse) for the request's Accept header, with RFC 7231 wildcard handling. + + Supports wildcard media types per RFC 7231, section 5.3.2: + - */* matches any media type + - application/* matches any application/ subtype + - text/* matches any text/ subtype + """ + accept_header = request.headers.get("accept", "") + accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")] + + has_wildcard = "*/*" in accept_types + has_json = has_wildcard or any(t in (CONTENT_TYPE_JSON, "application/*") for t in accept_types) + has_sse = has_wildcard or any(t in (CONTENT_TYPE_SSE, "text/*") for t in accept_types) + + return has_json, has_sse + + +@dataclass +class EventMessage: + """A JSONRPCMessage with an optional event ID for stream resumability.""" + + message: JSONRPCMessage + event_id: str | None = None + + +EventCallback = Callable[[EventMessage], Awaitable[None]] + + +class EventStore(ABC): + """Interface for resumability support via event storage.""" + + @abstractmethod + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event for later retrieval. + + Args: + stream_id: ID of the stream the event belongs to + message: The JSON-RPC message to store, or None for priming events + + Returns: + The generated event ID for the stored event. + """ + pass # pragma: no cover + + @abstractmethod + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID. + + Args: + last_event_id: The ID of the last event the client received + send_callback: A callback function to send events to the client + + Returns: + The stream ID of the replayed events, or None if no events were found. + """ + pass # pragma: no cover + + +class StreamableHTTPServerTransport: + """HTTP server transport with event streaming support for MCP. + + Handles JSON-RPC messages in HTTP POST requests with SSE streaming. + Supports optional JSON responses and session management. + """ + + # Server notification streams for POST requests as well as standalone SSE stream + _read_stream_writer: ContextSendStream[SessionMessage | Exception] | None = None + _read_stream: ContextReceiveStream[SessionMessage | Exception] | None = None + _write_stream: ContextSendStream[SessionMessage] | None = None + _write_stream_reader: ContextReceiveStream[SessionMessage] | None = None + _security: TransportSecurityMiddleware + + def __init__( + self, + mcp_session_id: str | None, + is_json_response_enabled: bool = False, + event_store: EventStore | None = None, + security_settings: TransportSecuritySettings | None = None, + retry_interval: int | None = None, + ) -> None: + """Initialize a new StreamableHTTP server transport. + + Args: + mcp_session_id: Optional session identifier for this connection. + Must contain only visible ASCII characters (0x21-0x7E). + is_json_response_enabled: If True, answer each request POST with a single + JSON body instead of an SSE stream, which removes + the request-scoped back-channel: a server-initiated + request tied to the call raises `NoBackChannelError` + and its notifications are dropped (see + `TransportContext.can_send_request`). Default is False. + event_store: Event store for resumability support. If provided, + resumability will be enabled, allowing clients to + reconnect and resume messages. + security_settings: Optional security settings for DNS rebinding protection. + retry_interval: Retry interval in milliseconds to suggest to clients in SSE + retry field. When set, the server will send a retry field in + SSE priming events to control client reconnection timing for + polling behavior. Only used when event_store is provided. + + Raises: + ValueError: If the session ID contains invalid characters. + """ + if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id): + raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)") + + self.mcp_session_id = mcp_session_id + self.is_json_response_enabled = is_json_response_enabled + self._event_store = event_store + self._security = TransportSecurityMiddleware(security_settings) + self._retry_interval = retry_interval + self._request_streams: dict[ + RequestId, + tuple[ + MemoryObjectSendStream[EventMessage], + MemoryObjectReceiveStream[EventMessage], + ], + ] = {} + self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {} + self._terminated = False + # Idle timeout cancel scope; managed by the session manager. + self.idle_scope: anyio.CancelScope | None = None + + @property + def is_terminated(self) -> bool: + """Check if this transport has been explicitly terminated.""" + return self._terminated + + def _message_metadata( + self, + request: Request, + *, + close_sse_stream: CloseSSEStreamCallback | None = None, + close_standalone_sse_stream: CloseSSEStreamCallback | None = None, + on_request_unanswered: Callable[[], Awaitable[None]] | None = None, + ) -> ServerMessageMetadata: + """The metadata this transport frames every inbound message with. + + The one place `can_send_request` is stamped, so no construction site can + forget it: a JSON body carries only the response, so in JSON-response mode + the request-scoped channel cannot carry a server-initiated request (see + `TransportContext.can_send_request`). + """ + return ServerMessageMetadata( + request_context=request, + close_sse_stream=close_sse_stream, + close_standalone_sse_stream=close_standalone_sse_stream, + on_request_unanswered=on_request_unanswered, + can_send_request=not self.is_json_response_enabled, + ) + + def close_sse_stream(self, request_id: RequestId) -> None: + """Close SSE connection for a specific request without terminating the stream. + + This method closes the HTTP connection for the specified request, triggering + client reconnection. Events continue to be stored in the event store and will + be replayed when the client reconnects with Last-Event-ID. + + Use this to implement polling behavior during long-running operations - + the client will reconnect after the retry interval specified in the priming event. + + Args: + request_id: The request ID whose SSE stream should be closed. + + Note: + This is a no-op if there is no active stream for the request ID. + Requires event_store to be configured for events to be stored during + the disconnect. + """ + writer = self._sse_stream_writers.pop(request_id, None) + if writer: # pragma: no branch + writer.close() + + # Also close and remove request streams + if request_id in self._request_streams: # pragma: no branch + send_stream, receive_stream = self._request_streams.pop(request_id) + send_stream.close() + receive_stream.close() + + def close_standalone_sse_stream(self) -> None: + """Close the standalone GET SSE stream, triggering client reconnection. + + This method closes the HTTP connection for the standalone GET stream used + for unsolicited server-to-client notifications. The client SHOULD reconnect + with Last-Event-ID to resume receiving notifications. + + Use this to implement polling behavior for the notification stream - + the client will reconnect after the retry interval specified in the priming event. + + Note: + This is a no-op if there is no active standalone SSE stream. + Requires event_store to be configured for events to be stored during + the disconnect. + """ + self.close_sse_stream(GET_STREAM_KEY) + + def _create_session_message( + self, + message: JSONRPCRequest, + request: Request, + request_id: RequestId, + protocol_version: str, + ) -> SessionMessage: + """Create a session message with metadata including close_sse_stream callback. + + The close_sse_stream callbacks are only provided when the client supports + resumability (protocol version >= 2025-11-25). Old clients can't resume if + the stream is closed early because they didn't receive a priming event. + Every request carries `on_request_unanswered`, so a request that settles + without a response is still terminated on this era's wire. + """ + end_stream = partial(self._terminate_unanswered_request, message.id) + # Only provide close callbacks when client supports resumability + if self._event_store and is_version_at_least(protocol_version, "2025-11-25"): + + async def close_stream_callback() -> None: + self.close_sse_stream(request_id) + + async def close_standalone_stream_callback() -> None: + self.close_standalone_sse_stream() + + metadata = self._message_metadata( + request, + close_sse_stream=close_stream_callback, + close_standalone_sse_stream=close_standalone_stream_callback, + on_request_unanswered=end_stream, + ) + else: + metadata = self._message_metadata(request, on_request_unanswered=end_stream) + + return SessionMessage(message, metadata=metadata) + + async def _mint_priming_event(self, stream_id: StreamId, protocol_version: str) -> SSEEvent | None: + """Store the priming cursor for `stream_id` and return its SSE wire form. + + Called before the request is dispatched so the priming row precedes + anything `message_router` can store for this stream. Returns `None` + when no event store is configured or the client predates 2025-11-25 + (older clients cannot parse the empty-data event). + """ + if not self._event_store: + return None + if not is_version_at_least(protocol_version, "2025-11-25"): + return None + priming_event_id = await self._event_store.store_event(stream_id, None) + priming_event: SSEEvent = {"id": priming_event_id, "data": ""} + if self._retry_interval is not None: + priming_event["retry"] = self._retry_interval + return priming_event + + async def _run_sse_writer( + self, + request_id: RequestId, + sse_stream_writer: MemoryObjectSendStream[SSEEvent], + request_stream_reader: MemoryObjectReceiveStream[EventMessage], + priming_event: SSEEvent | None, + ) -> None: + """Forward `_request_streams[request_id]` onto the SSE wire for one POST.""" + try: + async with sse_stream_writer, request_stream_reader: + if priming_event is not None: + await sse_stream_writer.send(priming_event) + async for event_message in request_stream_reader: + await sse_stream_writer.send(self._create_event_data(event_message)) + if isinstance(event_message.message, JSONRPCResponse | JSONRPCError): + break + except anyio.ClosedResourceError: # pragma: lax no cover + logger.debug("SSE stream closed by close_sse_stream()") + except Exception: # pragma: lax no cover + logger.exception("Error in SSE writer") + finally: + logger.debug("Closing SSE writer") + self._sse_stream_writers.pop(request_id, None) + await self._clean_up_memory_streams(request_id) + + def _create_error_response( + self, + error_message: str, + status_code: HTTPStatus, + error_code: int = INVALID_REQUEST, + headers: dict[str, str] | None = None, + ) -> Response: + """Create an error response with a simple string message.""" + response_headers = {"Content-Type": CONTENT_TYPE_JSON} + if headers: + response_headers.update(headers) + + if self.mcp_session_id: + response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + # Return a properly formatted JSON error response + error_response = JSONRPCError( + jsonrpc="2.0", + id=None, + error=ErrorData(code=error_code, message=error_message), + ) + + return Response( + error_response.model_dump_json(by_alias=True, exclude_unset=True), + status_code=status_code, + headers=response_headers, + ) + + def _create_json_response( + self, + response_message: JSONRPCMessage | None, + status_code: HTTPStatus = HTTPStatus.OK, + headers: dict[str, str] | None = None, + ) -> Response: + """Create a JSON response from a JSONRPCMessage.""" + response_headers = {"Content-Type": CONTENT_TYPE_JSON} + if headers: + response_headers.update(headers) # pragma: no cover + + if self.mcp_session_id: + response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + return Response( + response_message.model_dump_json(by_alias=True, exclude_unset=True) if response_message else None, + status_code=status_code, + headers=response_headers, + ) + + def _get_session_id(self, request: Request) -> str | None: + """Extract the session ID from request headers.""" + return request.headers.get(MCP_SESSION_ID_HEADER) + + def _create_event_data(self, event_message: EventMessage) -> SSEEvent: + """Create event data dictionary from an EventMessage.""" + event_data = { + "event": "message", + "data": event_message.message.model_dump_json(by_alias=True, exclude_unset=True), + } + + # If an event ID was provided, include it + if event_message.event_id: + event_data["id"] = event_message.event_id + + return event_data + + async def _terminate_unanswered_request(self, request_id: RequestId) -> None: + """Terminate a request that settled without a response (e.g. cancelled). + + The 2025-era wire ends a request's stream only with a response for its + id - and stores that response so a resuming client's replay terminates + too - so this era answers a cancelled request with `REQUEST_CANCELLED` + where the dispatcher itself stays silent (the 2026 transports MUST NOT + answer). It is written through the same ordered channel as the request's + other messages, so it cannot overtake anything already queued for it. + """ + assert self._write_stream is not None # a dispatched request implies connect() ran + error = ErrorData(code=REQUEST_CANCELLED, message="Request cancelled") + await self._write_stream.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error))) + + async def _clean_up_memory_streams(self, request_id: RequestId) -> None: + """Clean up memory streams for a given request ID.""" + if request_id in self._request_streams: # pragma: no branch + try: + # Close the request stream + await self._request_streams[request_id][0].aclose() + await self._request_streams[request_id][1].aclose() + except Exception: # pragma: no cover + # During cleanup, we catch all exceptions since streams might be in various states + logger.debug("Error closing memory streams - may already be closed") + finally: + # Remove the request stream from the mapping + self._request_streams.pop(request_id, None) + + async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: + """Application entry point that handles all HTTP requests.""" + request = Request(scope, receive) + + # Validate request headers for DNS rebinding protection + is_post = request.method == "POST" + error_response = await self._security.validate_request(request, is_post=is_post) + if error_response: + await error_response(scope, receive, send) + return + + if self._terminated: + # If the session has been terminated, return 404 Not Found + response = self._create_error_response( + "Not Found: Session has been terminated", + HTTPStatus.NOT_FOUND, + ) + await response(scope, receive, send) + return + + if request.method == "POST": + await self._handle_post_request(scope, request, receive, send) + elif request.method == "GET": + await self._handle_get_request(request, send) + elif request.method == "DELETE": + await self._handle_delete_request(request, send) + else: + await self._handle_unsupported_request(request, send) + + def _check_content_type(self, request: Request) -> bool: + """Check if the request has the correct Content-Type.""" + content_type = request.headers.get("content-type", "") + content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")] + + return any(part == CONTENT_TYPE_JSON for part in content_type_parts) + + async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool: + """Validate Accept header based on response mode. Returns True if valid.""" + has_json, has_sse = check_accept_headers(request) + if self.is_json_response_enabled: + # For JSON-only responses, only require application/json + if not has_json: + response = self._create_error_response( + "Not Acceptable: Client must accept application/json", + HTTPStatus.NOT_ACCEPTABLE, + ) + await response(scope, request.receive, send) + return False + # For SSE responses, require both content types + elif not (has_json and has_sse): + response = self._create_error_response( + "Not Acceptable: Client must accept both application/json and text/event-stream", + HTTPStatus.NOT_ACCEPTABLE, + ) + await response(scope, request.receive, send) + return False + return True + + async def _handle_post_request(self, scope: Scope, request: Request, receive: Receive, send: Send) -> None: + """Handle POST requests containing JSON-RPC messages.""" + writer = self._read_stream_writer + if writer is None: # pragma: no cover + raise ValueError("No read stream writer available. Ensure connect() is called first.") + try: + # Validate Accept header + if not await self._validate_accept_header(request, scope, send): + return + + # Validate Content-Type + if not self._check_content_type(request): # pragma: no cover + response = self._create_error_response( + "Unsupported Media Type: Content-Type must be application/json", + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + ) + await response(scope, receive, send) + return + + # Parse the body - only read it once + body = await request.body() + + try: + raw_message = pydantic_core.from_json(body) + except ValueError as e: + response = self._create_error_response(f"Parse error: {str(e)}", HTTPStatus.BAD_REQUEST, PARSE_ERROR) + await response(scope, receive, send) + return + + try: + message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False) + except ValidationError as e: + response = self._create_error_response( + f"Validation error: {str(e)}", + HTTPStatus.BAD_REQUEST, + INVALID_PARAMS, + ) + await response(scope, receive, send) + return + + # Check if this is an initialization request + is_initialization_request = isinstance(message, JSONRPCRequest) and message.method == "initialize" + + if is_initialization_request: + # Check if the server already has an established session + if self.mcp_session_id: + # Check if request has a session ID + request_session_id = self._get_session_id(request) + + # If request has a session ID but doesn't match, return 404 + if request_session_id and request_session_id != self.mcp_session_id: # pragma: no cover + response = self._create_error_response( + "Not Found: Invalid or expired session ID", + HTTPStatus.NOT_FOUND, + ) + await response(scope, receive, send) + return + elif not await self._validate_request_headers(request, send): + return + + # For notifications and responses only, return 202 Accepted + if not isinstance(message, JSONRPCRequest): + # Create response object and send it + response = self._create_json_response( + None, + HTTPStatus.ACCEPTED, + ) + await response(scope, receive, send) + + # Process the message after sending the response + session_message = SessionMessage(message, metadata=self._message_metadata(request)) + await writer.send(session_message) + + return + + # Extract protocol version for priming event decision. + # For initialize requests, get from request params. + # For other requests, get from header (already validated). + protocol_version = ( + str(message.params.get("protocolVersion", DEFAULT_NEGOTIATED_VERSION)) + if is_initialization_request and message.params + else request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) + ) + + request_id = str(message.id) + + if self.is_json_response_enabled: + self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage]( + REQUEST_STREAM_BUFFER_SIZE + ) + request_stream_reader = self._request_streams[request_id][1] + # Process the message + metadata = self._message_metadata( + request, on_request_unanswered=partial(self._terminate_unanswered_request, message.id) + ) + session_message = SessionMessage(message, metadata=metadata) + await writer.send(session_message) + try: + # `message_router` deposits only this request's own response + # here: anything else scoped to the request has no wire in + # JSON-response mode. + event_message = await request_stream_reader.receive() + except (anyio.EndOfStream, anyio.ClosedResourceError): + # The stream closed with no response: the session was + # terminated while this request was in flight. + logger.debug(f"Session terminated with request {request_id} in flight; no response to send") + response = self._create_error_response( + "Session terminated before the request completed", + HTTPStatus.INTERNAL_SERVER_ERROR, + INTERNAL_ERROR, + ) + else: + response = self._create_json_response(event_message.message) + finally: + await self._clean_up_memory_streams(request_id) + await response(scope, receive, send) + else: + # Mint the priming event before any per-request state exists: + # `EventStore.store_event` is user code and may raise, in which + # case the outer handler returns a 500 with nothing to clean up. + # Still strictly precedes dispatch, so storage order == wire order. + priming_event = await self._mint_priming_event(request_id, protocol_version) + + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) + self._sse_stream_writers[request_id] = sse_stream_writer + self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage]( + REQUEST_STREAM_BUFFER_SIZE + ) + request_stream_reader = self._request_streams[request_id][1] + + headers = { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": CONTENT_TYPE_SSE, + **({MCP_SESSION_ID_HEADER: self.mcp_session_id} if self.mcp_session_id else {}), + } + response = EventSourceResponse( + content=sse_stream_reader, + data_sender_callable=partial( + self._run_sse_writer, request_id, sse_stream_writer, request_stream_reader, priming_event + ), + headers=headers, + ) + + # Start the SSE response (this will send headers immediately) + try: + # First send the response to establish the SSE connection + async with anyio.create_task_group() as tg: + tg.start_soon(response, scope, receive, send) + # Then send the message to be processed by the server + session_message = self._create_session_message(message, request, request_id, protocol_version) + await writer.send(session_message) + except Exception: # pragma: lax no cover + logger.exception("SSE response error") + await sse_stream_writer.aclose() + await self._clean_up_memory_streams(request_id) + finally: + await sse_stream_reader.aclose() + + except Exception as err: + logger.exception("Error handling POST request") + response = self._create_error_response( + "Error handling POST request", + HTTPStatus.INTERNAL_SERVER_ERROR, + INTERNAL_ERROR, + ) + await response(scope, receive, send) + await writer.send(Exception(err)) + return + + async def _handle_get_request(self, request: Request, send: Send) -> None: + """Handle GET request to establish SSE. + + This allows the server to communicate to the client without the client + first sending data via HTTP POST. The server can send JSON-RPC requests + and notifications on this stream. + """ + writer = self._read_stream_writer + if writer is None: # pragma: no cover + raise ValueError("No read stream writer available. Ensure connect() is called first.") + + # Validate Accept header - must include text/event-stream + _, has_sse = check_accept_headers(request) + + if not has_sse: + response = self._create_error_response( + "Not Acceptable: Client must accept text/event-stream", + HTTPStatus.NOT_ACCEPTABLE, + ) + await response(request.scope, request.receive, send) + return + + if not await self._validate_request_headers(request, send): + return + + # Handle resumability: check for Last-Event-ID header + if last_event_id := request.headers.get(LAST_EVENT_ID_HEADER): + await self._replay_events(last_event_id, request, send) + return + + headers = { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": CONTENT_TYPE_SSE, + } + + if self.mcp_session_id: # pragma: no branch + headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + # Check if we already have an active GET stream + if GET_STREAM_KEY in self._request_streams: + response = self._create_error_response( + "Conflict: Only one SSE stream is allowed per session", + HTTPStatus.CONFLICT, + ) + await response(request.scope, request.receive, send) + return + + # Create SSE stream + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) + + async def standalone_sse_writer(): + try: + # Create a standalone message stream for server-initiated messages + + self._request_streams[GET_STREAM_KEY] = anyio.create_memory_object_stream[EventMessage]( + REQUEST_STREAM_BUFFER_SIZE + ) + standalone_stream_reader = self._request_streams[GET_STREAM_KEY][1] + + async with sse_stream_writer, standalone_stream_reader: + # Process messages from the standalone stream + async for event_message in standalone_stream_reader: + # For the standalone stream, we handle: + # - JSONRPCNotification (server sends notifications to client) + # - JSONRPCRequest (server sends requests to client) + # We should NOT receive JSONRPCResponse + + # Send the message via SSE + event_data = self._create_event_data(event_message) + await sse_stream_writer.send(event_data) + except anyio.ClosedResourceError: + # Session teardown can close the stream while the writer is between dequeues. + pass + except Exception: + logger.exception("Error in standalone SSE writer") # pragma: no cover + finally: + logger.debug("Closing standalone SSE writer") + await self._clean_up_memory_streams(GET_STREAM_KEY) + + # Create and start EventSourceResponse + response = EventSourceResponse( + content=sse_stream_reader, + data_sender_callable=standalone_sse_writer, + headers=headers, + ) + + try: + # This will send headers immediately and establish the SSE connection + await response(request.scope, request.receive, send) + except Exception: # pragma: lax no cover + logger.exception("Error in standalone SSE response") + await self._clean_up_memory_streams(GET_STREAM_KEY) + finally: + await sse_stream_writer.aclose() + await sse_stream_reader.aclose() + + async def _handle_delete_request(self, request: Request, send: Send) -> None: + """Handle DELETE requests for explicit session termination.""" + # Validate session ID + if not self.mcp_session_id: # pragma: no cover + # If no session ID set, return Method Not Allowed + response = self._create_error_response( + "Method Not Allowed: Session termination not supported", + HTTPStatus.METHOD_NOT_ALLOWED, + ) + await response(request.scope, request.receive, send) + return + + if not await self._validate_request_headers(request, send): # pragma: no cover + return + + await self.terminate() + + response = self._create_json_response( + None, + HTTPStatus.OK, + ) + await response(request.scope, request.receive, send) + + async def terminate(self) -> None: + """Terminate the current session, closing all streams. + + Once terminated, all requests with this session ID will receive 404 Not Found. + """ + + self._terminated = True + logger.info(f"Terminating session: {self.mcp_session_id}") + + # We need a copy of the keys to avoid modification during iteration + request_stream_keys = list(self._request_streams.keys()) + + # Close all request streams asynchronously + for key in request_stream_keys: + await self._clean_up_memory_streams(key) + + # Clear the request streams dictionary immediately + self._request_streams.clear() + try: + if self._read_stream_writer is not None: # pragma: no branch + await self._read_stream_writer.aclose() + if self._read_stream is not None: # pragma: no branch + await self._read_stream.aclose() + if self._write_stream_reader is not None: # pragma: no branch + await self._write_stream_reader.aclose() + if self._write_stream is not None: # pragma: no branch + await self._write_stream.aclose() + except Exception as e: # pragma: no cover + # During cleanup, we catch all exceptions since streams might be in various states + logger.debug(f"Error closing streams: {e}") + + async def _handle_unsupported_request(self, request: Request, send: Send) -> None: + """Handle unsupported HTTP methods.""" + headers = { + "Content-Type": CONTENT_TYPE_JSON, + "Allow": "GET, POST, DELETE", + } + if self.mcp_session_id: # pragma: no branch + headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + response = self._create_error_response( + "Method Not Allowed", + HTTPStatus.METHOD_NOT_ALLOWED, + headers=headers, + ) + await response(request.scope, request.receive, send) + + async def _validate_request_headers(self, request: Request, send: Send) -> bool: + # Protocol-version validation lives in the manager's era-routing: only + # values in `HANDSHAKE_PROTOCOL_VERSIONS` (or no header at all) reach + # this transport, so the legacy version-gate is gone. + return await self._validate_session(request, send) + + async def _validate_session(self, request: Request, send: Send) -> bool: + """Validate the session ID in the request.""" + if not self.mcp_session_id: + # If we're not using session IDs, return True + return True + + # Get the session ID from the request headers + request_session_id = self._get_session_id(request) + + # If no session ID provided but required, return error + if not request_session_id: + response = self._create_error_response( + "Bad Request: Missing session ID", + HTTPStatus.BAD_REQUEST, + ) + await response(request.scope, request.receive, send) + return False + + # If session ID doesn't match, return error + if request_session_id != self.mcp_session_id: # pragma: no cover + response = self._create_error_response( + "Not Found: Invalid or expired session ID", + HTTPStatus.NOT_FOUND, + ) + await response(request.scope, request.receive, send) + return False + + return True + + async def _replay_events(self, last_event_id: str, request: Request, send: Send) -> None: + """Replays events that would have been sent after the specified event ID. + + Only used when resumability is enabled. + """ + event_store = self._event_store + if not event_store: + return # pragma: no cover + + try: + headers = { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": CONTENT_TYPE_SSE, + } + + if self.mcp_session_id: # pragma: no branch + headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + # The manager only routes supported (or absent) header values to this transport + replay_protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) + + # Create SSE stream for replay + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) + + async def replay_sender(): + try: + async with sse_stream_writer: + # Define an async callback for sending events + async def send_event(event_message: EventMessage) -> None: + event_data = self._create_event_data(event_message) + await sse_stream_writer.send(event_data) + + # Replay past events and get the stream ID + stream_id = await event_store.replay_events_after(last_event_id, send_event) + + # If stream ID not in mapping, create it + if stream_id and stream_id not in self._request_streams: # pragma: no branch + try: + # Register SSE writer so close_sse_stream() can close it + self._sse_stream_writers[stream_id] = sse_stream_writer + + # Prime the resumed connection so the client sees the stream + # is re-registered. The replayโ†’live-tail ordering window here + # is pre-existing and tracked separately. + priming_event = await self._mint_priming_event(stream_id, replay_protocol_version) + if priming_event is not None: + await sse_stream_writer.send(priming_event) + + # Create new request streams for this connection + self._request_streams[stream_id] = anyio.create_memory_object_stream[EventMessage]( + REQUEST_STREAM_BUFFER_SIZE + ) + msg_reader = self._request_streams[stream_id][1] + + # Forward messages to SSE + async with msg_reader: + async for event_message in msg_reader: + event_data = self._create_event_data(event_message) + + await sse_stream_writer.send(event_data) + finally: + self._sse_stream_writers.pop(stream_id, None) + await self._clean_up_memory_streams(stream_id) + except anyio.ClosedResourceError: # pragma: lax no cover + # Expected when close_sse_stream() is called + logger.debug("Replay SSE stream closed by close_sse_stream()") + except Exception: # pragma: lax no cover + logger.exception("Error in replay sender") + + # Create and start EventSourceResponse + response = EventSourceResponse( + content=sse_stream_reader, + data_sender_callable=replay_sender, + headers=headers, + ) + + try: + await response(request.scope, request.receive, send) + except Exception: # pragma: lax no cover + logger.exception("Error in replay response") + finally: + await sse_stream_writer.aclose() + await sse_stream_reader.aclose() + + except Exception: # pragma: lax no cover + logger.exception("Error replaying events") + response = self._create_error_response( + "Error replaying events", + HTTPStatus.INTERNAL_SERVER_ERROR, + INTERNAL_ERROR, + ) + await response(request.scope, request.receive, send) + + @asynccontextmanager + async def connect( + self, + ) -> AsyncGenerator[ + tuple[ + ReadStream[SessionMessage | Exception], + WriteStream[SessionMessage], + ], + None, + ]: + """Context manager that provides read and write streams for a connection. + + Yields: + Tuple of (read_stream, write_stream) for bidirectional communication + """ + + # Create the memory streams for this connection + + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) + + # Store the streams + self._read_stream_writer = read_stream_writer + self._read_stream = read_stream + self._write_stream_reader = write_stream_reader + self._write_stream = write_stream + + # Start a task group for message routing + async with anyio.create_task_group() as tg: + # Create a message router that distributes messages to request streams + async def message_router(): + try: + async for session_message in write_stream_reader: # pragma: no branch + # Determine which request stream(s) should receive this message + message = session_message.message + target_request_id = None + # Check if this is a response with a known request id. + # Null-id errors (e.g., parse errors) fall through to + # the GET stream since they can't be correlated. + if isinstance(message, JSONRPCResponse | JSONRPCError) and message.id is not None: + target_request_id = str(message.id) + # Extract related_request_id from meta if it exists + elif ( + session_message.metadata is not None + and isinstance( + session_message.metadata, + ServerMessageMetadata, + ) + and session_message.metadata.related_request_id is not None + ): + related_request_id = session_message.metadata.related_request_id + if self.is_json_response_enabled: + # A JSON body carries only the response: this message + # has no wire form (nor a replay), so drop it before + # storing or queueing rather than park it (#1764). + logger.debug(f"Dropped message related to request {related_request_id} in JSON mode") + continue + target_request_id = str(related_request_id) + + request_stream_id = target_request_id if target_request_id is not None else GET_STREAM_KEY + + # Store the event if we have an event store, + # regardless of whether a client is connected + # messages will be replayed on the re-connect + event_id = None + if self._event_store: + event_id = await self._event_store.store_event(request_stream_id, message) + logger.debug(f"Stored {event_id} from {request_stream_id}") + + if request_stream_id in self._request_streams: + try: + # Send both the message and the event ID + await self._request_streams[request_stream_id][0].send(EventMessage(message, event_id)) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): # pragma: no cover + # Stream might be closed, remove from registry + self._request_streams.pop(request_stream_id, None) + else: + logger.debug( + f"""Request stream {request_stream_id} not found + for message. Still processing message as the client + might reconnect and replay.""" + ) + except anyio.ClosedResourceError: + if self._terminated: # pragma: lax no cover + logger.debug("Read stream closed by client") + else: + logger.exception("Unexpected closure of read stream in message router") + except Exception: # pragma: lax no cover + logger.exception("Error in message router") + + # Start the message router + tg.start_soon(message_router) + + try: + # Yield the streams for the caller to use + yield read_stream, write_stream + finally: + for stream_id in list(self._request_streams.keys()): + await self._clean_up_memory_streams(stream_id) + self._request_streams.clear() + + # Clean up the read and write streams + try: + await read_stream_writer.aclose() + await read_stream.aclose() + await write_stream_reader.aclose() + await write_stream.aclose() + except Exception as e: # pragma: no cover + # During cleanup, we catch all exceptions since streams might be in various states + logger.debug(f"Error closing streams: {e}") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/streamable_http_manager.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/streamable_http_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..31f587ee664652ae5d3c617a47bf5de1712b39e0 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/streamable_http_manager.py @@ -0,0 +1,441 @@ +"""StreamableHTTP Session Manager for MCP servers.""" + +from __future__ import annotations + +import contextlib +import logging +from collections import deque +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, Final +from uuid import uuid4 + +import anyio +from anyio.abc import TaskStatus +from mcp_types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS +from starlette.datastructures import Headers +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from mcp.server._streamable_http_modern import handle_modern_request +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context +from mcp.server.connection import Connection +from mcp.server.runner import serve_connection, serve_loop +from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared._compat import resync_tracer +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.transport_context import TransportContext + +if TYPE_CHECKING: + from mcp.server.lowlevel.server import Server + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024 +"""Default maximum Streamable HTTP request body size in bytes (4 MiB).""" + + +class StreamableHTTPSessionManager: + """Manages StreamableHTTP sessions with optional resumability via event store. + + This class abstracts away the complexity of session management, event storage, + and request handling for StreamableHTTP transports. It handles: + + 1. Session tracking for clients + 2. Resumability via an optional event store + 3. Connection management and lifecycle + 4. Request handling and transport setup + 5. Idle session cleanup via optional timeout + + Important: Only one StreamableHTTPSessionManager instance should be created + per application. The instance cannot be reused after its run() context has + completed. If you need to restart the manager, create a new instance. + + Args: + app: The MCP server instance + event_store: Optional event store for resumability support. If provided, enables resumable connections + where clients can reconnect and receive missed events. If None, sessions are still tracked but not + resumable. + json_response: Whether to use JSON responses instead of SSE streams + stateless: If True, creates a completely fresh transport for each request with no session tracking or + state persistence between requests. + security_settings: Optional transport security settings. + retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE + polling behavior. + session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that + receive no HTTP requests for this duration will be automatically terminated and removed. When + retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to + avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800 + (30 minutes) is recommended for most deployments. + max_request_body_size: Maximum size in bytes for Streamable HTTP POST request bodies. Requests that + exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB. + """ + + def __init__( + self, + app: Server[Any], + event_store: EventStore | None = None, + json_response: bool = False, + stateless: bool = False, + security_settings: TransportSecuritySettings | None = None, + retry_interval: int | None = None, + session_idle_timeout: float | None = None, + max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, + ): + if session_idle_timeout is not None and session_idle_timeout <= 0: + raise ValueError("session_idle_timeout must be a positive number of seconds") + if stateless and session_idle_timeout is not None: + raise RuntimeError("session_idle_timeout is not supported in stateless mode") + if max_request_body_size <= 0: + raise ValueError("max_request_body_size must be a positive number of bytes") + + self.app = app + self.event_store = event_store + self.json_response = json_response + self.stateless = stateless + self.security_settings = security_settings + self.retry_interval = retry_interval + self.session_idle_timeout = session_idle_timeout + self.max_request_body_size = max_request_body_size + self.asgi_app = RequestBodyLimitMiddleware(self._handle_request, max_request_body_size) + + # Session tracking (only used if not stateless) + self._session_creation_lock = anyio.Lock() + self._server_instances: dict[str, StreamableHTTPServerTransport] = {} + # Identity of the credential that created each session; requests for a + # session must present the same credential. + self._session_owners: dict[str, AuthorizationContext] = {} + + # The task group and lifespan state are set during run() + self._task_group = None + self._lifespan_state: Any = None + # Thread-safe tracking of run() calls + self._run_lock = anyio.Lock() + self._has_started = False + + @contextlib.asynccontextmanager + async def run(self) -> AsyncIterator[None]: + """Run the session manager with proper lifecycle management. + + This creates and manages the task group for all session operations. + + Important: This method can only be called once per instance. The same + StreamableHTTPSessionManager instance cannot be reused after this + context manager exits. Create a new instance if you need to restart. + + Use this in the lifespan context manager of your Starlette app: + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with session_manager.run(): + yield + """ + # Thread-safe check to ensure run() is only called once + async with self._run_lock: + if self._has_started: + raise RuntimeError( + "StreamableHTTPSessionManager .run() can only be called " + "once per instance. Create a new instance if you need to run again." + ) + self._has_started = True + + async with self.app.lifespan(self.app) as lifespan_state, anyio.create_task_group() as tg: + # Store for handle_request: lifespan is entered once for the + # manager's lifetime, not per request (per-connection cleanup + # belongs on `connection.exit_stack`). + self._lifespan_state = lifespan_state + self._task_group = tg + logger.info("StreamableHTTP session manager started") + try: + yield # Let the application run + finally: + logger.info("StreamableHTTP session manager shutting down") + # Cancel task group to stop all spawned tasks + tg.cancel_scope.cancel() + self._task_group = None + self._lifespan_state = None + # Clear any remaining server instances + self._server_instances.clear() + self._session_owners.clear() + await resync_tracer() + + async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: + """Process ASGI request with proper session handling and transport setup. + + Dispatches to the appropriate handler based on stateless mode. + """ + await self.asgi_app(scope, receive, send) + + async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: + if self._task_group is None: + raise RuntimeError("Task group is not initialized. Make sure to use run().") + + # TODO(L49): header-only era-routing for now; body-primary classification + # is a follow-up. The legacy paths below own only the known + # initialize-handshake versions; anything else (including unknown + # values) goes to the modern entry so the classifier can validate it + # and return a structured rejection. 2025 paths below remain unchanged. + header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii") + pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None) + if pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS: + await handle_modern_request( + self.app, self.security_settings, self.json_response, self._lifespan_state, scope, receive, send + ) + return + + # Dispatch to the appropriate handler + if self.stateless: + await self._handle_stateless_request(pv, scope, receive, send) + else: + await self._handle_stateful_request(scope, receive, send) + + async def _handle_stateless_request( + self, protocol_version_hint: str | None, scope: Scope, receive: Receive, send: Send + ) -> None: + """Process request in stateless mode - creating a new transport for each request.""" + logger.debug("Stateless mode: Creating new transport for this request") + # No session ID needed in stateless mode + http_transport = StreamableHTTPServerTransport( + mcp_session_id=None, # No session tracking in stateless mode + is_json_response_enabled=self.json_response, + event_store=None, # No event store in stateless mode + security_settings=self.security_settings, + ) + + # Start server in a new task + async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED): + async with http_transport.connect() as streams: + read_stream, write_stream = streams + task_status.started() + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, + write_stream, + inline_methods=frozenset({"initialize"}), + # No session ID means a server-to-client request can be + # written to this POST's response stream, but the client's + # reply has nowhere to land โ€” `can_send_request=False` + # makes the per-request channel raise `NoBackChannelError` + # for requests while still allowing notifications. + transport_builder=lambda _md: TransportContext(kind="streamable-http", can_send_request=False), + ) + # Born-ready, no standalone channel: the legacy stateless path + # never opens a GET stream and need not see `initialize`. The + # header (or the spec's default-absent value) seeds + # `ctx.protocol_version`. + connection = Connection.from_envelope( + protocol_version_hint if protocol_version_hint is not None else DEFAULT_NEGOTIATED_VERSION, + None, + None, + ) + try: + await serve_connection( + self.app, dispatcher, connection=connection, lifespan_state=self._lifespan_state + ) + except Exception: # pragma: lax no cover + logger.exception("Stateless session crashed") + + # Assert task group is not None for type checking + assert self._task_group is not None + # Start the server task + await self._task_group.start(run_stateless_server) + + # Handle the HTTP request and return the response + await http_transport.handle_request(scope, receive, send) + + # Terminate the transport after the request is handled + await http_transport.terminate() + + async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: Send) -> None: + """Process request in stateful mode - maintaining session state between requests.""" + request = Request(scope, receive) + request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER) + + user = scope.get("user") + requestor = authorization_context(user) if isinstance(user, AuthenticatedUser) else None + + # Existing session case + if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: + transport = self._server_instances[request_mcp_session_id] + if requestor != self._session_owners.get(request_mcp_session_id): + # A session can only be used with the credential that created + # it. Respond exactly as if the session did not exist. + logger.warning( + "Rejecting request for session %s: credential does not match the one that created the session", + request_mcp_session_id[:64], + ) + body = JSONRPCError( + jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message="Session not found") + ) + response = Response( + body.model_dump_json(by_alias=True, exclude_unset=True), + status_code=404, + media_type="application/json", + ) + await response(scope, receive, send) + return + logger.debug("Session already exists, handling request directly") + # Push back idle deadline on activity + if transport.idle_scope is not None and self.session_idle_timeout is not None: + transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover + await transport.handle_request(scope, receive, send) + return + + if request_mcp_session_id is None: + # New session case + logger.debug("Creating new transport") + async with self._session_creation_lock: + new_session_id = uuid4().hex + http_transport = StreamableHTTPServerTransport( + mcp_session_id=new_session_id, + is_json_response_enabled=self.json_response, + event_store=self.event_store, # May be None (no resumability) + security_settings=self.security_settings, + retry_interval=self.retry_interval, + ) + + assert http_transport.mcp_session_id is not None + if requestor is not None: + self._session_owners[http_transport.mcp_session_id] = requestor + self._server_instances[http_transport.mcp_session_id] = http_transport + logger.info(f"Created new transport with session ID: {new_session_id}") + + # Define the server runner + async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None: + async with http_transport.connect() as streams: + read_stream, write_stream = streams + task_status.started() + try: + # Use a cancel scope for idle timeout โ€” when the + # deadline passes the scope cancels the loop and + # execution continues after the ``with`` block. + # Incoming requests push the deadline forward. + idle_scope = anyio.CancelScope() + if self.session_idle_timeout is not None: + idle_scope.deadline = anyio.current_time() + self.session_idle_timeout + http_transport.idle_scope = idle_scope + + with idle_scope: + # Drive via `serve_loop` (not `Server.run()`) so the + # manager's already-entered lifespan is reused + # rather than re-entered per session. + await serve_loop( + self.app, + read_stream, + write_stream, + lifespan_state=self._lifespan_state, + session_id=http_transport.mcp_session_id, + ) + + if idle_scope.cancelled_caught: + assert http_transport.mcp_session_id is not None + logger.info(f"Session {http_transport.mcp_session_id} idle timeout") + self._server_instances.pop(http_transport.mcp_session_id, None) + self._session_owners.pop(http_transport.mcp_session_id, None) + await http_transport.terminate() + except Exception: + logger.exception(f"Session {http_transport.mcp_session_id} crashed") + finally: + if ( # pragma: no branch + http_transport.mcp_session_id + and http_transport.mcp_session_id in self._server_instances + and not http_transport.is_terminated + ): + logger.info( + "Cleaning up crashed session " + f"{http_transport.mcp_session_id} from active instances." + ) + del self._server_instances[http_transport.mcp_session_id] + self._session_owners.pop(http_transport.mcp_session_id, None) + + # Assert task group is not None for type checking + assert self._task_group is not None + # Start the server task + await self._task_group.start(run_server) + + # Handle the HTTP request and return the response + await http_transport.handle_request(scope, receive, send) + else: + # Unknown or expired session ID - return 404 per MCP spec + # TODO(L62): Align error code once spec clarifies + # See: https://github.com/modelcontextprotocol/python-sdk/issues/1821 + logger.info(f"Rejected request with unknown or expired session ID: {request_mcp_session_id[:64]}") + body = JSONRPCError( + jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message="Session not found") + ) + response = Response( + body.model_dump_json(by_alias=True, exclude_unset=True), status_code=404, media_type="application/json" + ) + await response(scope, receive, send) + + +class RequestBodyLimitMiddleware: + """Reject oversized HTTP request bodies before invoking an ASGI application.""" + + def __init__(self, app: ASGIApp, max_body_size: int) -> None: + self.app = app + self.max_body_size = max_body_size + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope["method"] != "POST": + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + content_length = headers.get("content-length") + if content_length is not None: + try: + declared_size = int(content_length) + except ValueError: + pass + else: + if declared_size > self.max_body_size: + response = Response("Request body too large", status_code=413) + return await response(scope, receive, send) + + received_body = bytearray() + received_request = False + body_complete = False + trailing_message: Message | None = None + while True: + message = await receive() + if message["type"] != "http.request": + trailing_message = message + break + + received_request = True + body = message.get("body", b"") + if len(received_body) + len(body) > self.max_body_size: + response = Response("Request body too large", status_code=413) + return await response(scope, receive, send) + received_body.extend(body) + if not message.get("more_body", False): + body_complete = True + break + + cached_messages: deque[Message] = deque() + if received_request: + cached_messages.append( + {"type": "http.request", "body": bytes(received_body), "more_body": not body_complete} + ) + if trailing_message is not None: + cached_messages.append(trailing_message) + + async def replay() -> Message: + if cached_messages: + return cached_messages.popleft() + return await receive() + + await self.app(scope, replay, send) + + +class StreamableHTTPASGIApp: + """ASGI application for Streamable HTTP server transport.""" + + def __init__(self, session_manager: StreamableHTTPSessionManager): + self.session_manager = session_manager + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await self.session_manager.asgi_app(scope, receive, send) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/subscriptions.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/subscriptions.py new file mode 100644 index 0000000000000000000000000000000000000000..f5d3b5b0a4074ef5d84149a3241046aafe999a38 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/subscriptions.py @@ -0,0 +1,254 @@ +"""Server-side `subscriptions/listen` support (2026-07-28, SEP-2575). + +On the 2026-07-28 wire there is no standing GET stream: a client opts in to +server events by sending a `subscriptions/listen` request whose response IS +the stream. This module provides the two pieces a server needs: + +- `SubscriptionBus`: the pluggable fan-out seam. The bus carries typed `ServerEvent` + values, not wire notifications - the listen handler owns subscription-id + stamping and per-stream filtering, so a custom bus (e.g. backed by Redis + pub/sub for multi-replica deployments) never sees JSON-RPC. The in-process + default is `InMemorySubscriptionBus`. +- `ListenHandler`: the request handler that serves `subscriptions/listen`. + `MCPServer` registers one automatically; lowlevel `Server` users pass an + instance as `on_subscriptions_listen=`. + +The event vocabulary lives in `mcp.shared.subscriptions`, shared with the client driver, and is re-exported here. + +Per the spec, the handler acknowledges first (the ack is the first frame on +the stream), tags every frame with the listen request's JSON-RPC id under +`_meta["io.modelcontextprotocol/subscriptionId"]`, and never delivers an +event kind the client did not request. Delivery is fire-and-forget with no +replay: a dropped stream is not resumable - clients re-listen and refetch. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Any, Protocol + +import anyio +import anyio.lowlevel +import anyio.streams.memory +from mcp_types import ( + INTERNAL_ERROR, + INVALID_REQUEST, + SubscriptionFilter, + SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, + SubscriptionsListenRequestParams, + SubscriptionsListenResult, +) + +from mcp.server.context import ServerRequestContext +from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, + event_matches, + event_to_notification, +) + +__all__ = [ + "SUBSCRIPTION_ID_META_KEY", + "InMemorySubscriptionBus", + "ListenHandler", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "SubscriptionBus", + "ToolsListChanged", +] + +logger = logging.getLogger(__name__) + + +class SubscriptionBus(Protocol): + """Fan-out seam between event publishers and open listen streams. + + Implement this over an external pub/sub backend (Redis, NATS, ...) to fan + events out across replicas: `publish` forwards the event to the backend, + and each replica's bus invokes its local listeners for events arriving + from the backend. The same instance can be shared across servers. + + `publish` is async so backend implementations can do network I/O. + `subscribe` is synchronous local registration. Listeners are synchronous, + must not raise, and are invoked on the server's event loop. + """ + + async def publish(self, event: ServerEvent) -> None: + """Deliver `event` to every subscribed listener.""" + ... + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + """Register `listener` and return an idempotent unsubscribe callable.""" + ... + + +class InMemorySubscriptionBus: + """In-process `SubscriptionBus`: synchronous fan-out to listeners in subscription order.""" + + def __init__(self) -> None: + # Keyed by a per-subscription token so the same callable can be + # registered more than once (bound methods compare equal). + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + """Deliver `event` to every subscribed listener. + + A raising listener is logged and skipped: one bad listener must not + starve the others or fail the publishing handler. Ends with a + checkpoint so a burst of publishes from one task lets listen streams + drain between events instead of overflowing their buffers unread. + """ + for listener in list(self._listeners.values()): + try: + listener(event) + except Exception: # fan-out boundary: isolate listeners from each other + logger.exception("subscription listener raised; continuing") + await anyio.lowlevel.checkpoint() + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + """Register `listener` and return an idempotent unsubscribe callable.""" + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +def _safe_unsubscribe(unsubscribe: Callable[[], None]) -> None: + """Run a bus's unsubscribe callable, isolating the stream from it raising. + + The callable comes from a custom `SubscriptionBus`; a raising one is + logged and skipped so it cannot stop the stream's own cleanup from + releasing its subscription slot. + """ + try: + unsubscribe() + except Exception: # fan-out boundary: a raising bus must not skip stream cleanup + logger.exception("bus unsubscribe raised; continuing stream cleanup") + + +def _honored_subset(requested: SubscriptionFilter) -> SubscriptionFilter: + """The subset of `requested` the server will deliver, for the ack. + + Every requested kind is honored - whether an event kind ever fires + depends on what the server publishes, exactly as a subscription to a + nonexistent resource URI is honored and never fires. Non-true flags and + an empty URI list are dropped rather than echoed as falsy values. + """ + return SubscriptionFilter( + tools_list_changed=True if requested.tools_list_changed else None, + prompts_list_changed=True if requested.prompts_list_changed else None, + resources_list_changed=True if requested.resources_list_changed else None, + resource_subscriptions=list(requested.resource_subscriptions) if requested.resource_subscriptions else None, + ) + + +class ListenHandler: + """Serves `subscriptions/listen`: one call is one subscription stream. + + Register on a lowlevel `Server` via `on_subscriptions_listen=` (or + `add_request_handler`); `MCPServer` does so automatically. Each call + acknowledges the honored filter first, then forwards matching bus events + onto the request's response stream until the client disconnects (which + cancels the handler; the stream just ends, per the spec's abrupt-close + contract) or `close` ends all streams gracefully. + + Served on any transport that can carry the request's response stream: + streamable HTTP's SSE mode, or a duplex stream pair such as stdio. + + `max_subscriptions` bounds concurrent streams (further listen requests are + rejected with `INTERNAL_ERROR`, before the ack). `max_buffered_events` + bounds each stream's event backlog: a stream whose client has stopped + reading is ended at the cap (the client re-listens and refetches - there + is no replay, so ending the stream loses nothing the backlog wasn't + already losing). + """ + + def __init__(self, bus: SubscriptionBus, *, max_subscriptions: int = 1024, max_buffered_events: int = 1024) -> None: + self._bus = bus + self._max_subscriptions = max_subscriptions + self._max_buffered_events = max_buffered_events + self._streams: set[anyio.streams.memory.MemoryObjectSendStream[ServerEvent]] = set() + + async def __call__( + self, + ctx: ServerRequestContext[Any, Any], + params: SubscriptionsListenRequestParams, + ) -> SubscriptionsListenResult: + """Serve one listen stream.""" + subscription_id = ctx.request_id + if subscription_id is None: + raise MCPError(INVALID_REQUEST, "subscriptions/listen requires a request id") + if len(self._streams) >= self._max_subscriptions: + raise MCPError(INTERNAL_ERROR, "Subscription limit reached") + honored = _honored_subset(params.notifications) + honored_uris = frozenset(honored.resource_subscriptions or ()) + meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: subscription_id} + + # Buffered so publishers don't block on a slow consumer (the transport + # write happens in this handler task, not the publisher's). A stream + # whose backlog hits the cap is ended - see the class docstring. + send, recv = anyio.create_memory_object_stream[ServerEvent](self._max_buffered_events) + + def deliver(event: ServerEvent) -> None: + if event_matches(honored, honored_uris, event): + try: + send.send_nowait(event) + except anyio.ClosedResourceError: + # `close` closed this stream; the loop below is unwinding. + pass + except anyio.WouldBlock: + logger.warning("listen stream %r backlog full; ending the stream", subscription_id) + # Release the subscription slot now: the handler's own + # cleanup can be wedged in a transport write that closing + # this buffer cannot wake (a client that stopped reading). + self._streams.discard(send) + send.close() + + # Subscribe before sending the ack so an event published while the + # ack write is suspended is buffered rather than lost. The ack is + # still the first frame: this task alone writes the stream, and it + # only starts draining the buffer after the ack send returns. + unsubscribe = self._bus.subscribe(deliver) + self._streams.add(send) + try: + await ctx.session.send_notification( + SubscriptionsAcknowledgedNotification( + params=SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta) + ), + related_request_id=subscription_id, + ) + async for event in recv: + await ctx.session.send_notification( + event_to_notification(event, meta), related_request_id=subscription_id + ) + finally: + _safe_unsubscribe(unsubscribe) + self._streams.discard(send) + send.close() + recv.close() + return SubscriptionsListenResult(_meta=meta) + + def close(self) -> None: + """Initiate graceful closure of every open listen stream. + + Each stream then drains its buffered events and sends its + `SubscriptionsListenResult` (stamped with the subscription id) as the + final frame from its own handler task - the spec's graceful closure + flow, telling clients the stream ended deliberately rather than + dropping. This method only initiates that; it does not wait for the + streams to finish flushing. + """ + for stream in list(self._streams): + stream.close() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/transport_security.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/transport_security.py new file mode 100644 index 0000000000000000000000000000000000000000..d9e9f965b3bb9ab3fb5745cc01dbabbde763d057 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/transport_security.py @@ -0,0 +1,116 @@ +"""DNS rebinding protection for MCP server transports.""" + +import logging + +from pydantic import BaseModel, Field +from starlette.requests import Request +from starlette.responses import Response + +logger = logging.getLogger(__name__) + + +# TODO(Marcelo): We should flatten these settings. To be fair, I don't think we should even have this middleware. +class TransportSecuritySettings(BaseModel): + """Settings for MCP transport security features. + + These settings help protect against DNS rebinding attacks by validating incoming request headers. + """ + + enable_dns_rebinding_protection: bool = True + """Enable DNS rebinding protection (recommended for production).""" + + allowed_hosts: list[str] = Field(default_factory=list) + """List of allowed Host header values. + + Only applies when `enable_dns_rebinding_protection` is `True`. + """ + + allowed_origins: list[str] = Field(default_factory=list) + """List of allowed Origin header values. + + Only applies when `enable_dns_rebinding_protection` is `True`. + """ + + +# TODO(Marcelo): This should be a proper ASGI middleware. I'm sad to see this. +class TransportSecurityMiddleware: + """Middleware to enforce DNS rebinding protection for MCP transport endpoints.""" + + def __init__(self, settings: TransportSecuritySettings | None = None): + # If not specified, disable DNS rebinding protection by default for backwards compatibility + self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False) + + def _validate_host(self, host: str | None) -> bool: + """Validate the Host header against allowed values.""" + if not host: + logger.warning("Missing Host header in request") + return False + + # Check exact match first + if host in self.settings.allowed_hosts: + return True + + # Check wildcard port patterns + for allowed in self.settings.allowed_hosts: + if allowed.endswith(":*"): + # Extract base host from pattern + base_host = allowed[:-2] + # Check if the actual host starts with base host and has a port + if host.startswith(base_host + ":"): + return True + + logger.warning(f"Invalid Host header: {host}") + return False + + def _validate_origin(self, origin: str | None) -> bool: + """Validate the Origin header against allowed values.""" + # Origin can be absent for same-origin requests + if not origin: + return True + + # Check exact match first + if origin in self.settings.allowed_origins: + return True + + # Check wildcard port patterns + for allowed in self.settings.allowed_origins: + if allowed.endswith(":*"): + # Extract base origin from pattern + base_origin = allowed[:-2] + # Check if the actual origin starts with base origin and has a port + if origin.startswith(base_origin + ":"): + return True + + logger.warning(f"Invalid Origin header: {origin}") + return False + + def _validate_content_type(self, content_type: str | None) -> bool: + """Validate the Content-Type header for POST requests.""" + return content_type is not None and content_type.lower().startswith("application/json") + + async def validate_request(self, request: Request, is_post: bool = False) -> Response | None: + """Validate request headers for DNS rebinding protection. + + Returns None if validation passes, or an error Response if validation fails. + """ + # Always validate Content-Type for POST requests + if is_post: + content_type = request.headers.get("content-type") + if not self._validate_content_type(content_type): + return Response("Invalid Content-Type header", status_code=400) + + # Skip remaining validation if DNS rebinding protection is disabled + if not self.settings.enable_dns_rebinding_protection: + return None + + # Validate Host header + host = request.headers.get("host") + if not self._validate_host(host): + return Response("Invalid Host header", status_code=421) + + # Validate Origin header + origin = request.headers.get("origin") + if not self._validate_origin(origin): + return Response("Invalid Origin header", status_code=403) + + return None diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/server/validation.py b/.mcp-venv/lib/python3.13/site-packages/mcp/server/validation.py new file mode 100644 index 0000000000000000000000000000000000000000..a281f4d08f22635ec8abfd956b334583d12f6363 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/server/validation.py @@ -0,0 +1,93 @@ +"""Shared validation functions for server requests. + +This module provides validation logic for sampling and elicitation requests. +""" + +from mcp_types import INVALID_PARAMS, ClientCapabilities, SamplingMessage, Tool, ToolChoice + +from mcp.shared.exceptions import MCPError + + +def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> bool: + """Check if the client supports sampling tools capability. + + Args: + client_caps: The client's declared capabilities + + Returns: + True if client supports sampling.tools, False otherwise + """ + if client_caps is None: + return False + if client_caps.sampling is None: + return False + if client_caps.sampling.tools is None: + return False + return True + + +def wants_sampling_tools(tools: list[Tool] | None, tool_choice: ToolChoice | None) -> bool: + """Whether a sampling request is tools-mode: `sampling.tools` gated, array-capable answer.""" + return tools is not None or tool_choice is not None + + +def validate_sampling_tools( + client_caps: ClientCapabilities | None, + tools: list[Tool] | None, + tool_choice: ToolChoice | None, +) -> None: + """Validate that the client supports sampling tools if tools are being used. + + Args: + client_caps: The client's declared capabilities + tools: The tools list, if provided + tool_choice: The tool choice setting, if provided + + Raises: + MCPError: If tools/tool_choice are provided but client doesn't support them + """ + if wants_sampling_tools(tools, tool_choice): + if not check_sampling_tools_capability(client_caps): + raise MCPError(code=INVALID_PARAMS, message="Client does not support sampling tools capability") + + +def validate_tool_use_result_messages(messages: list[SamplingMessage]) -> None: + """Validate tool_use/tool_result message structure per SEP-1577. + + This validation ensures: + 1. Messages with tool_result content contain ONLY tool_result content + 2. tool_result messages are preceded by a message with tool_use + 3. tool_result IDs match the tool_use IDs from the previous message + + See: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577 + + Args: + messages: The list of sampling messages to validate + + Raises: + ValueError: If the message structure is invalid + """ + if not messages: + return + + last_content = messages[-1].content_as_list + has_tool_results = any(c.type == "tool_result" for c in last_content) + + previous_content = messages[-2].content_as_list if len(messages) >= 2 else None + has_previous_tool_use = previous_content and any(c.type == "tool_use" for c in previous_content) + + if has_tool_results: + # Per spec: "SamplingMessage with tool result content blocks + # MUST NOT contain other content types." + if any(c.type != "tool_result" for c in last_content): + raise ValueError("The last message must contain only tool_result content if any is present") + if previous_content is None: + raise ValueError("tool_result requires a previous message containing tool_use") + if not has_previous_tool_use: + raise ValueError("tool_result blocks do not match any tool_use in the previous message") + + if has_previous_tool_use and previous_content: + tool_use_ids = {c.id for c in previous_content if c.type == "tool_use"} + tool_result_ids = {c.tool_use_id for c in last_content if c.type == "tool_result"} + if tool_use_ids != tool_result_ids: + raise ValueError("ids of tool_result blocks and tool_use blocks from previous message do not match") diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_callable_inspection.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_callable_inspection.py new file mode 100644 index 0000000000000000000000000000000000000000..0e89e446f8d82a525c88364b8f65a3fd46d61a2d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_callable_inspection.py @@ -0,0 +1,33 @@ +"""Callable inspection utilities. + +Adapted from Starlette's `is_async_callable` implementation. +https://github.com/encode/starlette/blob/main/starlette/_utils.py +""" + +from __future__ import annotations + +import functools +import inspect +from collections.abc import Awaitable, Callable +from typing import Any, TypeGuard, TypeVar, overload + +T = TypeVar("T") + +AwaitableCallable = Callable[..., Awaitable[T]] + + +@overload +def is_async_callable(obj: AwaitableCallable[T]) -> TypeGuard[AwaitableCallable[T]]: ... + + +@overload +def is_async_callable(obj: Any) -> TypeGuard[AwaitableCallable[Any]]: ... + + +def is_async_callable(obj: Any) -> Any: + while isinstance(obj, functools.partial): # pragma: lax no cover + obj = obj.func + + return inspect.iscoroutinefunction(obj) or ( + callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None)) + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_compat.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_compat.py new file mode 100644 index 0000000000000000000000000000000000000000..88d50ba20ae26ac81d9eb488117d607a6946fdd7 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_compat.py @@ -0,0 +1,19 @@ +"""Workarounds for CPython interpreter bugs the SDK papers over.""" + +import anyio.lowlevel + +__all__ = ["resync_tracer"] + + +async def resync_tracer() -> None: + """Resync coverage tracing after a cancelled task-group join. + + A cancel delivered at a join resumes the awaiting coroutine chain via + `coro.throw()`; on CPython 3.11 (python/cpython#106749) that drops the + `'call'` trace events for the outer frames and desyncs coverage's CTracer + until the chain next suspends and resumes normally. Yielding once here + resumes via `.send()`, which re-stamps the missing events. Shielded so a + pending outer cancel is not re-delivered at this point; behaviorally a + no-op. Delete this module when Python 3.11 support ends (EOL 2027-10). + """ + await anyio.lowlevel.cancel_shielded_checkpoint() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_context_streams.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_context_streams.py new file mode 100644 index 0000000000000000000000000000000000000000..04c33306d925e60943ebe0bab323bdcee1451af3 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_context_streams.py @@ -0,0 +1,119 @@ +"""Context-aware memory stream wrappers. + +anyio memory streams do not propagate ``contextvars.Context`` across task +boundaries. These thin wrappers capture the sender's context at ``send()`` +time and expose it on the receive side via ``last_context``, so consumers +can restore it with ``ctx.run(handler, item)``. + +The iteration interface is unchanged (yields ``T``, not tuples), keeping +these wrappers duck-type compatible with plain ``MemoryObjectSendStream`` +and ``MemoryObjectReceiveStream``. +""" + +from __future__ import annotations + +import contextvars +from types import TracebackType +from typing import Any, Generic, TypeVar + +import anyio +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +T = TypeVar("T") + +# Internal payload carried through the underlying raw stream. +_Envelope = tuple[contextvars.Context, T] + + +class ContextSendStream(Generic[T]): + """Send-side wrapper that snapshots ``contextvars.copy_context()`` on every ``send()``.""" + + __slots__ = ("_inner",) + + def __init__(self, inner: MemoryObjectSendStream[_Envelope[T]]) -> None: + self._inner = inner + + async def send(self, item: T) -> None: + await self._inner.send((contextvars.copy_context(), item)) + + def close(self) -> None: + self._inner.close() + + async def aclose(self) -> None: + await self._inner.aclose() + + def clone(self) -> ContextSendStream[T]: # pragma: no cover + return ContextSendStream(self._inner.clone()) + + async def __aenter__(self) -> ContextSendStream[T]: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + await self.aclose() + return None + + +class ContextReceiveStream(Generic[T]): + """Receive-side wrapper that yields ``T`` and stores the sender's context in ``last_context``.""" + + __slots__ = ("_inner", "last_context") + + def __init__(self, inner: MemoryObjectReceiveStream[_Envelope[T]]) -> None: + self._inner = inner + self.last_context: contextvars.Context | None = None + + async def receive(self) -> T: + ctx, item = await self._inner.receive() + self.last_context = ctx + return item + + def close(self) -> None: + self._inner.close() + + async def aclose(self) -> None: + await self._inner.aclose() + + def clone(self) -> ContextReceiveStream[T]: # pragma: no cover + return ContextReceiveStream(self._inner.clone()) + + def __aiter__(self) -> ContextReceiveStream[T]: + return self + + async def __anext__(self) -> T: + try: + return await self.receive() + except anyio.EndOfStream: + raise StopAsyncIteration + + async def __aenter__(self) -> ContextReceiveStream[T]: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + await self.aclose() + return None + + +class create_context_streams( + tuple[ContextSendStream[T], ContextReceiveStream[T]], +): + """Create context-aware memory object streams. + + Supports ``create_context_streams[T](n)`` bracket syntax, + matching anyio's ``create_memory_object_stream`` API style. + """ + + def __new__(cls, max_buffer_size: float = 0) -> tuple[ContextSendStream[T], ContextReceiveStream[T]]: # type: ignore[type-var] + raw_send: MemoryObjectSendStream[Any] + raw_receive: MemoryObjectReceiveStream[Any] + raw_send, raw_receive = anyio.create_memory_object_stream(max_buffer_size) + return (ContextSendStream(raw_send), ContextReceiveStream(raw_receive)) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_httpx_utils.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_httpx_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..6bb638886aac23685867339c022514e2eb07f352 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_httpx_utils.py @@ -0,0 +1,95 @@ +"""Utilities for creating standardized httpx2 AsyncClient instances.""" + +from typing import Any, Protocol + +import httpx2 + +__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] + +# Default MCP timeout configuration +MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds) +MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds) + + +class McpHttpClientFactory(Protocol): # pragma: no branch + def __call__( # pragma: no branch + self, + headers: dict[str, str] | None = None, + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: ... + + +def create_mcp_http_client( + headers: dict[str, str] | None = None, + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, +) -> httpx2.AsyncClient: + """Create a standardized httpx2 AsyncClient with MCP defaults. + + Always enables follow_redirects and applies an SSE-friendly default timeout. + + Args: + headers: Optional headers to include with all requests. + timeout: Request timeout as httpx2.Timeout object. Defaults to 30s for + connect/write/pool and 300s for read (for long-lived SSE streams). + auth: Optional authentication handler. + + Returns: + Configured httpx2.AsyncClient instance with MCP defaults. + + Note: + The returned AsyncClient must be used as a context manager to ensure + proper cleanup of connections. + + Example: + Basic usage with MCP defaults: + + ```python + async with create_mcp_http_client() as client: + response = await client.get("https://api.example.com") + ``` + + With custom headers: + + ```python + headers = {"Authorization": "Bearer token"} + async with create_mcp_http_client(headers) as client: + response = await client.get("/endpoint") + ``` + + With both custom headers and timeout: + + ```python + timeout = httpx2.Timeout(60.0, read=300.0) + async with create_mcp_http_client(headers, timeout) as client: + response = await client.get("/long-request") + ``` + + With authentication: + + ```python + from httpx2 import BasicAuth + auth = BasicAuth(username="user", password="pass") + async with create_mcp_http_client(headers, timeout, auth) as client: + response = await client.get("/protected-endpoint") + ``` + """ + # Set MCP defaults + kwargs: dict[str, Any] = {"follow_redirects": True} + + # Handle timeout + if timeout is None: + kwargs["timeout"] = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) + else: + kwargs["timeout"] = timeout + + # Handle headers + if headers is not None: + kwargs["headers"] = headers + + # Handle authentication + if auth is not None: # pragma: no cover + kwargs["auth"] = auth + + return httpx2.AsyncClient(**kwargs) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_otel.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_otel.py new file mode 100644 index 0000000000000000000000000000000000000000..b7b05b11ab1d79ab06026d5ba0bc62c72bc9d51a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_otel.py @@ -0,0 +1,60 @@ +"""OpenTelemetry helpers for MCP.""" + +from __future__ import annotations + +from collections.abc import Generator, Mapping +from contextlib import contextmanager +from typing import Any + +from opentelemetry.context import Context +from opentelemetry.propagate import extract, inject +from opentelemetry.trace import SpanKind, get_current_span, get_tracer +from opentelemetry.trace.span import Span + +_tracer = get_tracer("mcp-python-sdk") + + +@contextmanager +def otel_span( + name: str, + *, + kind: SpanKind, + attributes: dict[str, Any] | None = None, + context: Context | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, +) -> Generator[Span]: + """Create an OTel span.""" + with _tracer.start_as_current_span( + name, + kind=kind, + attributes=attributes, + context=context, + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) as span: + yield span + + +def inject_trace_context(meta: dict[str, Any]) -> None: + """Inject W3C trace context (traceparent/tracestate) into a `_meta` dict.""" + inject(meta) + + +def extract_trace_context(meta: Mapping[str, Any] | None) -> Context | None: + """Extract W3C trace context from a `_meta` dict. + + Returns `None` when the carrier is absent, malformed, or carries no + valid `traceparent`, so callers fall through to ambient parenting; an + explicit empty `Context` would orphan the span instead of nesting under + the current one. + """ + if not meta: + return None + try: + ctx = extract(meta) + except (ValueError, TypeError): + return None + if not get_current_span(ctx).get_span_context().is_valid: + return None + return ctx diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_stream_protocols.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_stream_protocols.py new file mode 100644 index 0000000000000000000000000000000000000000..b799751329bb227616a8b5e2ea1991c0356b42a4 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/_stream_protocols.py @@ -0,0 +1,49 @@ +"""Stream protocols for MCP transports. + +These are general-purpose protocols satisfied by both ``MemoryObjectSendStream``/ +``MemoryObjectReceiveStream`` and the context-aware wrappers in ``_context_streams``. +""" + +from __future__ import annotations + +from types import TracebackType +from typing import Protocol, TypeVar + +from typing_extensions import Self + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + + +class ReadStream(Protocol[T_co]): + """Protocol for reading items from a stream. + + Consumers that need the sender's context should use + ``getattr(stream, 'last_context', None)``. + """ + + async def receive(self) -> T_co: ... + async def aclose(self) -> None: ... + def __aiter__(self) -> ReadStream[T_co]: ... + async def __anext__(self) -> T_co: ... + async def __aenter__(self) -> Self: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: ... + + +class WriteStream(Protocol[T_contra]): + """Protocol for writing items to a stream.""" + + async def send(self, item: T_contra, /) -> None: ... + async def aclose(self) -> None: ... + async def __aenter__(self) -> Self: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: ... diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/auth.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..881379d381e30d834a66355f634fb0e5bbda4b4b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/auth.py @@ -0,0 +1,258 @@ +from typing import Any, Literal, cast + +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator + +# RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG. +JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" + +# Token-endpoint client authentication methods this SDK's clients request, and the set +# `OAuthContext.prepare_token_auth` recognizes on a registered client (`private_key_jwt` is +# applied by `PrivateKeyJWTOAuthProvider`; the rest send a client secret or nothing). +TokenEndpointAuthMethod = Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"] + +# grant_types a client requests when it does not specify its own (RFC 7591 ยง2). +DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] + + +def _empty_str_to_none(v: object) -> object: + # RFC 7591 ยง2 marks these URL fields OPTIONAL; a "" placeholder means absent, so it + # must not fail AnyHttpUrl validation. (The registered-client record applies the same + # rule to every member; this coercion serves the request model.) + if v == "": + return None + return v + + +class OAuthToken(BaseModel): + """See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1""" + + access_token: str + token_type: Literal["Bearer"] = "Bearer" + expires_in: int | None = None + scope: str | None = None + refresh_token: str | None = None + + @field_validator("token_type", mode="before") + @classmethod + def normalize_token_type(cls, v: str | None) -> str | None: + if isinstance(v, str): + # Bearer is title-cased in the spec, so we normalize it + # https://datatracker.ietf.org/doc/html/rfc6750#section-4 + return v.title() + return v # pragma: no cover + + +class AuthorizationCodeResult(BaseModel): + """Authorization-code-grant redirect parameters returned by a callback handler. + + `iss` carries the RFC 9207 authorization-response issuer when the authorization server + includes it in the redirect; the client validates it against the expected issuer. + """ + + code: str + state: str | None = None + iss: str | None = None + + +class InvalidScopeError(Exception): + def __init__(self, message: str): + self.message = message + + +class InvalidRedirectUriError(Exception): + def __init__(self, message: str): + self.message = message + + +class OAuthClientMetadataBase(BaseModel): + """RFC 7591 OAuth 2.0 Dynamic Client Registration metadata shared verbatim by the + registration request (`OAuthClientMetadata`) and the authorization server's record of a + registered client (`OAuthClientInformationFull`). Fields whose acceptable values differ + between the two - what this SDK sends versus what a third-party server may echo - are + declared on each model rather than here. + See https://datatracker.ietf.org/doc/html/rfc7591#section-2 + """ + + model_config = ConfigDict(url_preserve_empty_path=True) + + # The MCP spec requires the "code" response type, but OAuth + # servers may also return additional types they support + response_types: list[str] = ["code"] + scope: str | None = None + + # these fields are currently unused, but we support & store them for potential + # future use + client_name: str | None = None + client_uri: AnyHttpUrl | None = None + logo_uri: AnyHttpUrl | None = None + contacts: list[str] | None = None + tos_uri: AnyHttpUrl | None = None + policy_uri: AnyHttpUrl | None = None + jwks_uri: AnyHttpUrl | None = None + jwks: Any | None = None + software_id: str | None = None + software_version: str | None = None + + @field_validator( + "client_uri", + "logo_uri", + "tos_uri", + "policy_uri", + "jwks_uri", + mode="before", + ) + @classmethod + def _empty_string_optional_url_to_none(cls, v: object) -> object: + # These URL fields are OPTIONAL; an echoed "" would otherwise fail AnyHttpUrl + # and throw away an otherwise valid registration response. + return _empty_str_to_none(v) + + +class OAuthClientMetadata(OAuthClientMetadataBase): + """RFC 7591 OAuth 2.0 Dynamic Client Registration request metadata: what an MCP + client sends when it registers. Field values are narrowed to what this SDK will put + on the wire; parsing the authorization server's response is `OAuthClientInformationFull`'s + job. See https://datatracker.ietf.org/doc/html/rfc7591#section-2 + """ + + redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) + # supported auth methods for the token endpoint + token_endpoint_auth_method: TokenEndpointAuthMethod | None = None + # supported grant_types of this implementation + grant_types: list[ + Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str + ] = list(DEFAULT_GRANT_TYPES) + # SEP-837: OIDC application_type. Defaults to "native" since MCP clients typically use + # loopback redirect URIs; set "web" for remote browser-based clients on a non-local host. + application_type: Literal["web", "native"] = "native" + + +class OAuthClientInformationFull(OAuthClientMetadataBase): + """RFC 7591 OAuth 2.0 Dynamic Client Registration client information response + (client information plus metadata) - the authorization server's record of a + registered client. See https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1 + + A third-party authorization server "MAY reject or replace any of the client's + requested metadata values submitted during the registration and substitute them with + suitable values", so `application_type`, `token_endpoint_auth_method`, and `grant_types` + are typed to accept any string the server echoes, and `redirect_uris` may be absent or + empty. A member the server serializes as a placeholder - an explicit `null`, or `""` - + is read as an omitted key, so the field's default applies rather than the parse failing. + Whether a substituted value is usable is decided where the value is used, not at parse. + `redirect_uris` elements are still parsed as URLs, as the authorization server compares + them against a client's requested `redirect_uri`. + """ + + redirect_uris: list[AnyUrl] | None = None + # RFC 7591 ยง3.2.1: the server may assign an auth method other than the one requested, + # including methods this SDK does not implement, or omit it. + token_endpoint_auth_method: str | None = None + grant_types: list[str] = list(DEFAULT_GRANT_TYPES) + # SEP-837: OIDC application_type. OIDC Registration ยง2 defines "web" and "native", but + # servers echo other strings or an explicit null; the value is informational here. + application_type: str | None = None + + # RFC 7591 ยง3.2.1: client_id is REQUIRED in a client information response - a body + # without one is not a registration, whatever else it echoes. + client_id: str + client_secret: str | None = None + client_id_issued_at: int | None = None + client_secret_expires_at: int | None = None + # SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an + # RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse. + issuer: str | None = None + + @model_validator(mode="before") + @classmethod + def _placeholder_members_read_as_omitted(cls, data: object) -> object: + # Servers dump unset members of their client record as null, or echo them as "", + # instead of omitting the keys. Either placeholder would otherwise fail the parse of a + # list field (or read "" as an unrecognized method) and discard an already-provisioned + # registration; a placeholder and an absent key mean the same thing. + if isinstance(data, dict): + members = cast(dict[str, Any], data) + return {key: value for key, value in members.items() if value is not None and value != ""} + return data + + def validate_scope(self, requested_scope: str | None) -> list[str] | None: + if requested_scope is None: + return None + requested_scopes = requested_scope.split(" ") + allowed_scopes = [] if self.scope is None else self.scope.split(" ") + for scope in requested_scopes: + if scope not in allowed_scopes: + raise InvalidScopeError(f"Client was not registered with scope {scope}") + return requested_scopes + + def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: + if redirect_uri is not None: + # Validate redirect_uri against client's registered redirect URIs + if not self.redirect_uris or redirect_uri not in self.redirect_uris: + raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client") + return redirect_uri + elif self.redirect_uris and len(self.redirect_uris) == 1: + return self.redirect_uris[0] + else: + raise InvalidRedirectUriError( + "redirect_uri must be specified unless the client has exactly one registered URI" + ) + + +class OAuthMetadata(BaseModel): + """RFC 8414 OAuth 2.0 Authorization Server Metadata. + See https://datatracker.ietf.org/doc/html/rfc8414#section-2 + """ + + model_config = ConfigDict(url_preserve_empty_path=True) + + issuer: AnyHttpUrl + authorization_endpoint: AnyHttpUrl + token_endpoint: AnyHttpUrl + registration_endpoint: AnyHttpUrl | None = None + scopes_supported: list[str] | None = None + response_types_supported: list[str] = ["code"] + response_modes_supported: list[str] | None = None + grant_types_supported: list[str] | None = None + token_endpoint_auth_methods_supported: list[str] | None = None + token_endpoint_auth_signing_alg_values_supported: list[str] | None = None + service_documentation: AnyHttpUrl | None = None + ui_locales_supported: list[str] | None = None + op_policy_uri: AnyHttpUrl | None = None + op_tos_uri: AnyHttpUrl | None = None + revocation_endpoint: AnyHttpUrl | None = None + revocation_endpoint_auth_methods_supported: list[str] | None = None + revocation_endpoint_auth_signing_alg_values_supported: list[str] | None = None + introspection_endpoint: AnyHttpUrl | None = None + introspection_endpoint_auth_methods_supported: list[str] | None = None + introspection_endpoint_auth_signing_alg_values_supported: list[str] | None = None + code_challenge_methods_supported: list[str] | None = None + client_id_metadata_document_supported: bool | None = None + authorization_response_iss_parameter_supported: bool | None = None + # SEP-990 / draft-ietf-oauth-identity-assertion-authz-grant ยง7.2: profiles whose grants the + # authorization server supports, e.g. `urn:ietf:params:oauth:grant-profile:id-jag`. + authorization_grant_profiles_supported: list[str] | None = None + + +class ProtectedResourceMetadata(BaseModel): + """RFC 9728 OAuth 2.0 Protected Resource Metadata. + See https://datatracker.ietf.org/doc/html/rfc9728#section-2 + """ + + model_config = ConfigDict(url_preserve_empty_path=True) + + resource: AnyHttpUrl + authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) + jwks_uri: AnyHttpUrl | None = None + scopes_supported: list[str] | None = None + bearer_methods_supported: list[str] | None = Field(default=["header"]) # MCP only supports header method + resource_signing_alg_values_supported: list[str] | None = None + resource_name: str | None = None + resource_documentation: AnyHttpUrl | None = None + resource_policy_uri: AnyHttpUrl | None = None + resource_tos_uri: AnyHttpUrl | None = None + # tls_client_certificate_bound_access_tokens default is False, but omitted here for clarity + tls_client_certificate_bound_access_tokens: bool | None = None + authorization_details_types_supported: list[str] | None = None + dpop_signing_alg_values_supported: list[str] | None = None + # dpop_bound_access_tokens_required default is False, but omitted here for clarity + dpop_bound_access_tokens_required: bool | None = None diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/auth_utils.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/auth_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..3ba880f40dd6b019dd057929e91bbf9319658272 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/auth_utils.py @@ -0,0 +1,80 @@ +"""Utilities for OAuth 2.0 Resource Indicators (RFC 8707) and PKCE (RFC 7636).""" + +import time +from urllib.parse import urlparse, urlsplit, urlunsplit + +from pydantic import AnyUrl, HttpUrl + + +def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str: + """Convert server URL to canonical resource URL per RFC 8707. + + RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". + Returns absolute URI with lowercase scheme/host for canonical form. + + Args: + url: Server URL to convert + + Returns: + Canonical resource URL string + """ + # Convert to string if needed + url_str = str(url) + + # Parse the URL and remove fragment, create canonical form + parsed = urlsplit(url_str) + canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower(), fragment="")) + + return canonical + + +def check_resource_allowed(requested_resource: str, configured_resource: str) -> bool: + """Check if a requested resource URL matches a configured resource URL. + + A requested resource matches if it has the same scheme, domain, port, + and its path starts with the configured resource's path. This allows + hierarchical matching where a token for a parent resource can be used + for child resources. + + Args: + requested_resource: The resource URL being requested + configured_resource: The resource URL that has been configured + + Returns: + True if the requested resource matches the configured resource + """ + # Parse both URLs + requested = urlparse(requested_resource) + configured = urlparse(configured_resource) + + # Compare scheme, host, and port (origin) + if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower(): + return False + + # Normalize trailing slashes before comparison so that + # "/foo" and "/foo/" are treated as equivalent. + requested_path = requested.path + configured_path = configured.path + if not requested_path.endswith("/"): + requested_path += "/" + if not configured_path.endswith("/"): + configured_path += "/" + + # Check hierarchical match: requested must start with configured path. + # The trailing-slash normalization ensures "/api123/" won't match "/api/". + return requested_path.startswith(configured_path) + + +def calculate_token_expiry(expires_in: int | str | None) -> float | None: + """Calculate token expiry timestamp from expires_in seconds. + + Args: + expires_in: Seconds until token expiration (may be string from some servers) + + Returns: + Unix timestamp when token expires, or None if no expiry specified + """ + if expires_in is None: + return None # pragma: no cover + # Defensive: handle servers that return expires_in as string + return time.time() + int(expires_in) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/context.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/context.py new file mode 100644 index 0000000000000000000000000000000000000000..13c145be5bb57b4a8f0e58b2f2c8b5e31984c9e1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/context.py @@ -0,0 +1,85 @@ +"""`BaseContext` - the user-facing per-request context. + +Composition over a `DispatchContext`: forwards the transport metadata, the +back-channel (`send_raw_request`/`notify`), progress reporting, and the cancel +event. Adds `meta` (the inbound request's `_meta` field). + +Satisfies `Outbound`, so `ClientPeer` can wrap it. Shared between client and +server: the server's `Context` extends this with `lifespan`/`connection`; +`ClientContext` is just an alias. +""" + +from collections.abc import Mapping +from typing import Any, Generic + +import anyio +from mcp_types import RequestParamsMeta +from typing_extensions import TypeVar + +from mcp.shared.dispatcher import CallOptions, DispatchContext +from mcp.shared.transport_context import TransportContext + +__all__ = ["BaseContext"] + +TransportT = TypeVar("TransportT", bound=TransportContext, default=TransportContext, covariant=True) + + +class BaseContext(Generic[TransportT]): + """Per-request context wrapping a `DispatchContext`. + + `ServerRunner` constructs one per inbound request and passes it to the + user's handler. + """ + + def __init__(self, dctx: DispatchContext[TransportT], meta: RequestParamsMeta | None = None) -> None: + self._dctx = dctx + self._meta = meta + + @property + def transport(self) -> TransportT: + """Transport-specific metadata for this inbound request.""" + return self._dctx.transport + + @property + def cancel_requested(self) -> anyio.Event: + """Set when the peer sends `notifications/cancelled` for this request.""" + return self._dctx.cancel_requested + + @property + def can_send_request(self) -> bool: + """Whether the back-channel can currently deliver server-initiated requests. + + `False` when the transport has no back-channel, or when the underlying + dispatch context has been closed because the inbound request finished. + """ + return self._dctx.can_send_request + + @property + def meta(self) -> RequestParamsMeta | None: + """The inbound request's `_meta` field, if present.""" + return self._meta + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + """Send a request to the peer on the back-channel. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: `can_send_request` is `False`. + """ + return await self._dctx.send_raw_request(method, params, opts) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + """Send a notification to the peer on the back-channel.""" + await self._dctx.notify(method, params, opts) + + async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + """Report progress for this request, if the peer supplied a progress token. + + A no-op when no token was supplied. + """ + await self._dctx.progress(progress, total, message) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/direct_dispatcher.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/direct_dispatcher.py new file mode 100644 index 0000000000000000000000000000000000000000..e17283afa224ad41d04cf5494205bb92d1f7f182 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/direct_dispatcher.py @@ -0,0 +1,334 @@ +"""In-memory `Dispatcher` that wires two peers together with no transport. + +`DirectDispatcher` is the simplest possible `Dispatcher` implementation: a +request on one side directly invokes the other side's `on_request`. There is no +serialization, no JSON-RPC framing, and no streams. It exists to: + +* prove the `Dispatcher` Protocol is implementable without JSON-RPC +* provide a fast substrate for testing the layers above the dispatcher + (`ServerRunner`, `Context`, `Connection`) without wire-level moving parts +* embed a server in-process when the JSON-RPC overhead is unnecessary + +Like `JSONRPCDispatcher`, this is an exception-to-error boundary: a handler +exception surfaces to the caller as `MCPError`. The `raise_handler_exceptions` +knob controls whether unmapped exceptions are sanitized (matching the wire +path) or chained as ``__cause__`` for in-process debugging. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from typing import Any + +import anyio +import anyio.abc +from mcp_types import CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, REQUEST_TIMEOUT, RequestId +from pydantic import ValidationError + +from mcp.shared._compat import resync_tracer +from mcp.shared.dispatcher import ( + CallOptions, + OnNotify, + OnNotifyIntercept, + OnRequest, + ProgressFnT, + coerce_request_id, + run_notify_intercept, +) +from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.shared.message import MessageMetadata +from mcp.shared.transport_context import TransportContext + +logger = logging.getLogger(__name__) + +__all__ = ["DirectDispatcher", "create_direct_dispatcher_pair"] + +DIRECT_TRANSPORT_KIND = "direct" + + +_Request = Callable[[str, Mapping[str, Any] | None, CallOptions | None], Awaitable[dict[str, Any]]] +_Notify = Callable[[str, Mapping[str, Any] | None], Awaitable[None]] + + +@dataclass +class _DirectDispatchContext: + """`DispatchContext` for an inbound request on a `DirectDispatcher`. + + The back-channel callables target the *originating* side, so a handler's + `send_raw_request` reaches the peer that made the inbound request. + """ + + transport: TransportContext + _back_request: _Request + _back_notify: _Notify + request_id: RequestId | None = None + """The caller-supplied `CallOptions["request_id"]`, else a dispatcher-synthesized + id for requests; `None` for notifications.""" + message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework + """Always `None`: in-memory dispatch attaches no transport metadata.""" + _on_progress: ProgressFnT | None = None + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + + @property + def can_send_request(self) -> bool: + return self.transport.can_send_request + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + await self._back_notify(method, params) + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + if not self.can_send_request: + raise NoBackChannelError(method) + return await self._back_request(method, params, opts) + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + if self._on_progress is not None: + await self._on_progress(progress, total, message) + + +class DirectDispatcher: + """A `Dispatcher` that calls a peer's handlers directly, in-process. + + Two instances are wired together with `create_direct_dispatcher_pair`; each + holds a reference to the other. `send_raw_request` on one awaits the peer's + `on_request`. `run` parks until `close` is called. + + Lifecycle mirrors `JSONRPCDispatcher`: `send_raw_request` requires `run()` + to have started, and once a side has closed - via `close()` or `run()` + ending - `send_raw_request` raises `MCPError` (`CONNECTION_CLOSED`) and + inbound requests fail the peer's call the same way instead of invoking the + handler. Notifications are fire-and-forget in both directions: after close + they are silently dropped. + """ + + def __init__(self, transport_ctx: TransportContext, *, raise_handler_exceptions: bool = True): + self._transport_ctx = transport_ctx + self._raise_handler_exceptions = raise_handler_exceptions + self._peer: DirectDispatcher | None = None + self._on_request: OnRequest | None = None + self._on_notify: OnNotify | None = None + self._on_notify_intercept: OnNotifyIntercept | None = None + self._next_id = 0 + self._in_flight_ids: set[RequestId] = set() + self._ready = anyio.Event() + self._close_event = anyio.Event() + self._running = False + self._closed = False + + def connect_to(self, peer: DirectDispatcher) -> None: + self._peer = peer + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + """Send a request by invoking the peer's `on_request` directly. + + Raises: + MCPError: The peer's handler raised; `REQUEST_TIMEOUT` if + `opts["timeout"]` elapsed; `CONNECTION_CLOSED` if either + side has closed. + RuntimeError: Called before `run()`. + """ + if self._peer is None: + raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()") + # Post-close sends get the same CONNECTION_CLOSED contract as JSONRPCDispatcher. + if self._closed: + raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") + if not self._running: + raise RuntimeError("DirectDispatcher.send_raw_request called before run()") + return await self._peer._dispatch_request(method, params, opts) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + """Send a notification by invoking the peer's `on_notify` directly. + + Fire-and-forget: usable before `run()` (delivery waits for the peer to + start), and after close it is silently dropped, matching + `JSONRPCDispatcher.notify`. `opts` is accepted for `Dispatcher` + conformance; there is no HTTP layer here so `headers` is ignored. + """ + if self._peer is None: + raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()") + if self._closed: + logger.debug("dropped notification %r on closed DirectDispatcher", method) + return + await self._peer._dispatch_notify(method, params) + + async def run( + self, + on_request: OnRequest, + on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, + *, + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, + ) -> None: + """Mark this side ready and park until `close()` is called. + + Single-shot, like `JSONRPCDispatcher.run`: once it returns the + dispatcher stays closed and cannot be restarted. + """ + try: + self._on_request = on_request + self._on_notify = on_notify + self._on_notify_intercept = on_notify_intercept + self._running = True + self._ready.set() + task_status.started() + await self._close_event.wait() + finally: + self._running = False + self._closed = True + # run() may end via cancellation without close() ever being + # called; setting the event wakes `_wait_ready` waiters so they + # observe the closed state instead of parking forever. + self._close_event.set() + + def close(self) -> None: + self._closed = True + self._close_event.set() + + def _make_context( + self, on_progress: ProgressFnT | None = None, request_id: RequestId | None = None + ) -> _DirectDispatchContext: + assert self._peer is not None + peer = self._peer + return _DirectDispatchContext( + transport=self._transport_ctx, + _back_request=lambda m, p, o: peer._dispatch_request(m, p, o), + _back_notify=lambda m, p: peer._dispatch_notify(m, p), + request_id=request_id, + _on_progress=on_progress, + ) + + async def _wait_ready(self) -> None: + """Park until `run()` has started, waking early if this side closes. + + Raises: + MCPError: `CONNECTION_CLOSED` if this side has closed. + """ + if not self._ready.is_set() and not self._close_event.is_set(): + async with anyio.create_task_group() as tg: + + async def wake_on(event: anyio.Event) -> None: + await event.wait() + tg.cancel_scope.cancel() + + tg.start_soon(wake_on, self._ready) + tg.start_soon(wake_on, self._close_event) + if self._closed: + raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") + + async def _dispatch_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None, + ) -> dict[str, Any]: + opts = opts or {} + try: + with anyio.fail_after(opts.get("timeout")): + # Inside the timeout scope, so a configured timeout also bounds + # waiting on a peer whose run() has not started yet. + await self._wait_ready() + assert self._on_request is not None + supplied_id = opts.get("request_id") + if supplied_id is not None: + request_id: RequestId = supplied_id + # Collisions use the same coerced domain as JSONRPCDispatcher's + # pending keys, so this in-memory stand-in raises for exactly + # the ids the wire dispatcher would; the context still sees + # the verbatim value. + in_flight_key = coerce_request_id(request_id) + if in_flight_key in self._in_flight_ids: + raise ValueError(f"request id {request_id!r} is already in flight") + else: + # Synthesize an id (the DispatchContext contract reserves None + # for notifications), minting past any key a supplied id + # occupies: the collision error is reserved for the caller + # who actually chose the id. + self._next_id += 1 + while self._next_id in self._in_flight_ids: + self._next_id += 1 + request_id = self._next_id + in_flight_key = request_id + self._in_flight_ids.add(in_flight_key) + dctx = self._make_context(on_progress=opts.get("on_progress"), request_id=request_id) + try: + return await self._on_request(dctx, method, params) + except MCPError: + raise + except ValidationError as e: + # Same shape JSONRPCDispatcher writes, so runner-over-direct + # tests see what runner-over-JSONRPC would. + raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") from e + except Exception as e: + # Single owner of the in-proc exception-to-error policy (mirrors + # JSONRPCDispatcher / `_streamable_http_modern._to_jsonrpc_response` + # for the wire paths). True chains the original for in-process + # debugging; False sanitizes to match the wire path's leak guard. + if self._raise_handler_exceptions: + raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e + logger.exception("request handler raised") + raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None + finally: + self._in_flight_ids.discard(in_flight_key) + except TimeoutError: + raise MCPError( + code=REQUEST_TIMEOUT, + message=f"Timed out after {opts.get('timeout')}s waiting for {method!r}", + ) from None + finally: + await resync_tracer() + + async def _dispatch_notify(self, method: str, params: Mapping[str, Any] | None) -> None: + try: + await self._wait_ready() + except MCPError: + # Notifications are fire-and-forget: a notify to a closed peer is + # dropped, not raised back into the sender's call. + logger.debug("dropped notification %r to closed DirectDispatcher", method) + return + if run_notify_intercept(self._on_notify_intercept, method, params): + return + assert self._on_notify is not None + dctx = self._make_context() + await self._on_notify(dctx, method, params) + + +def create_direct_dispatcher_pair( + *, + can_send_request: bool = True, + headers: Mapping[str, str] | None = None, + raise_handler_exceptions: bool = True, +) -> tuple[DirectDispatcher, DirectDispatcher]: + """Create two `DirectDispatcher` instances wired to each other. + + Args: + can_send_request: Sets `TransportContext.can_send_request` on both + sides. Pass `False` to simulate a transport with no back-channel. + headers: Sets `TransportContext.headers` on both sides. + raise_handler_exceptions: When `True` (the default - this is an + in-process debugging substrate), an unmapped handler exception + reaches the caller as `MCPError` with the original chained as + ``__cause__``. When `False` it is sanitized to an opaque + `INTERNAL_ERROR` so the in-process path matches the wire. + + Returns: + A `(client, server)` pair. The wiring is symmetric, so the roles + are conventional only. + """ + ctx = TransportContext(kind=DIRECT_TRANSPORT_KIND, can_send_request=can_send_request, headers=headers) + client = DirectDispatcher(ctx, raise_handler_exceptions=raise_handler_exceptions) + server = DirectDispatcher(ctx, raise_handler_exceptions=raise_handler_exceptions) + client.connect_to(server) + server.connect_to(client) + return client, server diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/dispatcher.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/dispatcher.py new file mode 100644 index 0000000000000000000000000000000000000000..f109638f2aae243153b4cd82edffe68b92c3eedb --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/dispatcher.py @@ -0,0 +1,276 @@ +"""Dispatcher Protocol - the call/return boundary between transports and handlers. + +A Dispatcher turns a duplex message channel into two things: + +* an outbound API: `send_raw_request(method, params)` and `notify(method, params)` +* an inbound pump: `run(on_request, on_notify)` that drives the receive loop + and invokes the supplied handlers for each incoming request/notification + +It is deliberately *not* MCP-aware. Method names are strings, params and +results are `dict[str, Any]`. The MCP type layer (request/result models, +capability negotiation, `Context`) sits above this; the wire encoding +(JSON-RPC, gRPC, in-process direct calls) sits below it. + +See `JSONRPCDispatcher` for the production implementation and +`DirectDispatcher` for an in-memory implementation used in tests and for +embedding a server in-process. +""" + +import logging +from collections.abc import Awaitable, Callable, Mapping +from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable + +import anyio +import anyio.abc +from mcp_types import RequestId + +from mcp.shared.message import MessageMetadata +from mcp.shared.transport_context import TransportContext + +logger = logging.getLogger(__name__) + +__all__ = [ + "CallOptions", + "DispatchContext", + "Dispatcher", + "OnNotify", + "OnNotifyIntercept", + "OnRequest", + "Outbound", + "ProgressFnT", + "as_request_id", + "coerce_request_id", + "run_notify_intercept", +] + +TransportT_co = TypeVar("TransportT_co", bound=TransportContext, covariant=True) + + +def as_request_id(value: object) -> RequestId | None: + """Narrow an untyped wire value to a `RequestId`, or None; rejects bool (True would alias request id 1).""" + if isinstance(value, str | int) and not isinstance(value, bool): + return value + return None + + +def coerce_request_id(request_id: RequestId) -> RequestId: + """Coerce a stringified int request id back to int so a peer-echoed id still correlates (matches the TS SDK). + + This is the collision/correlation domain dispatchers share: "7" and 7 are one + id for correlation purposes, even where the wire carries the verbatim value. + """ + if isinstance(request_id, str): + try: + return int(request_id) + except ValueError: + pass + return request_id + + +class ProgressFnT(Protocol): + """Callback invoked when a progress notification arrives for a pending request.""" + + async def __call__(self, progress: float, total: float | None, message: str | None) -> None: ... + + +class CallOptions(TypedDict, total=False): + """Per-call options for `Outbound.send_raw_request`. + + All keys are optional. Dispatchers ignore keys they do not understand. + """ + + request_id: RequestId + """Send the request under this caller-supplied id instead of a dispatcher-minted one. + + The peer sees the value verbatim ("7" stays a string). A value that collides + with one of the sender's own in-flight request ids raises `ValueError`. + Callers that need to know a request's id before its result arrives (a + `subscriptions/listen` stream is demultiplexed by it) mint their own ids + here; string ids that don't parse as integers can never collide with the + dispatcher's minted sequence. Per the class contract, dispatchers that + predate this key ignore it and mint as usual. + """ + + timeout: float + """Seconds to wait for a result before raising and sending `notifications/cancelled`.""" + + cancel_on_abandon: bool + """Whether abandoning this request (timeout or caller cancellation) sends `notifications/cancelled`. + + Defaults to `True`. Set `False` for requests the protocol forbids cancelling, such as `initialize`. + Also suppressed when resumption hints reach the transport, or when the request was never written. + """ + + on_progress: ProgressFnT + """Receive `notifications/progress` updates for this request.""" + + resumption_token: str + """Opaque token to resume a previously interrupted request. + + Client-side, streamable-HTTP only. Ignored by server dispatchers and other + transports, and also ignored (with a debug log) for requests sent from a + `DispatchContext`, where routing onto the inbound request's stream takes + precedence. Supports protocol version 2025-11-25 and earlier; SSE-stream + resumption is removed in the next protocol revision. + """ + + on_resumption_token: Callable[[str], Awaitable[None]] + """Receive a resumption token when the transport issues one for this request. + + Client-side, streamable-HTTP only. Ignored by server dispatchers and other + transports, and also ignored (with a debug log) for requests sent from a + `DispatchContext`, where routing onto the inbound request's stream takes + precedence. Supports protocol version 2025-11-25 and earlier; SSE-stream + resumption is removed in the next protocol revision. + """ + + headers: dict[str, str] + """Transport-layer hint: HTTP transports merge these onto the outgoing request; non-HTTP transports ignore.""" + + +@runtime_checkable +class Outbound(Protocol): + """Anything that can send requests and notifications to the peer. + + Both `Dispatcher` (top-level outbound) and `DispatchContext` (back-channel + during an inbound request) extend this. The MCP type layer (`ClientPeer`, + `Connection`) builds typed `send_request` / convenience methods on top of + this raw channel. + """ + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + """Send a request and await its raw result dict. + + Raises: + MCPError: If the peer responded with an error, or the handler + raised. Implementations normalize all handler exceptions to + `MCPError` so callers see a single exception type. + """ + ... + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + """Send a fire-and-forget notification.""" + ... + + +class DispatchContext(Outbound, Protocol[TransportT_co]): + """Per-request context handed to `on_request` / `on_notify`. + + Carries the transport metadata for the inbound message and provides the + back-channel for sending requests/notifications to the peer while handling + it. `send_raw_request` raises `NoBackChannelError` if `can_send_request` + is `False`. + """ + + @property + def transport(self) -> TransportT_co: + """Transport-specific metadata for this inbound message.""" + ... + + @property + def can_send_request(self) -> bool: + """Whether the back-channel can currently deliver server-initiated requests. + + `False` when the transport has no back-channel, or when this context has + been closed (the inbound request finished). `send_raw_request` raises + `NoBackChannelError` exactly when this is `False`. + """ + ... + + @property + def request_id(self) -> RequestId | None: + """The id of the inbound request, or `None` for a notification. + + For JSON-RPC this is the wire `id` field. Handlers thread it through + as `related_request_id` on outbound notifications so HTTP transports + can route them onto the originating request's response stream. + """ + ... + + @property + def message_metadata(self) -> MessageMetadata: + """The metadata the transport attached to this inbound message, if any. + + This is `SessionMessage.metadata` passed through verbatim: HTTP + transports attach `ServerMessageMetadata` (the HTTP request, SSE + stream-close callbacks); stdio and in-memory dispatch attach nothing. + Tied to the `SessionMessage` wire format - goes away when transports + stop delivering messages that way. + """ + # TODO(maxisbey): remove for context rework + ... + + @property + def cancel_requested(self) -> anyio.Event: + """Set when the peer sends `notifications/cancelled` for this request.""" + ... + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + """Report progress for the inbound request, if the peer supplied a progress token. + + A no-op when no token was supplied. + """ + ... + + +OnRequest = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[dict[str, Any]]] +"""Handler for inbound requests: `(ctx, method, params) -> result`. Raise `MCPError` to send an error response.""" + +OnNotify = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[None]] +"""Handler for inbound notifications: `(ctx, method, params)`.""" + +OnNotifyIntercept = Callable[[str, Mapping[str, Any] | None], bool] +"""Synchronous receive-order intercept for inbound notifications: `(method, params) -> consumed`. + +Runs before `on_notify` is scheduled so correlation state advances in wire order +relative to response resolution (the client's listen demux depends on this). +Returning True consumes the notification. Must not block the receive path. +""" + + +def run_notify_intercept(intercept: OnNotifyIntercept | None, method: str, params: Mapping[str, Any] | None) -> bool: + """Invoke `intercept`, containing a raise to that one notification (never the receive loop).""" + if intercept is None: + return False + try: + return intercept(method, params) + except Exception: + logger.exception("notification intercept raised; passing %r through", method) + return False + + +class Dispatcher(Outbound, Protocol[TransportT_co]): + """A duplex request/notification channel with call-return semantics. + + Implementations own correlation of outbound requests to inbound results, the + receive loop, per-request concurrency, and cancellation/progress wiring. + + The lifecycle surface is provisional; `run()` may change before v2 stable. + """ + + async def run( + self, + on_request: OnRequest, + on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, + *, + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, + ) -> None: + """Drive the receive loop until the underlying channel closes. + + Each inbound request is dispatched to `on_request` in its own task; + the returned dict (or raised `MCPError`) is sent back as the response. + Implementations MUST offer every inbound notification to + `on_notify_intercept` synchronously in receive order (via + `run_notify_intercept`), handing only unconsumed ones to `on_notify`. + + `task_status.started()` is called once the dispatcher is ready to + accept `send_request`/`notify` calls, so callers can use + `await tg.start(dispatcher.run, on_request, on_notify)`. + """ + ... diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/exceptions.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..c2a7fd44e72072d33134c3b9e84efab52dbd3e6d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/exceptions.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from typing import Any, cast + +from mcp_types import INVALID_REQUEST, URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError + + +class MCPDeprecationWarning(UserWarning): + """A custom deprecation warning for the MCP SDK. + + Unlike the built-in `DeprecationWarning`, this inherits from `UserWarning` so + it is shown by default, helping users discover deprecated features without + enabling warnings explicitly. + + Reference: https://sethmlarson.dev/deprecations-via-warnings-dont-work-for-python-libraries + """ + + +class MCPError(Exception): + """Exception type raised when an error arrives over an MCP connection.""" + + error: ErrorData + + def __init__(self, code: int, message: str, data: Any = None): + super().__init__(code, message, data) + if data is not None: + self.error = ErrorData(code=code, message=message, data=data) + else: + self.error = ErrorData(code=code, message=message) + + @property + def code(self) -> int: + return self.error.code + + @property + def message(self) -> str: + return self.error.message + + @property + def data(self) -> Any: + return self.error.data + + @classmethod + def from_jsonrpc_error(cls, error: JSONRPCError) -> MCPError: + return cls.from_error_data(error.error) + + @classmethod + def from_error_data(cls, error: ErrorData) -> MCPError: + return cls(code=error.code, message=error.message, data=error.data) + + def __str__(self) -> str: + return self.message + + +class NoBackChannelError(MCPError): + """Raised when a server-initiated request has no channel that can deliver it. + + Raised by `DispatchContext.send_raw_request` when its request-scoped channel + reports `TransportContext.can_send_request` as `False` (the cases are + documented on that field), and by a connection's standalone channel when it + has none; serializes to an `INVALID_REQUEST` error response. + """ + + def __init__(self, method: str): + super().__init__( + code=INVALID_REQUEST, + message=( + f"Cannot send {method!r}: this transport context has no back-channel for server-initiated requests." + ), + ) + self.method = method + + +class UrlElicitationRequiredError(MCPError): + """Specialized error for when a tool requires URL mode elicitation(s) before proceeding. + + Servers can raise this error from tool handlers to indicate that the client + must complete one or more URL elicitations before the request can be processed. + + Example: + ```python + raise UrlElicitationRequiredError([ + ElicitRequestURLParams( + message="Authorization required for your files", + url="https://example.com/oauth/authorize", + elicitation_id="auth-001" + ) + ]) + ``` + """ + + def __init__(self, elicitations: list[ElicitRequestURLParams], message: str | None = None): + """Initialize UrlElicitationRequiredError.""" + if message is None: + message = f"URL elicitation{'s' if len(elicitations) > 1 else ''} required" + + self._elicitations = elicitations + + super().__init__( + code=URL_ELICITATION_REQUIRED, + message=message, + data={"elicitations": [e.model_dump(by_alias=True, exclude_none=True) for e in elicitations]}, + ) + + @property + def elicitations(self) -> list[ElicitRequestURLParams]: + """The list of URL elicitations required before the request can proceed.""" + return self._elicitations + + @classmethod + def from_error(cls, error: ErrorData) -> UrlElicitationRequiredError: + """Reconstruct from an ErrorData received over the wire.""" + if error.code != URL_ELICITATION_REQUIRED: + raise ValueError(f"Expected error code {URL_ELICITATION_REQUIRED}, got {error.code}") + + data = cast(dict[str, Any], error.data or {}) + raw_elicitations = cast(list[dict[str, Any]], data.get("elicitations", [])) + elicitations = [ElicitRequestURLParams.model_validate(e) for e in raw_elicitations] + return cls(elicitations, error.message) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/extension.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/extension.py new file mode 100644 index 0000000000000000000000000000000000000000..283e9ba89b2bda448604ffc0811318a919279504 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/extension.py @@ -0,0 +1,28 @@ +"""Extension-identifier grammar shared by the server and client extension surfaces.""" + +from __future__ import annotations + +import re +from typing import Any + +__all__ = ["validate_extension_identifier"] + +# Extension identifiers follow the `_meta` key grammar with a mandatory prefix +# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a +# letter and ending with a letter or digit (hyphens interior), then `/`, then a +# name that starts and ends alphanumeric (`.`/`_`/`-` interior). +_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?" +_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" +_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}") + + +def validate_extension_identifier(identifier: Any, *, owner: str) -> None: + """Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string. + + SEP-2133 requires extension identifiers to carry a reverse-DNS prefix. + """ + if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier): + raise TypeError( + f"{owner}.identifier must be a `vendor-prefix/name` string " + f"(reverse-DNS prefix required), got {identifier!r}" + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/inbound.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/inbound.py new file mode 100644 index 0000000000000000000000000000000000000000..c28aa7fb71c68822e28115da04416e829e388fc1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/inbound.py @@ -0,0 +1,581 @@ +"""Inbound request classification for the modern per-request-envelope path. + +Pure module: no I/O, no transport, no `mcp.server` imports. Runs the +validation ladder against a decoded JSON-RPC body and returns either an +:class:`InboundModernRoute` (every rung passed) or an +:class:`InboundLadderRejection` (the first rung that failed). Callers map a +rejection's `code` through :data:`ERROR_CODE_HTTP_STATUS` to pick the HTTP +status. + +Also hosts the shared header-value codec and the `x-mcp-header` schema +validator so client emit and server validate read the same source of truth. +""" + +import base64 +import binascii +import re +from collections.abc import Iterable, Iterator, Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Final, cast + +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + UnsupportedProtocolVersionErrorData, +) +from mcp_types.jsonrpc import ( + HEADER_MISMATCH, + INVALID_PARAMS, + INVALID_REQUEST, + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, + PARSE_ERROR, + UNSUPPORTED_PROTOCOL_VERSION, +) +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +__all__ = [ + "ERROR_CODE_HTTP_STATUS", + "InboundLadderRejection", + "InboundModernRoute", + "MCP_METHOD_HEADER", + "MCP_NAME_HEADER", + "MCP_PARAM_HEADER_PREFIX", + "MCP_PROTOCOL_VERSION_HEADER", + "NAME_BEARING_METHODS", + "X_MCP_HEADER_KEY", + "classify_inbound_request", + "decode_header_value", + "encode_header_value", + "find_duplicated_routing_header", + "find_invalid_x_mcp_header", + "mcp_param_headers", + "validate_mcp_param_headers", + "x_mcp_header_map", +] + +MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version" +"""Canonical lowercase name of the HTTP header carrying the MCP protocol version.""" + +MCP_METHOD_HEADER: Final = "mcp-method" +"""Canonical lowercase name of the HTTP header carrying the JSON-RPC method.""" + +MCP_NAME_HEADER: Final = "mcp-name" +"""Canonical lowercase name of the HTTP header carrying the resource name (tool/prompt/resource URI).""" + +X_MCP_HEADER_KEY: Final = "x-mcp-header" +"""JSON-Schema property annotation that designates an `Mcp-Param-*` HTTP header.""" + +NAME_BEARING_METHODS: Final[Mapping[str, str]] = MappingProxyType( + { + "tools/call": "name", + "prompts/get": "name", + "resources/read": "uri", + } +) +"""Method โ†’ params key whose value is mirrored as the `Mcp-Name` HTTP header. + +Shared by client emit (which header to send) and server validate (which body +field to compare against), so both ends agree on the field by construction. +""" + +_B64_SENTINEL = re.compile(r"^=\?base64\?(?P.*)\?=$") +# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value. +_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$") +# RFC 9110 ยง5.6.2 token: the only characters permitted in an HTTP field name. +_RFC9110_TOKEN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +# JSON-Schema types the spec permits to carry `x-mcp-header` (transports.mdx +# ยงCustom Headers). `number` is explicitly forbidden โ€” floatโ†’str is not +# portable across implementations. +_X_MCP_HEADER_PRIMITIVE_TYPES: Final = frozenset({"string", "integer", "boolean"}) + +# JSON Schema 2020-12 applicator keywords whose values are themselves schema +# positions, grouped by value shape. `properties` is handled separately as the +# only keyword that preserves the statically-reachable chain; every keyword +# here drops the chain to None. Instance-data keywords (`default`, `examples`, +# `const`, `enum`) and `$ref`/`$dynamicRef` are deliberately absent so the +# walk never mistakes data for an annotation and never dereferences. +_SUBSCHEMA_SINGLE: Final = frozenset( + { + "items", + "contains", + "unevaluatedItems", + "additionalProperties", + "propertyNames", + "unevaluatedProperties", + "not", + "if", + "then", + "else", + "contentSchema", + } +) +_SUBSCHEMA_LIST: Final = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) +_SUBSCHEMA_MAP: Final = frozenset({"patternProperties", "dependentSchemas", "$defs", "definitions"}) + + +def _walk_schema_positions(root: Any) -> Iterator[tuple[tuple[str, ...] | None, dict[str, Any]]]: + """Yield `(properties_path, schema)` for every schema position in `root`. + + `properties_path` is the chain of `properties` keys from the root to the + position, or `None` once any other applicator keyword has been crossed. + The root itself yields `()`. Only the JSON Schema 2020-12 applicators + listed above are entered; instance-data keywords are not, and `$ref` is + not dereferenced, so the walk terminates on any finite JSON value. An + explicit stack keeps the function total even on pathologically deep input. + """ + stack: list[tuple[tuple[str, ...] | None, Any]] = [((), root)] + while stack: + path, node = stack.pop() + if not isinstance(node, dict): + continue + schema = cast(dict[str, Any], node) + yield path, schema + for kw, val in schema.items(): + if kw == "properties" and isinstance(val, dict): + for name, sub in cast(dict[str, Any], val).items(): + stack.append(((*path, name) if path is not None else None, sub)) + elif kw in _SUBSCHEMA_SINGLE: + stack.append((None, val)) + elif kw in _SUBSCHEMA_LIST and isinstance(val, list): + stack.extend((None, sub) for sub in cast(list[Any], val)) + elif kw in _SUBSCHEMA_MAP and isinstance(val, dict): + stack.extend((None, sub) for sub in cast(dict[str, Any], val).values()) + + +def encode_header_value(value: str) -> str: + """Wrap `value` in the `=?base64?...?=` sentinel when it would not survive an HTTP field round-trip. + + Plain printable ASCII without leading/trailing whitespace passes verbatim; + anything else (control chars, non-ASCII, edge whitespace, or a value that + already looks like the sentinel) is base64-wrapped so the receiver can + recover the exact bytes. + """ + if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value): + return value + return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?=" + + +def decode_header_value(value: str | None) -> str | None: + """Inverse of :func:`encode_header_value`. + + Returns the value verbatim unless it carries the `=?base64?...?=` sentinel, + in which case the payload is decoded as UTF-8. A malformed sentinel (bad + base64, non-canonical base64, or bad UTF-8) yields `None` so a corrupt + header never matches a body value by accident. `None` in โ†’ `None` out so + callers can pass `headers.get(...)` directly. + """ + if value is None: + return None + m = _B64_SENTINEL.fullmatch(value) + if m is None: + return value + payload = m.group("payload") + try: + decoded = base64.b64decode(payload, validate=True) + except binascii.Error: + return None + # Reject non-canonical base64 (e.g. non-zero trailing bits), which + # `validate=True` tolerates; the encoder only ever emits canonical form. + if base64.b64encode(decoded).decode("ascii") != payload: + return None + try: + return decoded.decode("utf-8") + except UnicodeDecodeError: + return None + + +def find_invalid_x_mcp_header(input_schema: Any) -> str | None: + """Return a reason string if any `x-mcp-header` annotation in `input_schema` is invalid; else `None`. + + Walks every JSON Schema 2020-12 schema position. An annotation is valid + only when it sits on a property statically reachable from the root via a + chain of pure `properties` keys, names a non-empty RFC 9110 token, is on + an integer/string/boolean property, and is case-insensitively unique + across the whole schema. A `None` / non-mapping schema has no schema + positions and returns `None`. + """ + seen: dict[str, str] = {} + for path, schema in _walk_schema_positions(input_schema): + if X_MCP_HEADER_KEY not in schema: + continue + if not path: # None (off the pure-properties chain) or () (the root itself) + return f"{X_MCP_HEADER_KEY} found at a schema position not reachable via a pure `properties` chain" + where = ".".join(path) + header = schema[X_MCP_HEADER_KEY] + # Wrong type and malformed value are distinct failures with distinct messages: the + # non-str arm returns before any interpolation, because `repr` of an arbitrary + # schema value is not total (a large `int` exceeds `sys.get_int_max_str_digits`). + if not isinstance(header, str): + return f"property {where!r}: {X_MCP_HEADER_KEY} must be a string, not {type(header).__name__}" + if not _RFC9110_TOKEN.fullmatch(header): + return f"property {where!r}: {X_MCP_HEADER_KEY} {header!r} is not an RFC 9110 token" + prop_type = schema.get("type") + if not isinstance(prop_type, str): + return ( + f"property {where!r}: {X_MCP_HEADER_KEY} is only permitted on " + f"integer/string/boolean properties (the type keyword is {type(prop_type).__name__}, not a string)" + ) + if prop_type not in _X_MCP_HEADER_PRIMITIVE_TYPES: + return ( + f"property {where!r}: {X_MCP_HEADER_KEY} is only permitted on " + f"integer/string/boolean properties (got {prop_type!r})" + ) + lower = header.lower() + if lower in seen: + return f"{X_MCP_HEADER_KEY} {header!r} on property {where!r} duplicates property {seen[lower]!r}" + seen[lower] = where + return None + + +MCP_PARAM_HEADER_PREFIX: Final = "Mcp-Param-" +"""Prefix the `x-mcp-header` token is joined to, forming the per-parameter HTTP header name.""" + + +def x_mcp_header_map(input_schema: Any) -> dict[tuple[str, ...], str]: + """Map each property carrying a valid `x-mcp-header` to its annotation token, keyed by property path. + + The key is the chain of `properties` keys from the schema root to the + annotated property; a top-level property has a one-element path, a nested + one a longer path. Call only on a schema that + :func:`find_invalid_x_mcp_header` accepts; an invalid schema yields an + undefined subset. + """ + return {path: token for path, token, _ in _annotated_positions(input_schema)} + + +def _annotated_positions(input_schema: Any) -> Iterator[tuple[tuple[str, ...], str, dict[str, Any]]]: + """Yield `(path, token, schema)` for every statically-reachable `x-mcp-header` annotation. + + Shared by client emit and server validate so both ends agree on what counts as a declared header. + """ + for path, schema in _walk_schema_positions(input_schema): + if path and isinstance(token := schema.get(X_MCP_HEADER_KEY), str): + yield path, token, schema + + +def _render_header_scalar(value: Any) -> str | None: + """Render `value` the way the client mirrors it into a header, or `None` when no rendering exists. + + Shared by emit and validate so both sides agree on what is mirrorable: + non-primitives and ints beyond CPython's int-to-str digit limit are not. + """ + if isinstance(value, bool): + return "true" if value else "false" + if not isinstance(value, str | int | float): + return None + try: + return str(value) + except ValueError: + return None + + +def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapping[str, Any]) -> dict[str, str]: + """Build the `Mcp-Param-*` headers a `tools/call` mirrors from its arguments. + + For each `(path, token)` in `header_map`, read the value at that property + path in `arguments` and, when it is present and not `None`, emit + `Mcp-Param-` carrying it: `bool` as `true`/`false`, other scalars via + `str`, each passed through :func:`encode_header_value` so a non-token value + is base64-wrapped. A path that hits a missing key or a non-mapping node is + skipped, matching the spec's "omit the header when no value is present", + as is a value with no header rendering. + """ + headers: dict[str, str] = {} + for path, token in header_map.items(): + value = _value_at_path(arguments, path) + if value is None or (rendered := _render_header_scalar(value)) is None: + continue + headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered) + return headers + + +def _value_at_path(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any: + """Read the value at a `properties`-key path in `arguments`, or `None` if any step is missing or non-mapping.""" + node: Any = arguments + for key in path: + if not isinstance(node, Mapping): + return None + node = cast("Mapping[str, Any]", node).get(key) + return node + + +# INTERNAL_ERROR is deliberately unmapped (โ†’ HTTP 200): the spec assigns no status to +# -32603, and whether handler-origin errors get 5xx is an open S4 question โ€” see TODO(L66). +ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType( + { + PARSE_ERROR: 400, + INVALID_REQUEST: 400, + INVALID_PARAMS: 400, + HEADER_MISMATCH: 400, + MISSING_REQUIRED_CLIENT_CAPABILITY: 400, + UNSUPPORTED_PROTOCOL_VERSION: 400, + METHOD_NOT_FOUND: 404, + } +) +"""HTTP status to send for a JSON-RPC `error.code`. + +Consulted for classifier-origin *and* handler-origin errors, so one table +decides the wire status regardless of where the error was produced. Unmapped +codes fall back to the caller's default (typically 200). +""" + + +@dataclass(frozen=True) +class InboundModernRoute: + """A modern-protocol request whose envelope passed every ladder rung. + + `client_info` and `client_capabilities` are the raw envelope values; the + classifier checks presence only, not shape, and `client_info` is `None` + when the (optional, SHOULD-include) key is absent. Method existence is not + a ladder rung โ€” kernel dispatch is the single source of truth for that. + """ + + protocol_version: str + client_info: Any + client_capabilities: Any + + +@dataclass(frozen=True) +class InboundLadderRejection: + """The first ladder rung that failed, as JSON-RPC error fields.""" + + code: int + message: str + data: Any = None + + +_ROUTING_HEADER_NAMES: Final = frozenset({MCP_PROTOCOL_VERSION_HEADER, MCP_METHOD_HEADER, MCP_NAME_HEADER}) + + +def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str | None: + """Name of a routing header supplied more than once in raw header lines, or `None`. + + Takes raw `(name, value)` pairs โ€” a folded mapping hides duplicates. A + duplicate is rejected because first-copy and last-copy readers would + disagree. `Mcp-Param-*` duplicates are :func:`validate_mcp_param_headers`'s job. + """ + seen: set[str] = set() + for name, _ in headers: + key = name.lower() + if key in _ROUTING_HEADER_NAMES: + if key in seen: + return key + seen.add(key) + return None + + +def classify_inbound_request( + body: Mapping[str, Any], + *, + headers: Mapping[str, str] | None = None, + supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS, +) -> InboundModernRoute | InboundLadderRejection: + """Run the modern-protocol validation ladder over a decoded JSON-RPC body. + + Rungs, in order โ€” first failure wins: + + 1. `params._meta` is a mapping carrying the required envelope pair + (protocol version, client capabilities) โ†’ else + :data:`~mcp_types.jsonrpc.INVALID_PARAMS` naming the missing key(s) + (basic/index.mdx "Per-request protocol fields"). Client info is + optional (SHOULD-include, spec PR #3002); absent reads as `None`. + 2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's + protocol version, `Mcp-Method` equals `body.method`, and โ€” for the + methods in :data:`NAME_BEARING_METHODS` โ€” `Mcp-Name` equals the named + body param โ†’ else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs + before the supported-version rung so a client that disagrees with itself + is told so, rather than told the body's version is unsupported. + 3. The envelope's protocol version is a string in + `supported_modern_versions` โ†’ non-string values are + :data:`~mcp_types.jsonrpc.INVALID_PARAMS` (a shape defect, not a + negotiation outcome), else + :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with + `data = {"supported": [...], "requested": }`. + + Method existence is *not* a rung: kernel dispatch owns that decision so + custom-registered methods route and the answer lives in one place. + + Args: + body: The decoded JSON-RPC request mapping. Envelope shape + (`jsonrpc` / `id`) is not checked here. + headers: Transport headers keyed by lowercase name, or `None` to + skip the header rung (non-HTTP callers). + supported_modern_versions: Modern protocol revisions this server + accepts on the per-request-envelope path. + """ + try: + meta_value = body["params"]["_meta"] + except (KeyError, TypeError): + meta_value = None + if not isinstance(meta_value, Mapping): + return InboundLadderRejection( + code=INVALID_PARAMS, + message="params._meta must be an object carrying the required " + f"{PROTOCOL_VERSION_META_KEY!r} and {CLIENT_CAPABILITIES_META_KEY!r} envelope keys", + ) + meta = cast("Mapping[str, Any]", meta_value) + if missing := [key for key in (PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY) if key not in meta]: + return InboundLadderRejection( + code=INVALID_PARAMS, + message=f"params._meta is missing the required envelope key(s): {', '.join(missing)}", + ) + protocol_version: Any = meta[PROTOCOL_VERSION_META_KEY] + client_info: Any = meta.get(CLIENT_INFO_META_KEY) + client_capabilities: Any = meta[CLIENT_CAPABILITIES_META_KEY] + if headers is not None: + version_header = headers.get(MCP_PROTOCOL_VERSION_HEADER) + # Presence is checked explicitly: a null body version would otherwise + # slip the equality check (None == None) and mask the absent header. + if version_header is None or version_header != protocol_version: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version", + ) + method: Any = body.get("method") + if headers.get(MCP_METHOD_HEADER) != method: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{MCP_METHOD_HEADER} header does not match the request body's method", + ) + name_key = NAME_BEARING_METHODS.get(method) + if name_key is not None: + # Rung 1 already proved body["params"] is a mapping (its `_meta` is one). + body_value = cast("Mapping[str, Any]", body["params"]).get(name_key) + if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter", + ) + + if not isinstance(protocol_version, str): + # Rung 3's precondition: a shape defect, not a version-negotiation + # outcome - -32022 is the one code auto-negotiating clients do NOT + # fall back from, and the typed rung-3 payload itself requires a + # string `requested`. Sits after the header rung, which fires first + # for every header-bearing entry (an absent version header is a + # mismatch, and a present one is a string that can never equal a + # non-string body value) - so this rejection is reachable only on + # header-less transports. + return InboundLadderRejection( + code=INVALID_PARAMS, + message="the protocol-version envelope value must be a string", + ) + + if protocol_version not in supported_modern_versions: + return InboundLadderRejection( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="Unsupported protocol version", + data=UnsupportedProtocolVersionErrorData( + supported=list(supported_modern_versions), requested=protocol_version + ).model_dump(mode="json"), + ) + + return InboundModernRoute( + protocol_version=protocol_version, + client_info=client_info, + client_capabilities=client_capabilities, + ) + + +# Header values eligible for the spec's numeric-comparison SHOULD; scientific +# notation never compares numerically (matching the typescript-sdk's gate). +_CANONICAL_DECIMAL = re.compile(r"^-?[0-9]+(\.[0-9]+)?$") + + +def _mcp_param_value_matches(prop_type: Any, value: Any, rendered: str, decoded: str) -> bool: + """True when a decoded `Mcp-Param-*` header value agrees with the body argument. + + Integer-typed declarations with an integral body value compare numerically + (`42` matches `42.0`, the spec's SHOULD) for canonical-decimal headers โ€” + exact, no float round-trip, so values beyond the IEEE754 safe range still + compare. Anything else compares against `rendered`, the emit-side rendering. + """ + if ( + prop_type == "integer" + and not isinstance(value, bool) + and (isinstance(value, int) or (isinstance(value, float) and value.is_integer())) + and _CANONICAL_DECIMAL.fullmatch(decoded) is not None + ): + whole, _, fraction = decoded.partition(".") + if fraction and set(fraction) != {"0"}: + return False + try: + return int(whole) == int(value) + except ValueError: + return False + return decoded == rendered + + +def validate_mcp_param_headers( + input_schema: Any, + arguments: Mapping[str, Any], + headers: Mapping[str, str], +) -> InboundLadderRejection | None: + """Compare a `tools/call` request's `Mcp-Param-*` headers against its body arguments. + + Each annotated property's header and argument must agree: present together + and equal after sentinel decoding, or absent together (`null` counts as + absent). Returns the first failure as a `HEADER_MISMATCH` rejection, else `None`. + + A header whose argument is absent or unrenderable is deliberately rejected: + the spec's purpose clause is exactly an intermediary routing on a value the + body never carried. A duplicated recognized header is rejected โ€” first-copy + and last-copy readers would disagree. A schema :func:`find_invalid_x_mcp_header` + rejects validates nothing: conforming clients drop the tool and emit no headers. + """ + if find_invalid_x_mcp_header(input_schema) is not None: + return None + folded: dict[str, str] = {} + duplicated: set[str] = set() + for name, value in headers.items(): + key = name.lower() + if key in folded: + duplicated.add(key) + folded[key] = value + for path, token, schema in _annotated_positions(input_schema): + header_name = f"{MCP_PARAM_HEADER_PREFIX}{token}" + key = header_name.lower() + raw = folded.get(key) + value = _value_at_path(arguments, path) + argument = ".".join(path) + if raw is not None and key in duplicated: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header appears more than once", + ) + if value is None: + if raw is not None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header is present but the request body's {argument!r} argument is absent", + ) + continue + rendered = _render_header_scalar(value) + if rendered is None: + # Unrenderable value: a conforming client omitted the header, so one claiming it can never match. + if raw is not None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header does not match the request body's {argument!r} argument", + ) + continue + if raw is None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header is missing but the request body's {argument!r} argument is present", + ) + decoded = decode_header_value(raw) + if decoded is None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header carries a malformed base64 sentinel value", + ) + if not _mcp_param_value_matches(schema.get("type"), value, rendered, decoded): + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header does not match the request body's {argument!r} argument", + ) + return None diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/jsonrpc_dispatcher.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/jsonrpc_dispatcher.py new file mode 100644 index 0000000000000000000000000000000000000000..87bdf31cebf66bc9b27c039a4c1172450e140710 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/jsonrpc_dispatcher.py @@ -0,0 +1,836 @@ +"""JSON-RPC `Dispatcher` over the `SessionMessage` stream contract all transports speak. + +Owns request-id correlation, the receive loop, per-request task isolation, +cancellation/progress wiring, and the single exception-to-wire boundary; +methods and params are otherwise opaque strings and dicts. +""" + +from __future__ import annotations + +import contextvars +import logging +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from functools import partial +from typing import Any, Generic, Literal, cast + +import anyio +import anyio.abc +import anyio.lowlevel +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp_types import ( + CONNECTION_CLOSED, + INTERNAL_ERROR, + INVALID_PARAMS, + REQUEST_TIMEOUT, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + ProgressToken, + RequestId, +) +from opentelemetry.trace import SpanKind +from pydantic import ValidationError +from typing_extensions import TypeVar + +from mcp.shared._compat import resync_tracer +from mcp.shared._otel import inject_trace_context, otel_span +from mcp.shared._stream_protocols import ReadStream, WriteStream +from mcp.shared.dispatcher import ( + CallOptions, + DispatchContext, + Dispatcher, + OnNotify, + OnNotifyIntercept, + OnRequest, + ProgressFnT, + as_request_id, + coerce_request_id, + run_notify_intercept, +) +from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.shared.message import ( + ClientMessageMetadata, + MessageMetadata, + ServerMessageMetadata, + SessionMessage, +) +from mcp.shared.transport_context import TransportContext + +__all__ = [ + "JSONRPCDispatcher", + "cancelled_request_id_from_params", + "handler_exception_to_error_data", + "progress_token_from_params", +] + +logger = logging.getLogger(__name__) + +_ABANDON_WRITE_TIMEOUT: float = 5 +"""Bound for courtesy-cancel writes on the abandon paths; the caller-cancel +arm shields its write, so a wedged transport would otherwise hang it uncancellably.""" + +_SHUTDOWN_WRITE_TIMEOUT: float = 1 +"""Tighter bound for the shutdown-arm error write so a wedged transport can't hold session close.""" + +TransportT = TypeVar("TransportT", bound=TransportContext, default=TransportContext) + +PeerCancelMode = Literal["interrupt", "signal"] +"""How `notifications/cancelled` is applied: `"interrupt"` (default) cancels +the handler's scope; `"signal"` only sets `ctx.cancel_requested` and lets the +handler run to completion. Either way the cancelled request is never +answered - the handler's eventual result or error is dropped, not written.""" + + +def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None: + """Map a handler-raised exception to its wire `ErrorData`. + + The two rungs every dispatcher shares: an `MCPError` carries its own + `ErrorData`; a pydantic `ValidationError` is the spec's INVALID_PARAMS + with empty ``data`` (no pydantic text on the wire). Returns ``None`` for + any other exception so each caller applies its own catch-all - + `JSONRPCDispatcher` currently pins ``code=0`` for v1 compat, + the modern HTTP entry uses `INTERNAL_ERROR`. + """ + if isinstance(exc, MCPError): + return exc.error + if isinstance(exc, ValidationError): + return ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") + return None + + +def progress_token_from_params(params: Mapping[str, Any] | None) -> ProgressToken | None: + """Read `params._meta.progressToken`; reject bool (bool subclasses int, so True would alias 1).""" + match params: + case {"_meta": {"progressToken": str() | int() as token}} if not isinstance(token, bool): + return token + case _: + return None + + +def cancelled_request_id_from_params(params: Mapping[str, Any] | None) -> RequestId | None: + """Read `params.requestId` from a `notifications/cancelled` (`as_request_id` shape rules).""" + return as_request_id((params or {}).get("requestId")) + + +@dataclass(slots=True) +class _Pending: + """An outbound request awaiting its response.""" + + send: MemoryObjectSendStream[dict[str, Any] | ErrorData] + receive: MemoryObjectReceiveStream[dict[str, Any] | ErrorData] + on_progress: ProgressFnT | None = None + + +@dataclass(slots=True) +class _InFlight(Generic[TransportT]): + """An inbound request currently being handled.""" + + scope: anyio.CancelScope + dctx: _JSONRPCDispatchContext[TransportT] + + +@dataclass +class _JSONRPCDispatchContext(Generic[TransportT]): + """Concrete `DispatchContext` produced for each inbound JSON-RPC message.""" + + transport: TransportT + _dispatcher: JSONRPCDispatcher[TransportT] + _request_id: RequestId | None + message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework + """Transport-attached `SessionMessage.metadata` that the server lifts onto its request context.""" + _progress_token: ProgressToken | None = None + _closed: bool = False + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + + @property + def request_id(self) -> RequestId | None: + return self._request_id + + @property + def can_send_request(self) -> bool: + return self.transport.can_send_request and not self._closed + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + if self._closed: + logger.debug("dropped %s: dispatch context closed", method) + return + await self._dispatcher.notify(method, params, opts, _related_request_id=self._request_id) + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + if not self.can_send_request: + raise NoBackChannelError(method) + return await self._dispatcher.send_raw_request(method, params, opts, _related_request_id=self._request_id) + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + if self._progress_token is None: + return + params: dict[str, Any] = {"progressToken": self._progress_token, "progress": progress} + if total is not None: + params["total"] = total + if message is not None: + params["message"] = message + await self.notify("notifications/progress", params) + + def close(self) -> None: + self._closed = True + + +def _default_transport_builder(metadata: MessageMetadata) -> TransportContext: + """The `TransportContext` for a message, honoring the transport's own verdict when it stamps one. + + A message reads as riding a full duplex pipe (`can_send_request=True`) + unless the transport that framed it says otherwise on the metadata it + attached, so a transport whose response has no room for a server request + (streamable HTTP in JSON-response mode) needs no wiring from whoever drives + its streams. + """ + can_send_request = metadata.can_send_request if isinstance(metadata, ServerMessageMetadata) else True + return TransportContext(kind="jsonrpc", can_send_request=can_send_request) + + +def _shielded_progress(fn: ProgressFnT) -> ProgressFnT: + """Wrap a user progress callback so an exception can't cancel the dispatcher's task group.""" + + async def _wrapped(progress: float, total: float | None, message: str | None) -> None: + try: + await fn(progress, total, message) + except Exception: + logger.exception("progress callback raised") + + return _wrapped + + +def _contained_notify(fn: OnNotify) -> OnNotify: + """Wrap a notification handler so it can't crash the dispatcher (same boundary as `_shielded_progress`).""" + + async def _wrapped(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: + try: + await fn(dctx, method, params) + except Exception: + logger.exception("notification handler for %r raised", method) + + return _wrapped + + +@dataclass(slots=True, frozen=True) +class _OutboundPlan: + """Outbound metadata plus whether abandoning the request sends a courtesy `notifications/cancelled`.""" + + metadata: MessageMetadata + cancel_on_abandon: bool + + +def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | None) -> _OutboundPlan: + """Choose the outbound `SessionMessage.metadata` and the abandon-cancellation policy. + + `related_request_id` wins over resumption hints (they are dropped). Only + hints that actually reach the transport suppress the courtesy cancel - a + request that is neither resumable nor cancelled would leak the peer's work. + """ + opts = opts or {} + cancel_on_abandon = opts.get("cancel_on_abandon", True) + token = opts.get("resumption_token") + on_token = opts.get("on_resumption_token") + headers = opts.get("headers") + if related_request_id is not None: + if token is not None or on_token is not None: + logger.debug( + "dropping resumption hints: related_request_id %r takes precedence on metadata", related_request_id + ) + return _OutboundPlan(ServerMessageMetadata(related_request_id=related_request_id), cancel_on_abandon) + if token is not None or on_token is not None: + return _OutboundPlan( + ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token, headers=headers), + cancel_on_abandon=False, + ) + if headers: + return _OutboundPlan(ClientMessageMetadata(headers=headers), cancel_on_abandon) + return _OutboundPlan(None, cancel_on_abandon) + + +class JSONRPCDispatcher(Dispatcher[TransportT]): + """`Dispatcher` over the `SessionMessage` stream contract. + + Explicit Protocol base so pyright checks conformance at the class definition. + """ + + def __init__( + self, + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + transport_builder: Callable[[MessageMetadata], TransportT] | None = None, + peer_cancel_mode: PeerCancelMode = "interrupt", + raise_handler_exceptions: bool = False, + inline_methods: frozenset[str] = frozenset(), + on_stream_exception: Callable[[Exception], Awaitable[None]] | None = None, + ) -> None: + """Wire a dispatcher over a transport's `SessionMessage` stream pair. + + Args: + transport_builder: Builds each message's `TransportContext` from + its `SessionMessage.metadata`. + raise_handler_exceptions: Re-raise handler exceptions out of + `run()` after the error response is written. + inline_methods: Methods awaited in the read loop before the next + message is dequeued (e.g. `initialize`); an inline handler + that awaits the peer deadlocks the parked loop. + on_stream_exception: Observer for `Exception` items on the read + stream; without it they are debug-logged and dropped. Awaited + inline in the read loop, so a slow observer stalls dispatch. + """ + self._read_stream = read_stream + self._write_stream = write_stream + # With transport_builder omitted, TransportT defaults to + # TransportContext; pyright can't connect the two, hence the cast. + self._transport_builder = cast( + "Callable[[MessageMetadata], TransportT]", + transport_builder or _default_transport_builder, + ) + self._peer_cancel_mode: PeerCancelMode = peer_cancel_mode + self._raise_handler_exceptions = raise_handler_exceptions + self._inline_methods = inline_methods + self.on_stream_exception = on_stream_exception + """Observer for ``Exception`` items on the read stream. Mutable so a session can + bind it after the dispatcher is built (e.g. ``ClientSession`` routing into + ``message_handler``); only consulted inside ``run()`` so pre-enter assignment is safe.""" + + self._next_id = 0 + self._pending: dict[RequestId, _Pending] = {} + self._in_flight: dict[RequestId, _InFlight[TransportT]] = {} + self._on_notify_intercept: OnNotifyIntercept | None = None + self._tg: anyio.abc.TaskGroup | None = None + self._running = False + self._closed = False + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + *, + _related_request_id: RequestId | None = None, + ) -> dict[str, Any]: + """Send a JSON-RPC request and await its response. + + `_related_request_id` is set only by `_JSONRPCDispatchContext` so that + mid-handler requests route onto the inbound request's SSE stream. + + Raises: + MCPError: Peer error response; `REQUEST_TIMEOUT` if + `opts["timeout"]` elapsed; `CONNECTION_CLOSED` if the + transport closed or the dispatcher shut down. + RuntimeError: Called before `run()`. + """ + # Post-close sends get the same CONNECTION_CLOSED contract as in-flight waiters. + if self._closed: + raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") + if not self._running: + raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run()") + opts = opts or {} + supplied_id = opts.get("request_id") + if supplied_id is not None: + request_id: RequestId = supplied_id + # The pending key gets the same coercion `_resolve_pending` applies + # to inbound response ids, so a supplied "7" still correlates + # whether the peer echoes "7" or 7. The wire id stays verbatim. + pending_key = coerce_request_id(request_id) + if pending_key in self._pending: + raise ValueError(f"request id {request_id!r} is already in flight") + else: + # Mint past any key a supplied id occupies: the collision error is + # reserved for the caller who actually chose the id. + request_id = self._allocate_id() + while request_id in self._pending: + request_id = self._allocate_id() + pending_key = request_id + out_params = dict(params) if params is not None else {} + out_meta = dict(out_params.get("_meta") or {}) + on_progress = opts.get("on_progress") + if on_progress is not None: + # The request id doubles as the progress token, so `_pending[token]` finds `on_progress` directly. + out_meta["progressToken"] = request_id + out_params["_meta"] = out_meta + + # buffer=1: a close signal can arrive before the waiter parks in receive(); + # a WouldBlock later just means the waiter already has its one outcome. + send, receive = anyio.create_memory_object_stream[dict[str, Any] | ErrorData](1) + pending = _Pending(send=send, receive=receive, on_progress=on_progress) + self._pending[pending_key] = pending + + plan = _plan_outbound(_related_request_id, opts) + # Spec MUST: only previously-issued requests may be cancelled. A write + # interrupted by cancellation may still have delivered (a memory-stream + # send can hand its item to the receiver and still raise), so a started + # write counts as issued: the peer ignores a cancel for an id it never + # saw, while skipping it would leak a delivered request's handler. + request_write_started = False + timeout_armed = False + + target = out_params.get("name") + span_name = f"MCP send {method}{f' {target}' if isinstance(target, str) else ''}" + # TODO(maxisbey): move the otel span + inject into an outbound + # middleware once that seam exists; the dispatcher should not own otel. + try: + with otel_span( + span_name, + kind=SpanKind.CLIENT, + attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)}, + ): + # SEP-414: inject W3C trace context; `_meta` stays on the wire even with a no-op tracer. + inject_trace_context(out_meta) + msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params) + # Surface a pre-existing cancellation while the request provably + # never started; past this point a cancelled write counts as issued. + await anyio.lowlevel.checkpoint_if_cancelled() + request_write_started = True + try: + await self._write(msg, plan.metadata) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + # Transport tore down before run() noticed EOF; surface the documented contract. + raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") from None + with anyio.fail_after(opts.get("timeout")): + timeout_armed = True + outcome = await receive.receive() + except TimeoutError: + if not timeout_armed: + # `fail_after` arms only after the write, so this TimeoutError is the + # transport's own bounded send() failing - a transport error, not + # `opts["timeout"]` elapsing. Propagate it raw (v1 kept the write + # outside the timeout-catching try and did the same). + raise + # Courtesy cancel (spec-recommended, new vs v1) so the peer stops work; + # unshielded so an outer caller cancellation can still interrupt the write. + if plan.cancel_on_abandon: + await self._final_write( + partial( + self._cancel_outbound, + request_id, + f"timed out after {opts.get('timeout')}s", + _related_request_id, + ), + shield=False, + timeout=_ABANDON_WRITE_TIMEOUT, + describe=f"courtesy cancel for timed-out request {request_id!r}", + ) + raise MCPError(code=REQUEST_TIMEOUT, message=f"Request {method!r} timed out") from None + except anyio.get_cancelled_exc_class(): + # Caller cancelled: bare awaits re-raise here, so the shielded helper + # lets the courtesy cancel go out before we propagate. + if plan.cancel_on_abandon and request_write_started: + await self._final_write( + partial(self._cancel_outbound, request_id, "caller cancelled", _related_request_id), + shield=True, + timeout=_ABANDON_WRITE_TIMEOUT, + describe=f"courtesy cancel for caller-cancelled request {request_id!r}", + ) + raise + finally: + # Remove the waiter on every path so a late response is dropped, not leaked. + self._pending.pop(pending_key, None) + send.close() + receive.close() + + if isinstance(outcome, ErrorData): + raise MCPError(code=outcome.code, message=outcome.message, data=outcome.data) + return outcome + + async def notify( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + *, + _related_request_id: RequestId | None = None, + ) -> None: + """Send a fire-and-forget notification. + + Fire-and-forget all the way: a post-close send or a write onto a + torn-down transport drops the notification with a debug log instead + of raising (same policy as the response writes and `ctx.notify`). + """ + if self._closed: + logger.debug("dropped %s: dispatcher closed", method) + return + # Leave `params` unset when None: with `exclude_unset=True` an explicit + # None would serialize as `"params": null`, which JSON-RPC 2.0 forbids. + if params is not None: + msg = JSONRPCNotification(jsonrpc="2.0", method=method, params=dict(params)) + else: + msg = JSONRPCNotification(jsonrpc="2.0", method=method) + try: + await self._write(msg, _plan_outbound(_related_request_id, opts).metadata) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + # Transport tore down before run() noticed EOF. + logger.debug("dropped %s: write stream closed", method) + + async def run( + self, + on_request: OnRequest, + on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, + *, + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, + ) -> None: + """Drive the receive loop until the read stream closes. + + `task_status.started()` fires once `send_raw_request` is usable. + Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted. + """ + self._on_notify_intercept = on_notify_intercept + try: + # LIFO exits: the write stream closes only after the task-group join, so teardown writes still land. + async with self._write_stream: + async with anyio.create_task_group() as tg: + self._tg = tg + self._running = True + task_status.started() + try: + async with self._read_stream: + try: + async for item in self._read_stream: + # Duck-typed: only `ContextReceiveStream` carries the + # sender's per-message contextvars snapshot. + sender_ctx: contextvars.Context | None = getattr( + self._read_stream, "last_context", None + ) + await self._dispatch(item, on_request, on_notify, sender_ctx) + except anyio.ClosedResourceError: + # Receive end closed under us (stateless SHTTP teardown); same as EOF. + logger.debug("read stream closed by transport; treating as EOF") + # EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED. + self._running = False + self._closed = True + self._fan_out_closed() + finally: + # Cancel in-flight handlers; otherwise the task-group join + # waits on handlers whose callers are already gone. + tg.cancel_scope.cancel() + finally: + # Covers cancel/crash paths that skip the inline fan-out; idempotent. + self._running = False + self._closed = True + self._tg = None + self._fan_out_closed() + await resync_tracer() + + async def _dispatch( + self, + item: SessionMessage | Exception, + on_request: OnRequest, + on_notify: OnNotify, + sender_ctx: contextvars.Context | None, + ) -> None: + """Route one inbound item. + + Only `inline_methods` requests and the `on_stream_exception` observer + are awaited; any other `await` would head-of-line block the read loop. + """ + if isinstance(item, Exception): + if self.on_stream_exception is None: + logger.debug("transport yielded exception: %r", item) + return + try: + await self.on_stream_exception(item) + except Exception: + logger.exception("on_stream_exception observer raised") + return + metadata = item.metadata + msg = item.message + match msg: + case JSONRPCRequest(): + await self._dispatch_request(msg, metadata, on_request, sender_ctx) + case JSONRPCNotification(): + self._dispatch_notification(msg, metadata, on_notify, sender_ctx) + case JSONRPCResponse(): + self._resolve_pending(msg.id, msg.result) + case JSONRPCError(): # pragma: no branch + # Exhaustive over JSONRPCMessage, so the no-match arc is unreachable. + self._resolve_pending(msg.id, msg.error) + + async def _dispatch_request( + self, + req: JSONRPCRequest, + metadata: MessageMetadata, + on_request: OnRequest, + sender_ctx: contextvars.Context | None, + ) -> None: + progress_token = progress_token_from_params(req.params) + try: + transport_ctx = self._transport_builder(metadata) + except Exception: + # A raising builder must cost only this message, not the connection. + logger.exception("transport_builder raised; rejecting request %r", req.id) + self._spawn( + self._write_error, + req.id, + ErrorData(code=INTERNAL_ERROR, message="transport context unavailable"), + sender_ctx=sender_ctx, + ) + return + dctx = _JSONRPCDispatchContext( + transport=transport_ctx, + _dispatcher=self, + _request_id=req.id, + message_metadata=metadata, + _progress_token=progress_token, + ) + scope = anyio.CancelScope() + # TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit + # rejecting with INVALID_REQUEST. Key coerced so a stringified + # `notifications/cancelled` id still correlates. + self._in_flight[coerce_request_id(req.id)] = _InFlight(scope=scope, dctx=dctx) + if req.method in self._inline_methods: + # Spawn so `sender_ctx` applies, but park the read loop until the + # handler returns - that's the inline ordering guarantee. + done = anyio.Event() + + async def _run_inline() -> None: + try: + await self._handle_request(req, dctx, scope, on_request) + finally: + done.set() + + self._spawn(_run_inline, sender_ctx=sender_ctx) + await done.wait() + else: + self._spawn(self._handle_request, req, dctx, scope, on_request, sender_ctx=sender_ctx) + + def _dispatch_notification( + self, + msg: JSONRPCNotification, + metadata: MessageMetadata, + on_notify: OnNotify, + sender_ctx: contextvars.Context | None, + ) -> None: + """Route one inbound notification. + + `notifications/cancelled` and `notifications/progress` are intercepted + here (they correlate against the `_in_flight`/`_pending` tables this + layer owns) and still teed to `on_notify` afterwards. The caller's + `on_notify_intercept` then runs in receive order; only unconsumed + notifications reach the spawned `on_notify`. + """ + if msg.method == "notifications/cancelled": + rid = cancelled_request_id_from_params(msg.params) + if rid is not None and (in_flight := self._in_flight.get(coerce_request_id(rid))) is not None: + in_flight.dctx.cancel_requested.set() + if self._peer_cancel_mode == "interrupt": + in_flight.scope.cancel() + elif msg.method == "notifications/progress": + match msg.params: + case {"progressToken": str() | int() as token, "progress": int() | float() as progress} if ( + not isinstance(token, bool) + and not isinstance(progress, bool) + and (pending := self._pending.get(coerce_request_id(token))) is not None + and pending.on_progress is not None + ): + total = msg.params.get("total") + message = msg.params.get("message") + self._spawn( + _shielded_progress(pending.on_progress), + float(progress), + float(total) if isinstance(total, int | float) else None, + message if isinstance(message, str) else None, + sender_ctx=sender_ctx, + ) + case _: + pass + if run_notify_intercept(self._on_notify_intercept, msg.method, msg.params): + return + try: + transport_ctx = self._transport_builder(metadata) + except Exception: + # Same containment as `_dispatch_request`: drop the notification, keep the loop. + logger.exception("transport_builder raised; dropping notification %r", msg.method) + return + dctx = _JSONRPCDispatchContext( + transport=transport_ctx, _dispatcher=self, _request_id=None, message_metadata=metadata + ) + self._spawn(_contained_notify(on_notify), dctx, msg.method, msg.params, sender_ctx=sender_ctx) + + def _resolve_pending(self, request_id: RequestId | None, outcome: dict[str, Any] | ErrorData) -> None: + pending = self._pending.get(coerce_request_id(request_id)) if request_id is not None else None + if pending is None: + logger.debug("dropping response for unknown/late request id %r", request_id) + return + try: + pending.send.send_nowait(outcome) + except (anyio.WouldBlock, anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("waiter for request id %r already gone", request_id) + + def _spawn( + self, + fn: Callable[..., Awaitable[Any]], + *args: object, + sender_ctx: contextvars.Context | None, + ) -> None: + """Schedule `fn(*args)` in the run() task group, propagating the sender's contextvars. + + ASGI middleware (auth, OTel) sets contextvars on the task that wrote the + message; `Context.run` makes the spawned handler inherit that context. + """ + assert self._tg is not None + if sender_ctx is not None: + sender_ctx.run(self._tg.start_soon, fn, *args) + else: + self._tg.start_soon(fn, *args) + + def _fan_out_closed(self) -> None: + """Wake every pending `send_raw_request` waiter with `CONNECTION_CLOSED`. + + Synchronous: callers may be inside a cancelled scope. Idempotent. + """ + closed = ErrorData(code=CONNECTION_CLOSED, message="Connection closed") + for pending in self._pending.values(): + try: + pending.send.send_nowait(closed) + except (anyio.WouldBlock, anyio.BrokenResourceError, anyio.ClosedResourceError): + pass + self._pending.clear() + + async def _handle_request( + self, + req: JSONRPCRequest, + dctx: _JSONRPCDispatchContext[TransportT], + scope: anyio.CancelScope, + on_request: OnRequest, + ) -> None: + """Run `on_request` for one inbound request and write its response. + + The single exception-to-wire boundary: handler exceptions become + `JSONRPCError` here. A request the peer cancelled is never answered + (spec: MUST NOT send further messages for it) - it settles unanswered + instead, and `_settle_unanswered` tells the transport. + """ + answer_write_started = False + handler_failure: BaseException | None = None # re-raised once the request settles + try: + with scope: + try: + result = await on_request(dctx, req.method, req.params) + finally: + # Close the back-channel and drop from `_in_flight`; no checkpoint + # since handler return, so a peer cancel can't interleave. + # Identity guard: don't evict a duplicate id's newer entry. + dctx.close() + key = coerce_request_id(req.id) + if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx: + del self._in_flight[key] + if not dctx.cancel_requested.is_set(): + # A write interrupted by cancellation may still have delivered + # (a memory-stream send can hand its item to the receiver and + # still raise), so a started answer write counts as sent below: + # peers drop late responses, while a second answer for one id + # would break JSON-RPC. + answer_write_started = True + await self._write_result(req.id, result) + except anyio.get_cancelled_exc_class(): + # Shutdown: answer the request so the peer isn't left waiting - unless + # an answer write already started (it may have reached the transport; + # prefer possibly-zero answers over possibly-two), or the peer already + # cancelled it and stopped waiting. The shielded helper is needed + # because bare awaits re-raise here. + if not answer_write_started and not dctx.cancel_requested.is_set(): + await self._final_write( + partial(self._write_error, req.id, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")), + shield=True, + timeout=_SHUTDOWN_WRITE_TIMEOUT, + describe=f"shutdown error response for request {req.id!r}", + ) + raise + except Exception as e: + error = handler_exception_to_error_data(e) + if error is None: + logger.exception("handler for %r raised", req.method) + # TODO(L58): code=0 pins existing-server compat; JSON-RPC says + # INTERNAL_ERROR. Revisit per the suite's divergence entry. + error = ErrorData(code=0, message=str(e)) + if self._raise_handler_exceptions: + handler_failure = e + # A cancel silences only the wire; the failure stays as visible as before. + if not dctx.cancel_requested.is_set(): + answer_write_started = True + await self._write_error(req.id, error) + # The one place a cancelled request settles: the handler is done (any + # mode) with nothing written. A peer-interrupt cancel is absorbed at + # scope __exit__ and lands here too. + if not answer_write_started: + await self._settle_unanswered(dctx) + if handler_failure is not None: + raise handler_failure + # No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id. + + def _allocate_id(self) -> int: + self._next_id += 1 + return self._next_id + + async def _write(self, message: JSONRPCMessage, metadata: MessageMetadata = None) -> None: + await self._write_stream.send(SessionMessage(message=message, metadata=metadata)) + + async def _write_result(self, request_id: RequestId, result: dict[str, Any]) -> None: + try: + await self._write(JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("dropped result for %r: write stream closed", request_id) + + async def _write_error(self, request_id: RequestId, error: ErrorData) -> None: + try: + await self._write(JSONRPCError(jsonrpc="2.0", id=request_id, error=error)) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("dropped error for %r: write stream closed", request_id) + + async def _settle_unanswered(self, dctx: _JSONRPCDispatchContext[TransportT]) -> None: + """Run the transport's `on_request_unanswered` hook: this request settled with no response. + + The dispatcher writes nothing for it; a transport whose wire must still + end the request (2025-era streamable HTTP) does so from this hook. A + raising hook is contained here, like the other callback boundaries. + """ + metadata = dctx.message_metadata + if not isinstance(metadata, ServerMessageMetadata) or metadata.on_request_unanswered is None: + return + try: + await metadata.on_request_unanswered() + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("on_request_unanswered dropped: connection closing") + except Exception: + logger.exception("on_request_unanswered hook raised") + + async def _final_write( + self, + write: Callable[[], Awaitable[None]], + *, + shield: bool, + timeout: float, + describe: str, + ) -> None: + """Attempt one last write under the shared abandon/teardown policy. + + `shield=True` is for arms already inside a cancelled scope (a bare + `await` would re-raise); the bound keeps a wedged transport write + from becoming an uncancellable hang. + """ + with anyio.move_on_after(timeout, shield=shield) as scope: + await write() + if scope.cancelled_caught: + logger.warning("%s gave up: transport write blocked", describe) + + async def _cancel_outbound(self, request_id: RequestId, reason: str, related_request_id: RequestId | None) -> None: + # Thread `related_request_id` so streamable HTTP routes the cancel onto + # the request's own SSE stream instead of a possibly-absent GET stream. + # `notify` swallows connection-state errors itself, so no guard here. + await self.notify( + "notifications/cancelled", + {"requestId": request_id, "reason": reason}, + _related_request_id=related_request_id, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/memory.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/memory.py new file mode 100644 index 0000000000000000000000000000000000000000..01cab77c85546a7457b1fe1411d798f729b380ad --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/memory.py @@ -0,0 +1,33 @@ +"""In-memory transports""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from mcp.shared._compat import resync_tracer +from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams +from mcp.shared.message import SessionMessage + +MessageStream = tuple[ContextReceiveStream[SessionMessage | Exception], ContextSendStream[SessionMessage | Exception]] + + +@asynccontextmanager +async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageStream, MessageStream], None]: + """Creates a pair of bidirectional memory streams for client-server communication. + + Yields: + A tuple of (client_streams, server_streams) where each is a tuple of + (read_stream, write_stream) + """ + # Create streams for both directions + server_to_client_send, server_to_client_receive = create_context_streams[SessionMessage | Exception](1) + client_to_server_send, client_to_server_receive = create_context_streams[SessionMessage | Exception](1) + + client_streams = (server_to_client_receive, client_to_server_send) + server_streams = (client_to_server_receive, server_to_client_send) + + async with server_to_client_receive, client_to_server_send, client_to_server_receive, server_to_client_send: + yield client_streams, server_streams + # Heals caller-driven cancels; closing memory streams never suspends. + await resync_tracer() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/message.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/message.py new file mode 100644 index 0000000000000000000000000000000000000000..31e51e7128958fab37443d5809a32febc40e520f --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/message.py @@ -0,0 +1,63 @@ +"""Message wrapper with metadata support. + +This module defines a wrapper type that combines JSONRPCMessage with metadata +to support transport-specific features like resumability. +""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from mcp_types import JSONRPCMessage, RequestId + +ResumptionToken = str + +ResumptionTokenUpdateCallback = Callable[[ResumptionToken], Awaitable[None]] + +# Callback type for closing SSE streams without terminating +CloseSSEStreamCallback = Callable[[], Awaitable[None]] + + +@dataclass +class ClientMessageMetadata: + """Metadata specific to client messages.""" + + resumption_token: ResumptionToken | None = None + on_resumption_token_update: Callable[[ResumptionToken], Awaitable[None]] | None = None + # Per-message HTTP headers (e.g. MCP-Protocol-Version, Mcp-Method) the transport should set. + headers: dict[str, str] | None = None + + +@dataclass +class ServerMessageMetadata: + """Metadata specific to server messages.""" + + related_request_id: RequestId | None = None + # Transport-specific request context (e.g. starlette Request for HTTP + # transports, None for stdio). Typed as Any because the server layer is + # transport-agnostic. + request_context: Any = None + # Callback to close SSE stream for the current request without terminating + close_sse_stream: CloseSSEStreamCallback | None = None + # Callback to close the standalone GET SSE stream (for unsolicited notifications) + close_standalone_sse_stream: CloseSSEStreamCallback | None = None + # Callback the dispatcher runs when this request settles without a response + # (e.g. it was cancelled), for a transport whose wire must still end the + # request even though no response is written. + on_request_unanswered: Callable[[], Awaitable[None]] | None = None + # The transport's verdict on whether this message's request-scoped channel + # can deliver a server-initiated request (see + # `TransportContext.can_send_request`); a transport that says nothing leaves + # it True. + can_send_request: bool = True + + +MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None + + +@dataclass +class SessionMessage: + """A message with specific metadata for transport-specific features.""" + + message: JSONRPCMessage + metadata: MessageMetadata = None diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/metadata_utils.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/metadata_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b6461334779804d7af59b246e3a2de895837c2cd --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/metadata_utils.py @@ -0,0 +1,46 @@ +"""Utility functions for working with metadata in MCP types. + +These utilities are primarily intended for client-side usage to properly display +human-readable names in user interfaces in a spec-compliant way. +""" + +from mcp_types import Implementation, Prompt, Resource, ResourceTemplate, Tool + + +def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implementation) -> str: + """Get the display name for an MCP object with proper precedence. + + This is a client-side utility function designed to help MCP clients display + human-readable names in their user interfaces. When servers provide a 'title' + field, it should be preferred over the programmatic 'name' field for display. + + For tools: title > annotations.title > name + For other objects: title > name + + Example: + ```python + # In a client displaying available tools + tools = await session.list_tools() + for tool in tools.tools: + display_name = get_display_name(tool) + print(f"Available tool: {display_name}") + ``` + + Args: + obj: An MCP object with name and optional title fields + + Returns: + The display name to use for UI presentation + """ + if isinstance(obj, Tool): + # Tools have special precedence: title > annotations.title > name + if hasattr(obj, "title") and obj.title is not None: + return obj.title + if obj.annotations and hasattr(obj.annotations, "title") and obj.annotations.title is not None: + return obj.annotations.title + return obj.name + else: + # All other objects: title > name + if hasattr(obj, "title") and obj.title is not None: + return obj.title + return obj.name diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/path_security.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/path_security.py new file mode 100644 index 0000000000000000000000000000000000000000..0d338eacc2d90fca1d1876bbee85eb43fd28a03e --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/path_security.py @@ -0,0 +1,176 @@ +"""Filesystem path safety primitives for resource handlers. + +These functions help MCP servers reject paths that would resolve +outside the served root when extracted URI template parameters are +used in filesystem operations. They are standalone utilities usable from both the +high-level :class:`~mcp.server.mcpserver.MCPServer` and lowlevel server +implementations. + +The canonical safe pattern:: + + from mcp.shared.path_security import safe_join + + @mcp.resource("file://docs/{+path}") + def read_doc(path: str) -> str: + return safe_join("/data/docs", path).read_text() +""" + +import string +from pathlib import Path + +__all__ = ["PathEscapeError", "contains_path_traversal", "is_absolute_path", "safe_join"] + + +class PathEscapeError(ValueError): + """Raised by :func:`safe_join` when the resolved path escapes the base.""" + + +def contains_path_traversal(value: str) -> bool: + r"""Check whether a value, treated as a relative path, escapes its origin. + + This is a **base-free** check: it does not know the sandbox root, so + it detects only whether ``..`` components would move above the + starting point. Use :func:`safe_join` when you know the root โ€” it + additionally catches symlink escapes and absolute-path injection. + + Note: + This is a string-level check on the value as supplied. It does + not model platform-specific filesystem normalisation (e.g. Win32 + stripping of trailing dots and spaces from the final path + component). For filesystem access, use :func:`safe_join`, which + resolves through the OS and verifies containment. + + The check is component-based: ``..`` is dangerous only as a + standalone path segment, not as a substring. Both ``/`` and ``\`` + are treated as separators. + + Example:: + + >>> contains_path_traversal("a/b/c") + False + >>> contains_path_traversal("../etc") + True + >>> contains_path_traversal("a/../../b") + True + >>> contains_path_traversal("a/../b") + False + >>> contains_path_traversal("1.0..2.0") + False + >>> contains_path_traversal("..") + True + + Args: + value: A string that may be used as a filesystem path. + + Returns: + ``True`` if the path would escape its starting directory. + """ + depth = 0 + for part in value.replace("\\", "/").split("/"): + if part == "..": + depth -= 1 + if depth < 0: + return True + elif part and part != ".": + depth += 1 + return False + + +def is_absolute_path(value: str) -> bool: + r"""Check whether a value is an absolute filesystem path. + + Absolute paths are dangerous when joined onto a base: in Python, + ``Path("/data") / "/etc/passwd"`` yields ``/etc/passwd`` โ€” the + absolute right-hand side silently discards the base. + + Detects POSIX absolute (``/foo``), Windows drive-absolute + (``C:\foo``) and drive-relative (``C:foo``), and Windows + UNC/root-relative (``\\server\share``, ``\foo``). + + Example:: + + >>> is_absolute_path("relative/path") + False + >>> is_absolute_path("/etc/passwd") + True + >>> is_absolute_path("C:\\Windows") + True + >>> is_absolute_path("") + False + + Args: + value: A string that may be used as a filesystem path. + + Returns: + ``True`` if the path is absolute on any common platform. + """ + if not value: + return False + if value[0] in ("/", "\\"): + return True + # Windows drive form: C:, C:\, C:foo (drive-relative). A drive- + # relative right-hand side discards the join base when drives + # differ, so flag it even though PureWindowsPath.is_absolute() + # is False. This means single-letter-prefixed identifiers like + # "x:y" also match โ€” opt out via ResourceSecurity(exempt_params=). + if len(value) >= 2 and value[1] == ":" and value[0] in string.ascii_letters: + return True + return False + + +def safe_join(base: str | Path, *parts: str) -> Path: + """Join path components onto a base, rejecting escapes. + + Resolves the joined path and verifies it remains within ``base``. + This is the **gold-standard** check: it catches ``..`` traversal, + absolute-path injection, and symlink escapes that the base-free + checks cannot. + + The symlink check is point-in-time: a directory swapped for a + symlink between this call and the caller's subsequent open would not + be re-checked. Handlers serving a tree that may be modified + concurrently should additionally open with ``O_NOFOLLOW`` or use + platform path-confinement primitives. + + Example:: + + >>> safe_join("/data/docs", "readme.txt") + PosixPath('/data/docs/readme.txt') + >>> safe_join("/data/docs", "../../../etc/passwd") + Traceback (most recent call last): + ... + PathEscapeError: ... + + Args: + base: The sandbox root. May be relative; it will be resolved. + parts: Path components to join. Each is checked for null bytes + and absolute form before joining. + + Returns: + The resolved path, verified to be within ``base`` at resolution + time. + + Raises: + PathEscapeError: If any part contains a null byte, any part is + absolute, or the resolved path is not contained within the + resolved base. + """ + base_resolved = Path(base).resolve() + + for part in parts: + # Null bytes pass through Path construction but fail at the + # syscall boundary with a cryptic error. Reject here so callers + # get a clear PathEscapeError instead. + if "\0" in part: + raise PathEscapeError(f"Path component contains a null byte; refusing to join onto {base_resolved}") + # Absolute parts would silently discard everything to the left + # in Path's / operator. + if is_absolute_path(part): + raise PathEscapeError(f"Path component {part!r} is absolute; refusing to join onto {base_resolved}") + + target = base_resolved.joinpath(*parts).resolve() + + if not target.is_relative_to(base_resolved): + raise PathEscapeError(f"Path {target} escapes base {base_resolved}") + + return target diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/peer.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/peer.py new file mode 100644 index 0000000000000000000000000000000000000000..14e8fe1c299adbca2dc8362c3dd881b83a38fd1a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/peer.py @@ -0,0 +1,240 @@ +"""Typed MCP request sugar over an `Outbound`. + +`ClientPeer` wraps any `Outbound` (anything with `send_raw_request` and +`notify`) and exposes the server-to-client request methods (sampling, +elicitation, roots, ping) as typed methods. + +`ClientPeer` does no capability gating: it builds the params, calls +`send_raw_request(method, params)`, and parses the result into the typed +model. Gating (and `NoBackChannelError`) is the wrapped `Outbound`'s job. +""" + +from collections.abc import Mapping +from typing import Any, cast, overload + +from mcp_types import ( + CreateMessageRequestParams, + CreateMessageResult, + CreateMessageResultWithTools, + ElicitRequestedSchema, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + IncludeContext, + ListRootsResult, + ModelPreferences, + RequestParams, + RequestParamsMeta, + SamplingMessage, + Tool, + ToolChoice, +) +from pydantic import BaseModel +from typing_extensions import deprecated + +from mcp.shared.dispatcher import CallOptions, Outbound +from mcp.shared.exceptions import MCPDeprecationWarning + +__all__ = ["ClientPeer", "Meta"] + +Meta = dict[str, Any] +"""Type alias for the `_meta` field carried on request/notification params.""" + + +def dump_params(model: BaseModel | None, meta: Meta | None = None) -> dict[str, Any] | None: + """Serialize a params model to a wire dict, merging `meta` into `_meta`. + + Shared by `ClientPeer` and `Connection` so every typed convenience method + gets the same `_meta` handling. `meta` keys take precedence over any + `_meta` already present on the model. + + `meta` is serialized through `RequestParams` so Python field names emit + their wire aliases: an inbound `ctx.meta` carries `progress_token` (the + key `_extract_meta` validation produces), and forwarding it outbound via + `meta=ctx.meta` must put `progressToken` back on the wire. Keys not + declared on `RequestParamsMeta` pass through unchanged. + """ + out = model.model_dump(by_alias=True, mode="json", exclude_none=True) if model is not None else None + if meta: + wire_meta = RequestParams(_meta=cast(RequestParamsMeta, meta)).model_dump(by_alias=True, mode="json")["_meta"] + out = dict(out or {}) + out["_meta"] = {**out.get("_meta", {}), **wire_meta} + return out + + +class ClientPeer: + """Typed server-to-client request methods over a wrapped `Outbound`. + + Use this when you have a bare dispatcher (or any `Outbound`) and want the + typed methods (`sample`, `elicit_form`, `elicit_url`, `list_roots`, + `ping`) without writing your own host class. + """ + + def __init__(self, outbound: Outbound) -> None: + self._outbound = outbound + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + return await self._outbound.send_raw_request(method, params, opts) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + await self._outbound.notify(method, params, opts) + + @overload + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def sample( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: None = None, + tool_choice: None = None, + meta: Meta | None = None, + opts: CallOptions | None = None, + ) -> CreateMessageResult: ... + @overload + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def sample( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: list[Tool], + tool_choice: ToolChoice | None = None, + meta: Meta | None = None, + opts: CallOptions | None = None, + ) -> CreateMessageResultWithTools: ... + @overload + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def sample( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: list[Tool] | None = None, + tool_choice: ToolChoice, + meta: Meta | None = None, + opts: CallOptions | None = None, + ) -> CreateMessageResultWithTools: ... + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def sample( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: list[Tool] | None = None, + tool_choice: ToolChoice | None = None, + meta: Meta | None = None, + opts: CallOptions | None = None, + ) -> CreateMessageResult | CreateMessageResultWithTools: + """Send a `sampling/createMessage` request to the peer. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: No back-channel for server-initiated requests. + pydantic.ValidationError: The peer's result does not match the expected result type. + """ + params = CreateMessageRequestParams( + messages=messages, + system_prompt=system_prompt, + include_context=include_context, + temperature=temperature, + max_tokens=max_tokens, + stop_sequences=stop_sequences, + metadata=metadata, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + ) + result = await self.send_raw_request("sampling/createMessage", dump_params(params, meta), opts) + if tools is not None or tool_choice is not None: + return CreateMessageResultWithTools.model_validate(result, by_name=False) + return CreateMessageResult.model_validate(result, by_name=False) + + async def elicit_form( + self, + message: str, + requested_schema: ElicitRequestedSchema, + *, + meta: Meta | None = None, + opts: CallOptions | None = None, + ) -> ElicitResult: + """Send a form-mode `elicitation/create` request. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: No back-channel for server-initiated requests. + pydantic.ValidationError: The peer's result does not match the expected result type. + """ + params = ElicitRequestFormParams(message=message, requested_schema=requested_schema) + result = await self.send_raw_request("elicitation/create", dump_params(params, meta), opts) + return ElicitResult.model_validate(result, by_name=False) + + async def elicit_url( + self, + message: str, + url: str, + elicitation_id: str, + *, + meta: Meta | None = None, + opts: CallOptions | None = None, + ) -> ElicitResult: + """Send a URL-mode `elicitation/create` request. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: No back-channel for server-initiated requests. + pydantic.ValidationError: The peer's result does not match the expected result type. + """ + params = ElicitRequestURLParams(message=message, url=url, elicitation_id=elicitation_id) + result = await self.send_raw_request("elicitation/create", dump_params(params, meta), opts) + return ElicitResult.model_validate(result, by_name=False) + + @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def list_roots(self, *, meta: Meta | None = None, opts: CallOptions | None = None) -> ListRootsResult: + """Send a `roots/list` request. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: No back-channel for server-initiated requests. + pydantic.ValidationError: The peer's result does not match the expected result type. + """ + result = await self.send_raw_request("roots/list", dump_params(None, meta), opts) + return ListRootsResult.model_validate(result, by_name=False) + + async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = None) -> None: + """Send a `ping` request and ignore the result. + + Raises: + MCPError: The peer responded with an error. + NoBackChannelError: No back-channel for server-initiated requests. + """ + await self.send_raw_request("ping", dump_params(None, meta), opts) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/subscriptions.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/subscriptions.py new file mode 100644 index 0000000000000000000000000000000000000000..30449a82ff552e2e533085b9f2422920470d8806 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/subscriptions.py @@ -0,0 +1,111 @@ +"""Typed event vocabulary for `subscriptions/listen` (2026-07-28, SEP-2575), shared by server and client. + +Every event is a level trigger ("this changed, refetch if you care"), so both sides bound buffers by dedupe. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from mcp_types import ( + NotificationParams, + PromptListChangedNotification, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + ServerNotification, + SubscriptionFilter, + ToolListChangedNotification, +) + +__all__ = [ + "LISTEN_STREAM_METHODS", + "SUBSCRIPTION_ID_META_KEY", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "ToolsListChanged", + "event_from_wire", + "event_matches", + "event_to_notification", +] + +SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" +"""The `_meta` key on every listen-stream frame; the value is the `subscriptions/listen` request's JSON-RPC id.""" + + +@dataclass(frozen=True) +class ToolsListChanged: + """The server's tool list changed.""" + + +@dataclass(frozen=True) +class PromptsListChanged: + """The server's prompt list changed.""" + + +@dataclass(frozen=True) +class ResourcesListChanged: + """The server's resource list changed.""" + + +@dataclass(frozen=True) +class ResourceUpdated: + """The resource at `uri` changed and may need to be read again.""" + + uri: str + + +ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated +"""An event a server publishes for delivery to listen subscribers.""" + + +def event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNotification: + """Build the stamped wire notification for `event` (the server's direction).""" + if isinstance(event, ToolsListChanged): + return ToolListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, PromptsListChanged): + return PromptListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, ResourcesListChanged): + return ResourceListChangedNotification(params=NotificationParams(_meta=meta)) + return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) + + +_LIST_CHANGED_EVENTS: dict[str, ServerEvent] = { + "notifications/tools/list_changed": ToolsListChanged(), + "notifications/prompts/list_changed": PromptsListChanged(), + "notifications/resources/list_changed": ResourcesListChanged(), +} + +LISTEN_STREAM_METHODS: frozenset[str] = frozenset({*_LIST_CHANGED_EVENTS, "notifications/resources/updated"}) +"""The notification methods that ride `subscriptions/listen` streams at 2026-07-28 +(and, at that era, nowhere else): the change-notification vocabulary.""" + + +def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEvent | None: + """The event a raw listen-stream frame announces, or None if it carries none. + + Takes the raw wire dict: the client demultiplexes before the typed notification parse.""" + if (event := _LIST_CHANGED_EVENTS.get(method)) is not None: + return event + if method == "notifications/resources/updated": + uri = (params or {}).get("uri") + if isinstance(uri, str): + return ResourceUpdated(uri=uri) + return None + + +def event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: + """Whether `event` is within the stream's honored filter (`uris`: the honored resource subscriptions as a set). + + The admission predicate both sides share: server delivery and client intake honor only what was acknowledged.""" + if isinstance(event, ToolsListChanged): + return honored.tools_list_changed is True + if isinstance(event, PromptsListChanged): + return honored.prompts_list_changed is True + if isinstance(event, ResourcesListChanged): + return honored.resources_list_changed is True + return event.uri in uris diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/tool_name_validation.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/tool_name_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..96c34f7826808a9c005d53136460ca4c620ade0c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/tool_name_validation.py @@ -0,0 +1,129 @@ +"""Tool name validation utilities according to SEP-986. + +Tool names SHOULD be between 1 and 128 characters in length (inclusive). +Tool names are case-sensitive. +Allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), +digits (0-9), underscore (_), dash (-), and dot (.). +Tool names SHOULD NOT contain spaces, commas, or other special characters. + +See: https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +# Regular expression for valid tool names according to SEP-986 specification +TOOL_NAME_REGEX = re.compile(r"^[A-Za-z0-9._-]{1,128}$") + +# SEP reference URL for warning messages +SEP_986_URL = "https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names" + + +@dataclass +class ToolNameValidationResult: + """Result of tool name validation. + + Attributes: + is_valid: Whether the tool name conforms to SEP-986 requirements. + warnings: List of warning messages for non-conforming aspects. + """ + + is_valid: bool + warnings: list[str] = field(default_factory=lambda: []) + + +def validate_tool_name(name: str) -> ToolNameValidationResult: + """Validate a tool name according to the SEP-986 specification. + + Args: + name: The tool name to validate. + + Returns: + ToolNameValidationResult containing validation status and any warnings. + """ + warnings: list[str] = [] + + # Check for empty name + if not name: + return ToolNameValidationResult( + is_valid=False, + warnings=["Tool name cannot be empty"], + ) + + # Check length + if len(name) > 128: + return ToolNameValidationResult( + is_valid=False, + warnings=[f"Tool name exceeds maximum length of 128 characters (current: {len(name)})"], + ) + + # Check for problematic patterns (warnings, not validation failures) + if " " in name: + warnings.append("Tool name contains spaces, which may cause parsing issues") + + if "," in name: + warnings.append("Tool name contains commas, which may cause parsing issues") + + # Check for potentially confusing leading/trailing characters + if name.startswith("-") or name.endswith("-"): + warnings.append("Tool name starts or ends with a dash, which may cause parsing issues in some contexts") + + if name.startswith(".") or name.endswith("."): + warnings.append("Tool name starts or ends with a dot, which may cause parsing issues in some contexts") + + # Check for invalid characters + if not TOOL_NAME_REGEX.fullmatch(name): + # Find all invalid characters (unique, preserving order) + invalid_chars: list[str] = [] + seen: set[str] = set() + for char in name: + if not re.match(r"[A-Za-z0-9._-]", char) and char not in seen: + invalid_chars.append(char) + seen.add(char) + + warnings.append(f"Tool name contains invalid characters: {', '.join(repr(c) for c in invalid_chars)}") + warnings.append("Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)") + + return ToolNameValidationResult(is_valid=False, warnings=warnings) + + return ToolNameValidationResult(is_valid=True, warnings=warnings) + + +def issue_tool_name_warning(name: str, warnings: list[str]) -> None: + """Log warnings for non-conforming tool names. + + Args: + name: The tool name that triggered the warnings. + warnings: List of warning messages to log. + """ + if not warnings: + return + + logger.warning(f'Tool name validation warning for "{name}":') + for warning in warnings: + logger.warning(f" - {warning}") + logger.warning("Tool registration will proceed, but this may cause compatibility issues.") + logger.warning("Consider updating the tool name to conform to the MCP tool naming standard.") + logger.warning(f"See SEP-986 ({SEP_986_URL}) for more details.") + + +def validate_and_warn_tool_name(name: str) -> bool: + """Validate a tool name and issue warnings for non-conforming names. + + This is the primary entry point for tool name validation. It validates + the name and logs any warnings via the logging module. + + Args: + name: The tool name to validate. + + Returns: + True if the name is valid, False otherwise. + """ + result = validate_tool_name(name) + issue_tool_name_warning(name, result.warnings) + return result.is_valid diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/transport_context.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/transport_context.py new file mode 100644 index 0000000000000000000000000000000000000000..8d15a2eaa2e644bac950fabb171e4c3816935e6a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/transport_context.py @@ -0,0 +1,45 @@ +"""Transport-specific metadata attached to each inbound message. + +`TransportContext` is the base; each transport defines its own subclass with +whatever fields make sense (HTTP request id, ASGI scope, stdio process handle, +etc.). The dispatcher passes it through opaquely; only the layers above the +dispatcher (`ServerRunner`, `Context`, user handlers) read its concrete fields. +""" + +from collections.abc import Mapping +from dataclasses import dataclass + +__all__ = ["TransportContext"] + + +@dataclass(kw_only=True, frozen=True) +class TransportContext: + """Base transport metadata for an inbound message. + + Subclass per transport and add fields as needed. Instances are immutable. + """ + + kind: str + """Short identifier for the transport (e.g. `"stdio"`, `"streamable-http"`).""" + + can_send_request: bool + """Whether this message's request-scoped channel can deliver a server-initiated request. + + `False` for any of three reasons: the response has no room (streamable + HTTP in JSON-response mode and the 2026-07-28 single-exchange entry answer + with one JSON-RPC reply), the client's reply has nowhere to land (stateless + HTTP, no session), or the protocol forbids server-initiated requests (any + 2026-07-28 connection, whose dispatch masks the flag off). `True` for a + plain duplex pipe (stdio, SSE) and stateful streamable HTTP with SSE + responses, all pre-2026-07-28. When `False`, + `DispatchContext.send_raw_request` raises `NoBackChannelError` instead of + parking a waiter no reply can reach. Says nothing about the connection's + standalone channel, which refuses separately. + """ + + headers: Mapping[str, str] | None = None + """Request headers carried by this message, when the transport has them. + + Populated by HTTP-based transports; `None` on stdio. Handlers should + None-check before use. + """ diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/shared/uri_template.py b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/uri_template.py new file mode 100644 index 0000000000000000000000000000000000000000..20d6fa9c2e70d4670cab58cfef549dd4e629fad1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/shared/uri_template.py @@ -0,0 +1,1116 @@ +"""RFC 6570 URI Templates with bidirectional support. + +Provides both expansion (template + variables โ†’ URI) and matching +(URI โ†’ variables). RFC 6570 only specifies expansion; matching is the +inverse operation needed by MCP servers to route ``resources/read`` +requests to handlers. + +Supports Levels 1-3 fully, plus Level 4 explode modifier for path-like +operators (``{/var*}``, ``{.var*}``, ``{;var*}``). The Level 4 prefix +modifier (``{var:N}``) and query-explode (``{?var*}``) are not supported. + +Matching semantics +------------------ + +Matching is not specified by RFC 6570 (ยง1.4 explicitly defers to regex +languages). This implementation uses a two-ended scan that never +backtracks: match time is O(nยทv) where n is URI length and v is the +number of template variables. Realistic templates have v < 10, making +this effectively linear; there is no input that produces +superpolynomial time. + +A template may contain **at most one multi-segment variable** โ€” +``{+var}``, ``{#var}``, or an explode-modified variable (``{/var*}``, +``{.var*}``, ``{;var*}``). This variable greedily consumes whatever the +surrounding bounded variables and literals do not. Two such variables +in one template are inherently ambiguous (which one gets the extra +segment?) and are rejected at parse time. So are any two variables +adjacent with no literal between them โ€” including a variable adjacent +to the multi-segment variable: the scan has nothing to anchor the +boundary on. Operators that emit their own lead character supply that +literal themselves, so ``{+path}{.ext}`` and ``{a}{.b}`` are fine +while ``{+path}{ext}`` and ``{a}{b}`` are not. + +Bounded variables before the multi-segment variable match **lazily** +(first occurrence of the following literal); those after match +**greedily** (last occurrence of the preceding literal). Templates +without a multi-segment variable match greedily throughout, identical +to regex semantics. + +Reserved expansion ``{+var}`` leaves ``?`` and ``#`` unencoded, but +the scan stops at those characters so ``{+path}{?q}`` can separate path +from query. A value containing a literal ``?`` or ``#`` expands fine +but will not round-trip through ``match()``. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Literal, TypeAlias, cast +from urllib.parse import quote, unquote + +__all__ = [ + "DEFAULT_MAX_TEMPLATE_LENGTH", + "DEFAULT_MAX_VARIABLES", + "DEFAULT_MAX_URI_LENGTH", + "InvalidUriTemplate", + "Operator", + "UriTemplate", + "Variable", +] + +Operator = Literal["", "+", "#", ".", "/", ";", "?", "&"] + +_OPERATORS: frozenset[str] = frozenset({"+", "#", ".", "/", ";", "?", "&"}) + +# RFC 6570 ยง2.3: varname = varchar *(["."] varchar), varchar = ALPHA / DIGIT / "_" +# Dots appear only between varchar groups โ€” not consecutive, not trailing. +# (Percent-encoded varchars are technically allowed but unseen in practice.) +_VARNAME_RE = re.compile(r"^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*$") + +DEFAULT_MAX_TEMPLATE_LENGTH = 8_192 +DEFAULT_MAX_VARIABLES = 256 +DEFAULT_MAX_URI_LENGTH = 65_536 + +# RFC 3986 reserved characters, kept unencoded by {+var} and {#var}. +_RESERVED = ":/?#[]@!$&'()*+,;=" + + +@dataclass(frozen=True) +class _OperatorSpec: + """Expansion behavior for a single operator (RFC 6570 ยง3.2, Table in ยงA).""" + + prefix: str + """Leading character emitted before the first variable.""" + separator: str + """Character between variables (and between exploded list items).""" + named: bool + """Emit ``name=value`` pairs (query/path-param style) rather than bare values.""" + allow_reserved: bool + """Keep reserved characters unencoded ({+var}, {#var}).""" + ifemp: str + """Suffix after a named variable whose expanded value is empty (RFC ยงA): '' for ;, '=' for ?/&.""" + + +_OPERATOR_SPECS: dict[Operator, _OperatorSpec] = { + "": _OperatorSpec(prefix="", separator=",", named=False, allow_reserved=False, ifemp=""), + "+": _OperatorSpec(prefix="", separator=",", named=False, allow_reserved=True, ifemp=""), + "#": _OperatorSpec(prefix="#", separator=",", named=False, allow_reserved=True, ifemp=""), + ".": _OperatorSpec(prefix=".", separator=".", named=False, allow_reserved=False, ifemp=""), + "/": _OperatorSpec(prefix="/", separator="/", named=False, allow_reserved=False, ifemp=""), + ";": _OperatorSpec(prefix=";", separator=";", named=True, allow_reserved=False, ifemp=""), + "?": _OperatorSpec(prefix="?", separator="&", named=True, allow_reserved=False, ifemp="="), + "&": _OperatorSpec(prefix="&", separator="&", named=True, allow_reserved=False, ifemp="="), +} + +# Per-operator stop characters for the linear scan. A bounded variable's +# value ends at the first occurrence of any character in its stop set, +# mirroring the character-class boundaries a regex would use but without +# the backtracking. +_STOP_CHARS: dict[Operator, str] = { + "": "/?#&,", # simple: everything structural is pct-encoded + "+": "?#", # reserved: / allowed, stop at query/fragment + "#": "", # fragment: tail of URI, nothing stops it + ".": "./?#", # label: stop at next . + "/": "/?#", # path segment: stop at next / + ";": ";/?#", # path-param value (may be empty: ;name) + "?": "&#", # query value (may be empty: ?name=) + "&": "&#", # query-cont value +} + + +class InvalidUriTemplate(ValueError): + """Raised when a URI template string is malformed or unsupported. + + Attributes: + template: The template string that failed to parse. + position: Character offset where the error was detected, or None + if the error is not tied to a specific position. + """ + + def __init__(self, message: str, *, template: str, position: int | None = None) -> None: + super().__init__(message) + self.template = template + self.position = position + + +@dataclass(frozen=True) +class Variable: + """A single variable within a URI template expression.""" + + name: str + operator: Operator + explode: bool = False + + +@dataclass +class _Expression: + """A parsed ``{...}`` expression: one operator, one or more variables.""" + + operator: Operator + variables: list[Variable] + + +_Part = str | _Expression + + +@dataclass(frozen=True) +class _Lit: + """A literal run in the flattened match-atom sequence.""" + + text: str + + +@dataclass(frozen=True) +class _Cap: + """A single-variable capture in the flattened match-atom sequence. + + ``ifemp`` marks the ``;`` operator's optional-equals quirk: ``{;id}`` + expands to ``;id=value`` or bare ``;id`` when the value is empty, so + the scan must accept both forms. + """ + + var: Variable + ifemp: bool = False + + +_Atom: TypeAlias = _Lit | _Cap + + +def _is_greedy(var: Variable) -> bool: + """Return True if this variable can span multiple path segments. + + Reserved/fragment expansion and explode variables are the only + constructs whose match range is not bounded by a single structural + delimiter. A template may contain at most one such variable. + """ + return var.explode or var.operator in ("+", "#") + + +def _is_str_sequence(value: object) -> bool: + """Check if value is a non-string sequence whose items are all strings.""" + if isinstance(value, str) or not isinstance(value, Sequence): + return False + seq = cast(Sequence[object], value) + return all(isinstance(item, str) for item in seq) + + +_PCT_TRIPLET_RE = re.compile(r"%[0-9A-Fa-f]{2}") + + +def _encode(value: str, *, allow_reserved: bool) -> str: + """Percent-encode a value per RFC 6570 ยง3.2.1. + + Simple expansion encodes everything except unreserved characters. + Reserved expansion (``{+var}``, ``{#var}``) additionally keeps + RFC 3986 reserved characters intact and passes through existing + ``%XX`` pct-triplets unchanged (RFC 6570 ยง3.2.3). A bare ``%`` not + followed by two hex digits is still encoded to ``%25``. + """ + if not allow_reserved: + return quote(value, safe="") + + # Reserved expansion: walk the string, pass through triplets as-is, + # quote the gaps between them. A bare % with no triplet lands in a + # gap and gets encoded normally. + out: list[str] = [] + last = 0 + for m in _PCT_TRIPLET_RE.finditer(value): + out.append(quote(value[last : m.start()], safe=_RESERVED)) + out.append(m.group()) + last = m.end() + out.append(quote(value[last:], safe=_RESERVED)) + return "".join(out) + + +def _expand_expression(expr: _Expression, variables: Mapping[str, str | Sequence[str]]) -> str: + """Expand a single ``{...}`` expression into its URI fragment. + + Walks the expression's variables, encoding and joining defined ones + according to the operator's spec. Undefined variables are skipped + (RFC 6570 ยง2.3); if all are undefined, the expression contributes + nothing (no prefix is emitted). + """ + spec = _OPERATOR_SPECS[expr.operator] + rendered: list[str] = [] + + for var in expr.variables: + if var.name not in variables: + # Undefined: skip entirely, no placeholder. + continue + + value = variables[var.name] + + # Explicit type guard: reject non-str scalars with a clear message + # rather than a confusing "not iterable" from the sequence branch. + if not isinstance(value, str) and not _is_str_sequence(value): + raise TypeError(f"Variable {var.name!r} must be str or a sequence of str, got {type(value).__name__}") + + if isinstance(value, str): + encoded = _encode(value, allow_reserved=spec.allow_reserved) + if spec.named: + rendered.append(f"{var.name}{spec.ifemp}" if value == "" else f"{var.name}={encoded}") + else: + rendered.append(encoded) + else: + # Sequence value. + items = [_encode(v, allow_reserved=spec.allow_reserved) for v in value] + if not items: + continue + if var.explode: + # Each item gets the operator's separator; named ops repeat the key. + if spec.named: + rendered.append( + spec.separator.join(f"{var.name}{spec.ifemp}" if v == "" else f"{var.name}={v}" for v in items) + ) + else: + rendered.append(spec.separator.join(items)) + else: + # Non-explode: comma-join into a single value, then apply + # ifemp to the joined result (RFC ยง3.2.1: behaves as if the + # value were the joined string). + joined = ",".join(items) + if spec.named: + rendered.append(f"{var.name}{spec.ifemp}" if joined == "" else f"{var.name}={joined}") + else: + rendered.append(joined) + + if not rendered: + return "" + return spec.prefix + spec.separator.join(rendered) + + +@dataclass(frozen=True) +class UriTemplate: + """A parsed RFC 6570 URI template. + + Construct via :meth:`parse`. Instances are immutable and hashable; + equality is based on the template string alone. + """ + + template: str + _parts: list[_Part] = field(repr=False, compare=False) + _variables: list[Variable] = field(repr=False, compare=False) + _prefix: list[_Atom] = field(repr=False, compare=False) + _greedy: Variable | None = field(repr=False, compare=False) + _suffix: list[_Atom] = field(repr=False, compare=False) + _query_variables: list[Variable] = field(repr=False, compare=False) + + @staticmethod + def is_template(value: str) -> bool: + """Check whether a string contains URI template expressions. + + A cheap heuristic for distinguishing concrete URIs from templates + without the cost of full parsing. Returns ``True`` if the string + contains at least one ``{...}`` pair. + + Example:: + + >>> UriTemplate.is_template("file://docs/{name}") + True + >>> UriTemplate.is_template("file://docs/readme.txt") + False + + Note: + This does not validate the template. A ``True`` result does + not guarantee :meth:`parse` will succeed. + """ + open_i = value.find("{") + return open_i != -1 and value.find("}", open_i) != -1 + + @classmethod + def parse( + cls, + template: str, + *, + max_length: int = DEFAULT_MAX_TEMPLATE_LENGTH, + max_variables: int = DEFAULT_MAX_VARIABLES, + ) -> UriTemplate: + """Parse a URI template string. + + Args: + template: An RFC 6570 URI template. + max_length: Maximum permitted length of the template string. + Guards against resource exhaustion. + max_variables: Maximum number of variables permitted across + all expressions. Counting variables rather than + ``{...}`` expressions closes the gap where a single + ``{v0,v1,...,vN}`` expression packs arbitrarily many + variables under one expression count. + + Raises: + InvalidUriTemplate: If the template is malformed, exceeds the + size limits, or uses unsupported RFC 6570 features. + """ + if len(template) > max_length: + raise InvalidUriTemplate( + f"Template exceeds maximum length of {max_length}", + template=template, + ) + + parts, variables = _parse(template, max_variables=max_variables) + + # Trailing {?...}/{&...} expressions are split off and matched as + # a query string (order-agnostic, partial, extras ignored) rather + # than via the linear scan. + path_parts, query_vars = _split_query_tail(parts) + atoms = _flatten(path_parts) + prefix, greedy, suffix = _partition_greedy(atoms, template) + + return cls( + template=template, + _parts=parts, + _variables=variables, + _prefix=prefix, + _greedy=greedy, + _suffix=suffix, + _query_variables=query_vars, + ) + + @property + def variables(self) -> list[Variable]: + """All variables in the template, in order of appearance.""" + return list(self._variables) + + @property + def variable_names(self) -> list[str]: + """All variable names in the template, in order of appearance.""" + return [v.name for v in self._variables] + + @property + def query_variable_names(self) -> frozenset[str]: + """Names of variables that :meth:`match` treats as optional query parameters. + + These are the variables in a trailing run of ``{?...}``/``{&...}`` + expressions, which are matched leniently: a URI that omits some + (or all) of them still matches, and the omitted names are simply + absent from the result. Any value bound to such a name therefore + needs a fallback for the omitted case. + + Every other variable is bound on every successful :meth:`match` + (possibly to an empty string) and is *not* in this set. That + includes a ``{&...}`` expression with no preceding ``{?...}``: it + never emits the ``?`` the lenient query split keys on, so it is + matched strictly. + """ + return frozenset(v.name for v in self._query_variables) + + def expand(self, variables: Mapping[str, str | Sequence[str]]) -> str: + """Expand the template by substituting variable values. + + String values are percent-encoded according to their operator: + simple ``{var}`` encodes reserved characters; ``{+var}`` and + ``{#var}`` leave them intact. Sequence values are joined with + commas for non-explode variables, or with the operator's + separator for explode variables. + + Example:: + + >>> t = UriTemplate.parse("file://docs/{name}") + >>> t.expand({"name": "hello world.txt"}) + 'file://docs/hello%20world.txt' + + >>> t = UriTemplate.parse("file://docs/{+path}") + >>> t.expand({"path": "src/main.py"}) + 'file://docs/src/main.py' + + >>> t = UriTemplate.parse("/search{?q,lang}") + >>> t.expand({"q": "mcp", "lang": "en"}) + '/search?q=mcp&lang=en' + + >>> t = UriTemplate.parse("/files{/path*}") + >>> t.expand({"path": ["a", "b", "c"]}) + '/files/a/b/c' + + Args: + variables: Values for each template variable. Keys must be + strings; values must be ``str`` or a sequence of ``str``. + + Returns: + The expanded URI string. + + Note: + Per RFC 6570, variables absent from the mapping are + **silently omitted**. This is the correct behavior for + optional query parameters (``{?page}`` with no page yields + no ``?page=``), but for required path segments it produces + a structurally incomplete URI. If you need all variables + present, validate before calling:: + + missing = set(t.variable_names) - variables.keys() + if missing: + raise ValueError(f"Missing: {missing}") + + Raises: + TypeError: If a value is neither ``str`` nor an iterable of + ``str``. Non-string scalars (``int``, ``None``) are not + coerced. + """ + out: list[str] = [] + for part in self._parts: + if isinstance(part, str): + out.append(part) + else: + out.append(_expand_expression(part, variables)) + return "".join(out) + + def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> dict[str, str | list[str]] | None: + """Match a concrete URI against this template and extract variables. + + This is the inverse of :meth:`expand`. The URI is matched via a + linear scan of the template and captured values are + percent-decoded. The round-trip ``match(expand({k: v})) == {k: v}`` + holds when ``v`` does not contain its operator's separator + unencoded: ``{.ext}`` with ``ext="tar.gz"`` expands to + ``.tar.gz`` but does not match โ€” the scan stops ``ext`` at the + first ``.`` and the trailing ``.gz`` has nothing to consume it. + RFC 6570 ยง1.4 notes this is an inherent reversal limitation. + + Matching is structural at the URI level only: a simple ``{name}`` + will not match across a literal ``/`` in the URI (the scan stops + there), but a percent-encoded ``%2F`` that decodes to ``/`` is + accepted as part of the value. Path-safety validation belongs at + a higher layer; see :mod:`mcp.shared.path_security`. + + Example:: + + >>> t = UriTemplate.parse("file://docs/{name}") + >>> t.match("file://docs/readme.txt") + {'name': 'readme.txt'} + >>> t.match("file://docs/hello%20world.txt") + {'name': 'hello world.txt'} + + >>> t = UriTemplate.parse("file://docs/{+path}") + >>> t.match("file://docs/src/main.py") + {'path': 'src/main.py'} + + >>> t = UriTemplate.parse("/files{/path*}") + >>> t.match("/files/a/b/c") + {'path': ['a', 'b', 'c']} + + **Query parameters** (``{?q,lang}`` at the end of a template) + are matched leniently: order-agnostic, partial, and unrecognized + params are ignored. Absent params are omitted from the result so + downstream function defaults can apply:: + + >>> t = UriTemplate.parse("logs://{service}{?since,level}") + >>> t.match("logs://api") + {'service': 'api'} + >>> t.match("logs://api?level=error") + {'service': 'api', 'level': 'error'} + >>> t.match("logs://api?level=error&since=5m&utm=x") + {'service': 'api', 'since': '5m', 'level': 'error'} + + Args: + uri: A concrete URI string. + max_uri_length: Maximum permitted length of the input URI. + Oversized inputs return ``None`` without scanning, + guarding against resource exhaustion. + + Returns: + A mapping from variable names to decoded values (``str`` for + scalar variables, ``list[str]`` for explode variables), or + ``None`` if the URI does not match the template or exceeds + ``max_uri_length``. + """ + if len(uri) > max_uri_length: + return None + + if self._query_variables: + # Two-phase: scan matches the path, the query is split and + # decoded manually. Query params may be partial, reordered, + # or include extras; absent params stay absent so downstream + # defaults can apply. Fragment is stripped first since the + # template's {?...} tail never describes a fragment. + before_fragment, _, _ = uri.partition("#") + path, _, query = before_fragment.partition("?") + result = self._scan(path) + if result is None: + return None + if query: + parsed = _parse_query(query) + for var in self._query_variables: + if var.name in parsed: + result[var.name] = parsed[var.name] + return result + + return self._scan(uri) + + def _scan(self, uri: str) -> dict[str, str | list[str]] | None: + """Run the two-ended linear scan against the path portion of a URI.""" + n = len(uri) + + if self._greedy is None: + # No greedy var: the suffix IS the whole template, scanned + # right-to-left and anchored so atoms[0] matches at position 0. + suffix = _scan_suffix(self._suffix, uri, n, anchored=True) + if suffix is None: + return None + suffix_result, suffix_start = suffix + return suffix_result if suffix_start == 0 else None + + # Greedy var present. The parser rejects a capture adjacent to + # the greedy slot, so a non-empty suffix begins with a _Lit whose + # rfind-derived anchor does not depend on how far the prefix + # scans. Scan the suffix first, then give the prefix that exact + # position as its ceiling so it cannot consume past the anchor. + suffix = _scan_suffix(self._suffix, uri, n, anchored=False) + if suffix is None: + return None + suffix_result, suffix_start = suffix + prefix = _scan_prefix(self._prefix, uri, 0, suffix_start) + if prefix is None: + return None + prefix_result, prefix_end = prefix + + # Prefix consumed [0, prefix_end); suffix consumed [suffix_start, n); + # the greedy var takes the gap. The prefix scan is bounded by + # suffix_start, so this holds by construction; guard explicitly + # rather than asserting so a future regression surfaces as a + # non-match, not an exception. + if suffix_start < prefix_end: + return None # pragma: no cover - unreachable while bounds hold + middle = uri[prefix_end:suffix_start] + greedy_value = _extract_greedy(self._greedy, middle) + if greedy_value is None: + return None + + return {**prefix_result, self._greedy.name: greedy_value, **suffix_result} + + def __str__(self) -> str: + return self.template + + +def _parse_query(query: str) -> dict[str, str]: + """Parse a query string into a nameโ†’value mapping. + + Unlike ``urllib.parse.parse_qs``, this follows RFC 3986 semantics: + ``+`` is a literal sub-delim, not a space. Form-urlencoding treats + ``+`` as space for HTML form submissions, but RFC 6570 and MCP + resource URIs follow RFC 3986 where only ``%20`` encodes a space. + + Parameter names are **not** percent-decoded. RFC 6570 expansion + never encodes variable names, so a legitimate match will always + have the name in literal form. Decoding names would let + ``%74oken=evil&token=real`` shadow the real ``token`` parameter + via first-wins. + + Duplicate keys keep the first value. Pairs without ``=`` are + treated as empty-valued. + """ + result: dict[str, str] = {} + for pair in query.split("&"): + name, _, value = pair.partition("=") + if name and name not in result: + result[name] = unquote(value) + return result + + +def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None: + """Decode the greedy variable's isolated middle span. + + For scalar greedy (``{+var}``, ``{#var}``) this is a stop-char + validation and a single ``unquote``. For explode variables the span + is a run of separator-delimited segments (``/a/b/c`` or + ``;keys=a;keys=b``) that is split, validated, and decoded per item. + """ + spec = _OPERATOR_SPECS[var.operator] + stops = _STOP_CHARS[var.operator] + + if not var.explode: + if any(c in stops for c in raw): + return None + return unquote(raw) + + sep = spec.separator + if not raw: + return [] + # A non-empty explode span must begin with the separator: {/a*} + # expands to "/x/y", never "x/y". The scan does not consume the + # separator itself, so it must be the first character here. + if raw[0] != sep: + return None + # Segments must not contain the operator's non-separator stop + # characters (e.g. {/path*} segments may contain neither ? nor #). + body_stops = set(stops) - {sep} + if any(c in body_stops for c in raw): + return None + + segments: list[str] = [] + prefix = f"{var.name}=" + # split()[0] is always "" because raw starts with the separator; + # subsequent empties are legitimate values ({/path*} with + # ["a","","c"] expands to /a//c). + for seg in raw.split(sep)[1:]: + if spec.named: + # Named explode emits name=value per item (or bare name + # under ; with empty value). Validate the name and strip + # the prefix before decoding. + if seg.startswith(prefix): + seg = seg[len(prefix) :] + elif seg == var.name: + seg = "" + else: + return None + segments.append(unquote(seg)) + return segments + + +def _split_query_tail(parts: list[_Part]) -> tuple[list[_Part], list[Variable]]: + """Separate trailing ``?``/``&`` expressions from the path portion. + + Lenient query matching (order-agnostic, partial, ignores extras) + applies when a template ends with one or more consecutive ``?``/``&`` + expressions and the preceding path portion contains no literal + ``?``. If the path has a literal ``?`` (e.g., ``?fixed=1{&page}``), + the URI's ``?`` split won't align with the template's expression + boundary, so the strict scan is used instead. + + Returns: + A pair ``(path_parts, query_vars)``. If lenient matching does + not apply, ``query_vars`` is empty and ``path_parts`` is the + full input. + """ + split = len(parts) + for i in range(len(parts) - 1, -1, -1): + part = parts[i] + if isinstance(part, _Expression) and part.operator in ("?", "&"): + split = i + else: + break + + if split == len(parts): + return parts, [] + + # The tail must start with a {?...} expression so that expand() + # emits a ? the URI can split on. A standalone {&page} expands + # with an & prefix, which partition("?") won't find. + first = parts[split] + assert isinstance(first, _Expression) + if first.operator != "?": + return parts, [] + + # If the path portion contains a literal ?/# or a {?...}/{#...} + # expression, lenient matching's partition("#") then partition("?") + # would strip content the path scan expects to see. Fall back to + # the strict scan. + for part in parts[:split]: + if isinstance(part, str): + if "?" in part or "#" in part: + return parts, [] + elif part.operator in ("?", "#"): + return parts, [] + + query_vars: list[Variable] = [] + for part in parts[split:]: + assert isinstance(part, _Expression) + query_vars.extend(part.variables) + + return parts[:split], query_vars + + +def _parse(template: str, *, max_variables: int) -> tuple[list[_Part], list[Variable]]: + """Split a template into an ordered sequence of literals and expressions. + + Walks the string, alternating between collecting literal runs and + parsing ``{...}`` expressions. The resulting ``parts`` sequence + preserves positional interleaving so ``match()`` and ``expand()`` can + walk it in order. + + Raises: + InvalidUriTemplate: On unclosed braces, too many expressions, or + any error surfaced by :func:`_parse_expression`. + """ + parts: list[_Part] = [] + variables: list[Variable] = [] + i = 0 + n = len(template) + + while i < n: + # Find the next expression opener from the current cursor. + brace = template.find("{", i) + + if brace == -1: + # No more expressions; everything left is a trailing literal. + parts.append(template[i:]) + break + + if brace > i: + # Literal text between cursor and the brace. + parts.append(template[i:brace]) + + end = template.find("}", brace) + if end == -1: + raise InvalidUriTemplate( + f"Unclosed expression at position {brace}", + template=template, + position=brace, + ) + + # Delegate body (between braces, exclusive) to the expression parser. + expr = _parse_expression(template, template[brace + 1 : end], brace) + parts.append(expr) + variables.extend(expr.variables) + + if len(variables) > max_variables: + raise InvalidUriTemplate( + f"Template exceeds maximum of {max_variables} variables", + template=template, + ) + + # Advance past the closing brace. + i = end + 1 + + _check_duplicate_variables(template, variables) + _check_single_query_expression(template, parts) + return parts, variables + + +def _parse_expression(template: str, body: str, pos: int) -> _Expression: + """Parse the body of a single ``{...}`` expression. + + The body is everything between the braces. It consists of an optional + leading operator character followed by one or more comma-separated + variable specifiers. Each specifier is a name with an optional + trailing ``*`` (explode modifier). + + Args: + template: The full template string, for error reporting. + body: The expression body, braces excluded. + pos: Character offset of the opening brace, for error reporting. + + Raises: + InvalidUriTemplate: On empty body, invalid variable names, or + unsupported modifiers. + """ + if not body: + raise InvalidUriTemplate(f"Empty expression at position {pos}", template=template, position=pos) + + # Peel off the operator, if any. Membership check justifies the cast. + operator: Operator = "" + if body[0] in _OPERATORS: + operator = cast(Operator, body[0]) + body = body[1:] + if not body: + raise InvalidUriTemplate( + f"Expression has operator but no variables at position {pos}", + template=template, + position=pos, + ) + + # Remaining body is comma-separated variable specs: name[*] + variables: list[Variable] = [] + for spec in body.split(","): + if ":" in spec: + raise InvalidUriTemplate( + f"Prefix modifier {{var:N}} is not supported (in {spec!r} at position {pos})", + template=template, + position=pos, + ) + + explode = spec.endswith("*") + name = spec[:-1] if explode else spec + + if not _VARNAME_RE.fullmatch(name): + raise InvalidUriTemplate( + f"Invalid variable name {name!r} at position {pos}", + template=template, + position=pos, + ) + + # Explode only makes sense for operators that repeat a separator. + # Simple/reserved/fragment have no per-item separator; query-explode + # needs order-agnostic dict matching which we don't support yet. + if explode and operator in ("", "+", "#", "?", "&"): + raise InvalidUriTemplate( + f"Explode modifier on {{{operator}{name}*}} is not supported for matching", + template=template, + position=pos, + ) + + variables.append(Variable(name=name, operator=operator, explode=explode)) + + return _Expression(operator=operator, variables=variables) + + +def _check_duplicate_variables(template: str, variables: list[Variable]) -> None: + """Reject templates that use the same variable name more than once. + + RFC 6570 requires repeated variables to expand to the same value, + which would require backreference matching with potentially + exponential cost. Rather than silently returning only the last + captured value, we reject at parse time. + + Raises: + InvalidUriTemplate: If any variable name appears more than once. + """ + seen: set[str] = set() + for var in variables: + if var.name in seen: + raise InvalidUriTemplate( + f"Variable {var.name!r} appears more than once; repeated variables are not supported", + template=template, + ) + seen.add(var.name) + + +def _check_single_query_expression(template: str, parts: list[_Part]) -> None: + """Reject templates with more than one ``{?...}`` expression. + + The ``?`` operator emits a leading ``?``, so two such expressions + expand to a URI with two ``?`` characters โ€” malformed per RFC 3986 + ยง3.4. Use ``{?a,b}`` or ``{?a}{&b}`` for multiple query parameters. + """ + seen = False + for part in parts: + if isinstance(part, _Expression) and part.operator == "?": + if seen: + raise InvalidUriTemplate( + "Template contains more than one {?...} expression; " + "use {?a,b} or {?a}{&b} for multiple query parameters", + template=template, + ) + seen = True + + +def _flatten(parts: list[_Part]) -> list[_Atom]: + """Lower expressions into a flat sequence of literals and single-variable captures. + + Operator prefixes and separators become explicit ``_Lit`` atoms so + the scan only ever sees two atom kinds. Adjacent literals are + coalesced so that anchor-finding (``find``/``rfind``) operates on + the longest possible literal, reducing false matches. + + Explode variables emit no lead literal: the explode capture + includes its own separator-prefixed repetitions (``{/a*}`` โ†’ + ``/x/y/z``, not ``/`` then ``x/y/z``). + """ + atoms: list[_Atom] = [] + + def push_lit(text: str) -> None: + if not text: + return + if atoms and isinstance(atoms[-1], _Lit): + atoms[-1] = _Lit(atoms[-1].text + text) + else: + atoms.append(_Lit(text)) + + for part in parts: + if isinstance(part, str): + push_lit(part) + continue + spec = _OPERATOR_SPECS[part.operator] + for i, var in enumerate(part.variables): + lead = spec.prefix if i == 0 else spec.separator + if var.explode: + atoms.append(_Cap(var)) + elif spec.named: + # ; uses ifemp (bare name when empty); ? and & always + # emit name= so the equals is part of the literal. + if part.operator == ";": + push_lit(f"{lead}{var.name}") + atoms.append(_Cap(var, ifemp=True)) + else: + push_lit(f"{lead}{var.name}=") + atoms.append(_Cap(var)) + else: + push_lit(lead) + atoms.append(_Cap(var)) + return atoms + + +def _partition_greedy(atoms: list[_Atom], template: str) -> tuple[list[_Atom], Variable | None, list[_Atom]]: + """Split atoms at the single greedy variable, if any. + + Returns ``(prefix, greedy_var, suffix)``. If there is no greedy + variable the entire atom list is returned as the suffix so that + the right-to-left scan (which matches regex-greedy semantics) + handles it. + + Raises: + InvalidUriTemplate: If two variables are adjacent with no + literal between them โ€” whether or not one is the + multi-segment variable, the scan has nothing to anchor the + boundary on โ€” or if more than one multi-segment variable + is present (two are inherently ambiguous: there is no + principled way to decide which one absorbs an extra + segment). + """ + greedy_idx: int | None = None + prev: _Atom | None = None + for i, atom in enumerate(atoms): + if isinstance(atom, _Cap): + if isinstance(prev, _Cap): + raise InvalidUriTemplate( + f"Variables {prev.var.name!r} and {atom.var.name!r} are adjacent " + "with no literal separator; matching cannot determine where one " + "ends and the other begins. Add a literal between them or use a " + "single variable.", + template=template, + ) + if _is_greedy(atom.var): + if greedy_idx is not None: + raise InvalidUriTemplate( + "Template contains more than one multi-segment variable " + "({+var}, {#var}, or explode modifier); matching would be ambiguous", + template=template, + ) + greedy_idx = i + prev = atom + if greedy_idx is None: + return [], None, atoms + greedy = atoms[greedy_idx] + assert isinstance(greedy, _Cap) + return atoms[:greedy_idx], greedy.var, atoms[greedy_idx + 1 :] + + +def _scan_suffix( + atoms: Sequence[_Atom], uri: str, end: int, *, anchored: bool +) -> tuple[dict[str, str | list[str]], int] | None: + """Scan atoms right-to-left from ``end``, returning captures and start position. + + Each bounded variable takes the minimum span that lets its + preceding literal match (found via ``rfind``), which makes the + *first* variable in template order greedy โ€” identical to Python + regex semantics for a sequence of greedy groups. + + When ``anchored`` is true the atom sequence is the entire template + (no greedy variable), so ``atoms[0]`` must match at URI position 0 + rather than at its rightmost occurrence. + """ + result: dict[str, str | list[str]] = {} + pos = end + i = len(atoms) - 1 + while i >= 0: + atom = atoms[i] + if isinstance(atom, _Lit): + n = len(atom.text) + if pos < n or uri[pos - n : pos] != atom.text: + return None + pos -= n + i -= 1 + continue + + var = atom.var + stops = _STOP_CHARS[var.operator] + prev = atoms[i - 1] if i > 0 else None + + if atom.ifemp: + # ;name or ;name=value. The preceding _Lit is ";name". + # Try empty first: if the lit ends at pos the value is + # absent (RFC ifemp). Otherwise require =value. + assert isinstance(prev, _Lit) + if uri.endswith(prev.text, 0, pos): + result[var.name] = "" + i -= 1 + continue + earliest = pos + while earliest > 0 and uri[earliest - 1] not in stops: + earliest -= 1 + eq = uri.find("=", earliest, pos) + if eq == -1: + return None + result[var.name] = unquote(uri[eq + 1 : pos]) + pos = eq + i -= 1 + continue + + # Earliest valid start: the var cannot extend left past any + # stop-char, so scan backward to find that boundary. + earliest = pos + while earliest > 0 and uri[earliest - 1] not in stops: + earliest -= 1 + + if prev is None: + start = earliest + else: + # prev is a _Lit: the parser rejects two adjacent captures, + # so the only possible neighbour kind is a literal. + assert isinstance(prev, _Lit) + if anchored and i - 1 == 0: + # First atom of the whole template: positionally fixed at + # 0, not rightmost occurrence. rfind would land inside the + # value when the literal repeats there (e.g. "prefix-{id}" + # against "prefix-prefix-123"). + start = len(prev.text) + if start < earliest or start > pos: + return None + else: + # Rightmost occurrence of the preceding literal whose end + # falls within the var's valid range. + idx = uri.rfind(prev.text, 0, pos) + if idx == -1 or idx + len(prev.text) < earliest: + return None + start = idx + len(prev.text) + + result[var.name] = unquote(uri[start:pos]) + pos = start + i -= 1 + return result, pos + + +def _scan_prefix( + atoms: Sequence[_Atom], uri: str, start: int, limit: int +) -> tuple[dict[str, str | list[str]], int] | None: + """Scan atoms left-to-right from ``start``, not exceeding ``limit``. + + Each bounded variable takes the minimum span that lets its + following literal match (found via ``find``), leaving the + greedy variable as much of the URI as possible. + """ + result: dict[str, str | list[str]] = {} + pos = start + for i, atom in enumerate(atoms): + if isinstance(atom, _Lit): + end = pos + len(atom.text) + if end > limit or uri[pos:end] != atom.text: + return None + pos = end + continue + + var = atom.var + stops = _STOP_CHARS[var.operator] + # Every capture here is followed by a literal: the parser rejects + # two adjacent captures, and a capture at the END of the prefix + # would be adjacent to the greedy variable. + nxt = atoms[i + 1] + assert isinstance(nxt, _Lit) + + if atom.ifemp: + # RFC ยง3.2.7 ifemp: ;name=val for non-empty, bare ;name for + # empty. Decide which form is present without falling through + # to the stop-char scan when the value is empty. + if uri.startswith(nxt.text, pos): + # Following literal begins immediately: value is empty. + # Checked before '=' so a literal that itself starts + # with '=' is not mistaken for the ifemp separator. + result[var.name] = "" + continue + if pos < limit and uri[pos] == "=": + pos += 1 # value follows; fall through to the scan + else: + # The following literal does not start here and there is + # no '=': the URI's name continued past the template's + # (e.g. ;keys vs ;key) โ€” no parse. + return None + + # Latest valid end: the var stops at the first stop-char or + # the scan limit, whichever comes first. + latest = pos + while latest < limit and uri[latest] not in stops: + latest += 1 + + # First occurrence of the following literal: the capture takes + # the minimum span, leaving the greedy variable as much of the + # URI as possible. The search window's upper bound already + # forces any hit to start at or before ``latest``, so the var + # never extends past a stop-char. + end = uri.find(nxt.text, pos, latest + len(nxt.text)) + if end == -1: + return None + + result[var.name] = unquote(uri[pos:end]) + pos = end + return result, pos diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/types/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp/types/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9bdfa06a564ca6475b30f89e0740e43359d182e0 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/types/__init__.py @@ -0,0 +1,31 @@ +"""The MCP protocol wire types, as the `mcp.types` namespace. + +This module mirrors the standalone `mcp_types` package exactly (every name is the +same object), so SDK users can keep the familiar v1 spelling: + + import mcp.types as types + + types.TextContent(type="text", text="hi") + +The `mcp.types.jsonrpc`, `mcp.types.methods`, and `mcp.types.version` +submodules mirror `mcp_types.jsonrpc`, `mcp_types.methods`, and +`mcp_types.version` the same way, so every supported `mcp_types` import has +an `mcp.types` spelling. + +Depend on and import `mcp_types` directly instead when you only need to +(de)serialize MCP traffic and don't want the SDK's transport stack: its only +runtime dependencies are `pydantic` and `typing-extensions`. +""" + +# A wildcard mirror of the mcp_types namespace is the whole point of this module. +# pyright: reportWildcardImportFromLibrary=false + +from mcp_types import * +from mcp_types import __all__ as __all__ + +# Bind the mirror submodules on the package, so `mcp.types.version.X` is as +# reachable by attribute access as `mcp_types.version.X` (whose `__init__` +# binds `.version` by importing from it), not only via `from ... import`. +from . import jsonrpc as jsonrpc +from . import methods as methods +from . import version as version diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/types/jsonrpc.py b/.mcp-venv/lib/python3.13/site-packages/mcp/types/jsonrpc.py new file mode 100644 index 0000000000000000000000000000000000000000..a76b74ee26feff9bbd4e1262832f77636d7d0030 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/types/jsonrpc.py @@ -0,0 +1,13 @@ +"""The JSON-RPC 2.0 message and error types, as the `mcp.types.jsonrpc` namespace. + +A mirror of `mcp_types.jsonrpc` (every name is the same object), so code that +depends on `mcp` can import from `mcp.types.jsonrpc` without importing the +`mcp_types` distribution directly. Depend on and import `mcp_types.jsonrpc` +instead when you use `mcp-types` without the SDK. +""" + +# A wildcard mirror of the mcp_types.jsonrpc namespace is the whole point of this module. +# pyright: reportWildcardImportFromLibrary=false + +from mcp_types.jsonrpc import * +from mcp_types.jsonrpc import __all__ as __all__ diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/types/methods.py b/.mcp-venv/lib/python3.13/site-packages/mcp/types/methods.py new file mode 100644 index 0000000000000000000000000000000000000000..4b01fcc6cf37fb703e4dfbaf0378304bac5b4708 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/types/methods.py @@ -0,0 +1,13 @@ +"""The MCP method registry, as the `mcp.types.methods` namespace. + +A mirror of `mcp_types.methods` (every name is the same object), so code that +depends on `mcp` can import from `mcp.types.methods` without importing the +`mcp_types` distribution directly. Depend on and import `mcp_types.methods` +instead when you use `mcp-types` without the SDK. +""" + +# A wildcard mirror of the mcp_types.methods namespace is the whole point of this module. +# pyright: reportWildcardImportFromLibrary=false + +from mcp_types.methods import * +from mcp_types.methods import __all__ as __all__ diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp/types/version.py b/.mcp-venv/lib/python3.13/site-packages/mcp/types/version.py new file mode 100644 index 0000000000000000000000000000000000000000..857c53f0f8fb0b82317d78c79e3af3eb4b13fb2d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp/types/version.py @@ -0,0 +1,13 @@ +"""The protocol-version registry, as the `mcp.types.version` namespace. + +A mirror of `mcp_types.version` (every name is the same object), so code that +depends on `mcp` can write `from mcp.types.version import LATEST_MODERN_VERSION` +without importing the `mcp_types` distribution directly. Depend on and import +`mcp_types.version` instead when you use `mcp-types` without the SDK. +""" + +# A wildcard mirror of the mcp_types.version namespace is the whole point of this module. +# pyright: reportWildcardImportFromLibrary=false + +from mcp_types.version import * +from mcp_types.version import __all__ as __all__ diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/INSTALLER b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/METADATA b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..9593ffbe3a7f08c0d0aab9c0fb1d2847c31560c9 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/METADATA @@ -0,0 +1,43 @@ +Metadata-Version: 2.4 +Name: mcp-types +Version: 2.0.0 +Summary: Model Context Protocol wire types +Project-URL: Homepage, https://modelcontextprotocol.io +Project-URL: Documentation, https://py.sdk.modelcontextprotocol.io/ +Project-URL: Repository, https://github.com/modelcontextprotocol/python-sdk +Project-URL: Issues, https://github.com/modelcontextprotocol/python-sdk/issues +Author: Model Context Protocol a Series of LF Projects, LLC. +Maintainer-email: David Soria Parra , Marcelo Trylesinski , Max Isbey , Felix Weinberger +License: MIT +License-File: LICENSE +Keywords: automation,llm,mcp +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Requires-Python: >=3.10 +Requires-Dist: pydantic>=2.12.0 +Requires-Dist: typing-extensions>=4.13.0 +Description-Content-Type: text/markdown + +# MCP Types + +The wire types for the [Model Context Protocol](https://modelcontextprotocol.io). + +This package holds the protocol message models, JSON-RPC envelope types, per-version +surface validators, and the protocol-version registry. Its only runtime dependencies are +`pydantic` and `typing-extensions`, so it can be installed on its own when you need to +(de)serialize MCP traffic without pulling in the full `mcp` SDK. + +```python +from mcp_types import Tool, CallToolRequest +from mcp_types.version import LATEST_PROTOCOL_VERSION +``` + +The `mcp` package re-exports these names, so existing `from mcp import Tool` imports +keep working. diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/RECORD b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..0c5933db9073cf6a5f969b9f0a3500222dc0d427 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/RECORD @@ -0,0 +1,22 @@ +mcp_types-2.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +mcp_types-2.0.0.dist-info/METADATA,sha256=641G3wfOKL8A9BOMm3aiwARt-lx-yfLF0i1g6zjtFio,1914 +mcp_types-2.0.0.dist-info/RECORD,, +mcp_types-2.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87 +mcp_types-2.0.0.dist-info/licenses/LICENSE,sha256=XhPbvB0SD8KgPOzefJFCSuLX3hG2PVje0vRDHiYe5Q0,1071 +mcp_types/__init__.py,sha256=eR3nUuX5XKk8OASZVhzLO2t4die9NOYqPp8yF2JYOS8,11548 +mcp_types/__pycache__/__init__.cpython-313.pyc,, +mcp_types/__pycache__/_types.cpython-313.pyc,, +mcp_types/__pycache__/_wire_base.cpython-313.pyc,, +mcp_types/__pycache__/jsonrpc.cpython-313.pyc,, +mcp_types/__pycache__/methods.cpython-313.pyc,, +mcp_types/__pycache__/version.cpython-313.pyc,, +mcp_types/_types.py,sha256=DQcAe_UFLF_hI9bxKt2uj9zU6r_Ss6L9Y5w6R2dbfZg,79979 +mcp_types/_v2025_11_25/__init__.py,sha256=mF-0Yc_jIU9a9OwnMUDa6g0lfNITsA0adgOZiPwgieY,104129 +mcp_types/_v2025_11_25/__pycache__/__init__.cpython-313.pyc,, +mcp_types/_v2026_07_28/__init__.py,sha256=RNt1DinD9pt6nWIUnmdsuqEZIwult5xS6Uw70VhKT-A,121356 +mcp_types/_v2026_07_28/__pycache__/__init__.cpython-313.pyc,, +mcp_types/_wire_base.py,sha256=Xcy2Q4X8UIPLK8ynK790a0HvI5XVkfR4TuqdVDhVM7Y,315 +mcp_types/jsonrpc.py,sha256=wtG8HQfF68dZeRnk9lYRw4h9UEEPEVtKSdeRj2j_v4k,4485 +mcp_types/methods.py,sha256=6qC_exE-3X7QkaCnkoFWsapQI9Y8XEkaSt0fxiRooQ0,36220 +mcp_types/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +mcp_types/version.py,sha256=4kTAuw3ad0tQr0ovGiFFvmUReU_f1j5qbHr-YO5i5R4,2785 diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/WHEEL b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..7401812e19af977cc5088f3b8fb1ef6bc0441c0a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.31.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/licenses/LICENSE b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..3d48435454b105021b4f777c11b6b07d8d2ffea3 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types-2.0.0.dist-info/licenses/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp_types/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..41cec2edf32b8846b1fbfe08f29bc13cf82d7199 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types/__init__.py @@ -0,0 +1,453 @@ +"""This module defines the types for the MCP protocol. + +Check the latest schema at: +https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json +""" + +# Re-export everything from _types for backward compatibility +from mcp_types._types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + CORE_RESULT_TYPES, + DEFAULT_NEGOTIATED_VERSION, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, + Annotations, + AudioContent, + BaseMetadata, + BlobResourceContents, + CacheableResult, + CallToolRequest, + CallToolRequestParams, + CallToolResult, + CancelledNotification, + CancelledNotificationParams, + CancelTaskRequest, + CancelTaskRequestParams, + CancelTaskResult, + ClientCapabilities, + ClientNotification, + ClientRequest, + ClientResult, + ClientTasksCapability, + ClientTasksRequestsCapability, + CompleteRequest, + CompleteRequestParams, + CompleteResult, + Completion, + CompletionArgument, + CompletionContext, + CompletionsCapability, + ContentBlock, + CreateMessageRequest, + CreateMessageRequestParams, + CreateMessageResult, + CreateMessageResultWithTools, + CreateTaskResult, + DiscoverRequest, + DiscoverResult, + ElicitationCapability, + ElicitationRequiredErrorData, + ElicitCompleteNotification, + ElicitCompleteNotificationParams, + ElicitRequest, + ElicitRequestedSchema, + ElicitRequestFormParams, + ElicitRequestParams, + ElicitRequestURLParams, + ElicitResult, + EmbeddedResource, + EmptyResult, + FormElicitationCapability, + GetPromptRequest, + GetPromptRequestParams, + GetPromptResult, + GetTaskPayloadRequest, + GetTaskPayloadRequestParams, + GetTaskPayloadResult, + GetTaskRequest, + GetTaskRequestParams, + GetTaskResult, + Icon, + IconTheme, + ImageContent, + Implementation, + IncludeContext, + InitializedNotification, + InitializeRequest, + InitializeRequestParams, + InitializeResult, + InputRequest, + InputRequests, + InputRequiredResult, + InputResponse, + InputResponseRequestParams, + InputResponses, + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListResourceTemplatesRequest, + ListResourceTemplatesResult, + ListRootsRequest, + ListRootsResult, + ListTasksRequest, + ListTasksResult, + ListToolsRequest, + ListToolsResult, + LoggingCapability, + LoggingLevel, + LoggingMessageNotification, + LoggingMessageNotificationParams, + MissingRequiredClientCapabilityErrorData, + ModelHint, + ModelPreferences, + Notification, + NotificationParams, + PaginatedRequest, + PaginatedRequestParams, + PaginatedResult, + PingRequest, + ProgressNotification, + ProgressNotificationParams, + ProgressToken, + Prompt, + PromptArgument, + PromptListChangedNotification, + PromptMessage, + PromptReference, + PromptsCapability, + ReadResourceRequest, + ReadResourceRequestParams, + ReadResourceResult, + RelatedTaskMetadata, + Request, + RequestParams, + RequestParamsMeta, + Resource, + ResourceContents, + ResourceLink, + ResourceListChangedNotification, + ResourcesCapability, + ResourceTemplate, + ResourceTemplateReference, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + Result, + ResultType, + Role, + Root, + RootsCapability, + RootsListChangedNotification, + SamplingCapability, + SamplingContent, + SamplingContextCapability, + SamplingMessage, + SamplingMessageContentBlock, + SamplingToolsCapability, + ServerCapabilities, + ServerNotification, + ServerRequest, + ServerResult, + ServerTasksCapability, + ServerTasksRequestsCapability, + SetLevelRequest, + SetLevelRequestParams, + StopReason, + SubscribeRequest, + SubscribeRequestParams, + SubscriptionFilter, + SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, + SubscriptionsListenRequest, + SubscriptionsListenRequestParams, + SubscriptionsListenResult, + Task, + TaskMetadata, + TasksCallCapability, + TasksCancelCapability, + TasksCreateElicitationCapability, + TasksCreateMessageCapability, + TasksElicitationCapability, + TasksListCapability, + TasksSamplingCapability, + TaskStatus, + TaskStatusNotification, + TaskStatusNotificationParams, + TasksToolsCapability, + TextContent, + TextResourceContents, + Tool, + ToolAnnotations, + ToolChoice, + ToolExecution, + ToolListChangedNotification, + ToolResultContent, + ToolsCapability, + ToolUseContent, + UnsubscribeRequest, + UnsubscribeRequestParams, + UnsupportedProtocolVersionErrorData, + UrlElicitationCapability, + client_notification_adapter, + client_request_adapter, + client_result_adapter, + server_notification_adapter, + server_request_adapter, + server_result_adapter, +) + +# Re-export JSONRPC types +from mcp_types.jsonrpc import ( + CONNECTION_CLOSED, + HEADER_MISMATCH, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + JSONRPC_VERSION, + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, + PARSE_ERROR, + REQUEST_TIMEOUT, + UNSUPPORTED_PROTOCOL_VERSION, + URL_ELICITATION_REQUIRED, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + RequestId, + jsonrpc_message_adapter, +) +from mcp_types.version import LATEST_PROTOCOL_VERSION + +__all__ = [ + # Protocol version constants + "LATEST_PROTOCOL_VERSION", + "DEFAULT_NEGOTIATED_VERSION", + # Reserved request _meta keys + "PROTOCOL_VERSION_META_KEY", + "CLIENT_INFO_META_KEY", + "CLIENT_CAPABILITIES_META_KEY", + "LOG_LEVEL_META_KEY", + # Reserved result _meta keys + "SERVER_INFO_META_KEY", + # Type aliases and variables + "CORE_RESULT_TYPES", + "ContentBlock", + "ElicitRequestedSchema", + "ElicitRequestParams", + "IncludeContext", + "InputRequest", + "InputRequests", + "InputResponse", + "InputResponses", + "LoggingLevel", + "ProgressToken", + "ResultType", + "Role", + "SamplingContent", + "SamplingMessageContentBlock", + "StopReason", + "TaskStatus", + # Base classes + "BaseMetadata", + "Request", + "Notification", + "Result", + "RequestParams", + "RequestParamsMeta", + "InputResponseRequestParams", + "NotificationParams", + "PaginatedRequest", + "PaginatedRequestParams", + "PaginatedResult", + "CacheableResult", + "EmptyResult", + # Capabilities + "ClientCapabilities", + "ClientTasksCapability", + "ClientTasksRequestsCapability", + "CompletionsCapability", + "ElicitationCapability", + "FormElicitationCapability", + "LoggingCapability", + "PromptsCapability", + "ResourcesCapability", + "RootsCapability", + "SamplingCapability", + "SamplingContextCapability", + "SamplingToolsCapability", + "ServerCapabilities", + "ServerTasksCapability", + "ServerTasksRequestsCapability", + "TasksCallCapability", + "TasksCancelCapability", + "TasksCreateElicitationCapability", + "TasksCreateMessageCapability", + "TasksElicitationCapability", + "TasksListCapability", + "TasksSamplingCapability", + "TasksToolsCapability", + "ToolsCapability", + "UrlElicitationCapability", + # Content types + "Annotations", + "AudioContent", + "BlobResourceContents", + "EmbeddedResource", + "Icon", + "IconTheme", + "ImageContent", + "ResourceContents", + "ResourceLink", + "TextContent", + "TextResourceContents", + "ToolResultContent", + "ToolUseContent", + # Entity types + "Completion", + "CompletionArgument", + "CompletionContext", + "Implementation", + "ModelHint", + "ModelPreferences", + "Prompt", + "PromptArgument", + "PromptMessage", + "PromptReference", + "Resource", + "ResourceTemplate", + "ResourceTemplateReference", + "Root", + "SamplingMessage", + "SubscriptionFilter", + "Task", + "TaskMetadata", + "RelatedTaskMetadata", + "Tool", + "ToolAnnotations", + "ToolChoice", + "ToolExecution", + # Requests + "CallToolRequest", + "CallToolRequestParams", + "CompleteRequest", + "CompleteRequestParams", + "CancelTaskRequest", + "CancelTaskRequestParams", + "CreateMessageRequest", + "CreateMessageRequestParams", + "DiscoverRequest", + "ElicitRequest", + "ElicitRequestFormParams", + "ElicitRequestURLParams", + "GetPromptRequest", + "GetPromptRequestParams", + "GetTaskPayloadRequest", + "GetTaskPayloadRequestParams", + "GetTaskRequest", + "GetTaskRequestParams", + "InitializeRequest", + "InitializeRequestParams", + "ListPromptsRequest", + "ListResourcesRequest", + "ListResourceTemplatesRequest", + "ListRootsRequest", + "ListTasksRequest", + "ListToolsRequest", + "PingRequest", + "ReadResourceRequest", + "ReadResourceRequestParams", + "SetLevelRequest", + "SetLevelRequestParams", + "SubscribeRequest", + "SubscribeRequestParams", + "SubscriptionsListenRequest", + "SubscriptionsListenRequestParams", + "UnsubscribeRequest", + "UnsubscribeRequestParams", + # Results + "CallToolResult", + "CancelTaskResult", + "CompleteResult", + "CreateMessageResult", + "CreateMessageResultWithTools", + "CreateTaskResult", + "DiscoverResult", + "ElicitResult", + "ElicitationRequiredErrorData", + "GetPromptResult", + "GetTaskPayloadResult", + "GetTaskResult", + "InitializeResult", + "InputRequiredResult", + "ListPromptsResult", + "ListResourcesResult", + "ListResourceTemplatesResult", + "ListRootsResult", + "ListTasksResult", + "ListToolsResult", + "ReadResourceResult", + "SubscriptionsListenResult", + # Error data payloads + "MissingRequiredClientCapabilityErrorData", + "UnsupportedProtocolVersionErrorData", + # Notifications + "CancelledNotification", + "CancelledNotificationParams", + "ElicitCompleteNotification", + "ElicitCompleteNotificationParams", + "InitializedNotification", + "LoggingMessageNotification", + "LoggingMessageNotificationParams", + "ProgressNotification", + "ProgressNotificationParams", + "PromptListChangedNotification", + "ResourceListChangedNotification", + "ResourceUpdatedNotification", + "ResourceUpdatedNotificationParams", + "RootsListChangedNotification", + "SubscriptionsAcknowledgedNotification", + "SubscriptionsAcknowledgedNotificationParams", + "TaskStatusNotification", + "TaskStatusNotificationParams", + "ToolListChangedNotification", + # Union types for request/response routing + "ClientNotification", + "ClientRequest", + "ClientResult", + "ServerNotification", + "ServerRequest", + "ServerResult", + # Type adapters + "client_notification_adapter", + "client_request_adapter", + "client_result_adapter", + "server_notification_adapter", + "server_request_adapter", + "server_result_adapter", + # JSON-RPC types + "CONNECTION_CLOSED", + "HEADER_MISMATCH", + "INTERNAL_ERROR", + "INVALID_PARAMS", + "INVALID_REQUEST", + "JSONRPC_VERSION", + "METHOD_NOT_FOUND", + "MISSING_REQUIRED_CLIENT_CAPABILITY", + "PARSE_ERROR", + "REQUEST_TIMEOUT", + "UNSUPPORTED_PROTOCOL_VERSION", + "URL_ELICITATION_REQUIRED", + "ErrorData", + "JSONRPCError", + "JSONRPCMessage", + "JSONRPCNotification", + "JSONRPCRequest", + "JSONRPCResponse", + "RequestId", + "jsonrpc_message_adapter", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/_types.py b/.mcp-venv/lib/python3.13/site-packages/mcp_types/_types.py new file mode 100644 index 0000000000000000000000000000000000000000..5852d9bba3162caeb0b5399850aa176de9acaa02 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types/_types.py @@ -0,0 +1,2198 @@ +"""Version-superset MCP protocol models. + +One model per protocol construct, carrying every field from every supported +protocol version, so application code sees a single set of types regardless of +the negotiated version. Per-field docstrings note version availability. The +`mcp_types._v*` surface packages carry the schema-exact wire shapes. +""" + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar, Final, Generic, Literal, TypeAlias, TypeVar, get_args + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + FileUrl, + TypeAdapter, + model_validator, +) +from pydantic.alias_generators import to_camel +from typing_extensions import NotRequired, Self, TypedDict + +from mcp_types.jsonrpc import RequestId + +DEFAULT_NEGOTIATED_VERSION: Final[str] = "2025-03-26" +"""The default negotiated version of the Model Context Protocol when no version is specified. + +We need this to satisfy the MCP specification, which requires the server to assume a specific version if none is +provided by the client. + +See the "Protocol Version Header" at +https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header. +""" + +ProgressToken = str | int +"""A progress token, used to associate progress notifications with the original request.""" +Role = Literal["user", "assistant"] +"""The sender or recipient of messages and data in a conversation.""" + +IconTheme = Literal["light", "dark"] +"""Theme an icon is designed for. Wire values of `Icon.theme` (2025-11-25+).""" + + +class MCPModel(BaseModel): + """Base class for all MCP protocol types.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +Meta: TypeAlias = dict[str, Any] + +PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion" +"""Reserved request `_meta` key: the MCP protocol version for this request (2026-07-28). + +SDK-managed; for HTTP its value must match the `MCP-Protocol-Version` header. +""" + +CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo" +"""Reserved request `_meta` key: the client `Implementation` (2026-07-28). SDK-managed.""" + +CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities" +"""Reserved request `_meta` key: per-request `ClientCapabilities` (2026-07-28). SDK-managed.""" + +LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel" +"""Reserved request `_meta` key: desired log level for this request (2026-07-28). + +Deprecated (with the rest of logging) by SEP-2577 in the same revision that +introduces it. If absent, the server must not send log notifications. +""" + +SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo" +"""Reserved result `_meta` key: the server `Implementation` (2026-07-28). SDK-managed. + +Servers SHOULD stamp it on every result. The value is self-reported and +unverified - display, logging, and debugging only; never behavior or security. +""" + + +class RequestParamsMeta(TypedDict, extra_items=Any): + """The `_meta` object on request params (schema name: `RequestMetaObject`). + + An open map: arbitrary keys round-trip via `extra_items=Any`. Read or set + the reserved `io.modelcontextprotocol/*` keys via the `*_META_KEY` constants. + """ + + progress_token: NotRequired[ProgressToken] + """ + If specified, the caller requests out-of-band progress notifications for + this request (as represented by notifications/progress). The value of this + parameter is an opaque token that will be attached to any subsequent + notifications. The receiver is not obligated to provide these notifications. + """ + + +class RequestParams(MCPModel): + meta: RequestParamsMeta | None = Field(alias="_meta", default=None) + """Metadata reserved by MCP for protocol-level concerns (wire name `_meta`). + + Carries the optional progress token and, on 2026-07-28+ sessions, the + reserved `io.modelcontextprotocol/*` keys. Required on the wire for + 2026-07-28+ client requests; the session layer supplies the reserved + entries, so code sending through an SDK session leaves this unset. + """ + + +class PaginatedRequestParams(RequestParams): + cursor: str | None = None + """An opaque token representing the current pagination position. + + If provided, the server should return results starting after this cursor. + """ + + +class NotificationParams(MCPModel): + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +RequestParamsT = TypeVar("RequestParamsT", bound=RequestParams | dict[str, Any] | None) +NotificationParamsT = TypeVar("NotificationParamsT", bound=NotificationParams | dict[str, Any] | None) +MethodT = TypeVar("MethodT", bound=str) + + +class Request(MCPModel, Generic[RequestParamsT, MethodT]): + """Base class for JSON-RPC requests. + + The JSON-RPC envelope (`jsonrpc`, `id`) is attached by the session layer + (see `mcp_types.jsonrpc`), not carried here. + """ + + method: MethodT + params: RequestParamsT + + name_param: ClassVar[str | None] = None + """Wire-params key mirrored into the `Mcp-Name` header on sends; SEP-2663 requires it for tasks/*. + + Subclasses override by bare assignment: re-annotating as `ClassVar` trips pyright's invariance check. + """ + + +class PaginatedRequest(Request[PaginatedRequestParams | None, MethodT], Generic[MethodT]): + """Base class for paginated requests, matching the schema's PaginatedRequest interface.""" + + params: PaginatedRequestParams | None = None + """Pagination params. Required on the 2026-07-28+ wire (because `_meta` is); + the session layer materializes it there. Optional on earlier versions.""" + + +class Notification(MCPModel, Generic[NotificationParamsT, MethodT]): + """Base class for JSON-RPC notifications.""" + + method: MethodT + params: NotificationParamsT + + +_CoreResultType = Literal["complete", "input_required"] + +ResultType = _CoreResultType | str +"""Tags a `Result` so the client knows how to parse it (2026-07-28). + +"complete" means the result is final; "input_required" means it is an +`InputRequiredResult`. The union is open (the tasks extension reserves "task"). +Absent `resultType` is equivalent to "complete". +""" + +CORE_RESULT_TYPES: Final[frozenset[str]] = frozenset(get_args(_CoreResultType)) +"""The `resultType` tags owned by the core protocol vocabulary; extension claims may not re-key them.""" + + +class Result(MCPModel): + """Base class for JSON-RPC results. + + `result_type` is declared per concrete subclass, not here, because defaults + differ: most results default to "complete", `EmptyResult` defaults to None + (so it dumps as `{}`; some peer SDKs strict-validate empty results), and + `InputRequiredResult` carries a literal. + """ + + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class PaginatedResult(Result): + next_cursor: str | None = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + + +class CacheableResult(Result): + """Base class for results that carry client-side caching directives (2026-07-28). + + Both fields are required on the 2026-07-28 wire. The SDK defaults to + `ttl_ms=0` (immediately stale) and `cache_scope="private"` so a handler + that doesn't set them still produces a valid 2026-07-28 result without + accidentally enabling shared caching. + """ + + ttl_ms: Annotated[int, Field(ge=0)] = 0 + """How long (ms) the client MAY cache this response, analogous to HTTP + `Cache-Control: max-age`. 0 means immediately stale.""" + + cache_scope: Literal["public", "private"] = "private" + """Analogous to HTTP `Cache-Control: public` vs `private`: "public" allows + shared caches to serve the response to any user; "private" forbids that.""" + + +class EmptyResult(Result): + """A result that indicates success but carries no data. + + `result_type` defaults to None so this dumps as `{}`: deployed TypeScript + and Rust SDK peers (clients and servers) validate empty results strictly + and reject extra keys. The 2026-07-28 schema requires `resultType`, so code + answering an empty result on a 2026-07-28+ session must pass + `result_type="complete"`. + """ + + result_type: ResultType | None = None + """None keeps the dump empty; see the class docstring.""" + + +class BaseMetadata(MCPModel): + """Base class for entities with a programmatic name and an optional display title.""" + + name: str + """Intended for programmatic or logical use, but used as a display name in past + specs or fallback (if title isn't present).""" + + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class Icon(MCPModel): + """An optionally-sized icon for display in a user interface (2025-11-25+).""" + + src: str + """A standard URI pointing to an icon resource (`http(s):` or `data:`). + + Consumers SHOULD ensure icon URLs come from a trusted domain and SHOULD + take appropriate precautions when consuming SVGs (which can contain script). + """ + + mime_type: str | None = None + """Optional MIME type override if the source MIME type is missing or generic.""" + + sizes: list[str] | None = None + """Optional sizes this icon is available in: WxH (e.g. `"48x48"`) or `"any"`. + If not provided, assume the icon can be used at any size.""" + + theme: IconTheme | None = None + """The theme this icon is designed for. If not provided, assume any theme.""" + + +class Implementation(BaseMetadata): + """Describes the name and version of an MCP implementation (`clientInfo` / `serverInfo`).""" + + version: str + description: str | None = None + """An optional human-readable description of what this implementation does.""" + + website_url: str | None = None + """An optional URL of the website for this implementation.""" + + icons: list[Icon] | None = None + """Optional set of sized icons that the client can display in a user interface.""" + + +class RootsCapability(MCPModel): + """Capability for root operations. + + Deprecated in protocol 2026-07-28 (SEP-2577) but still carried there as an + empty object (`list_changed` exists only through 2025-11-25). + """ + + list_changed: bool | None = None + """Whether the client supports notifications for changes to the roots list.""" + + +class SamplingContextCapability(MCPModel): + """Capability for context inclusion during sampling. + + Indicates support for non-'none' values in the includeContext parameter. + SOFT-DEPRECATED: New implementations should use tools parameter instead. + """ + + +class SamplingToolsCapability(MCPModel): + """Capability indicating support for tool calling during sampling. + + When present in ClientCapabilities.sampling, indicates that the client + supports the tools and toolChoice parameters in sampling requests. + """ + + +class FormElicitationCapability(MCPModel): + """Capability for form mode elicitation.""" + + +class UrlElicitationCapability(MCPModel): + """Capability for URL mode elicitation (2025-11-25+).""" + + +class ElicitationCapability(MCPModel): + """Capability for elicitation operations. + + Clients must support at least one mode (form or url). + """ + + form: FormElicitationCapability | None = None + """Present if the client supports form mode elicitation.""" + + url: UrlElicitationCapability | None = None + """Present if the client supports URL mode elicitation (2025-11-25 and later).""" + + +class SamplingCapability(MCPModel): + """Sampling capability structure. Deprecated in 2026-07-28 (SEP-2577); shape unchanged.""" + + context: SamplingContextCapability | None = None + """ + Present if the client supports non-'none' values for includeContext parameter. + SOFT-DEPRECATED: New implementations should use tools parameter instead. + """ + tools: SamplingToolsCapability | None = None + """ + Present if the client supports tools and toolChoice parameters in sampling requests. + Presence indicates full tool calling support during sampling. + """ + + +class TasksListCapability(MCPModel): + """Capability for tasks listing operations (2025-11-25 only).""" + + +class TasksCancelCapability(MCPModel): + """Capability for tasks cancel operations (2025-11-25 only).""" + + +class TasksCreateMessageCapability(MCPModel): + """Capability for task-augmented sampling/createMessage requests (2025-11-25 only).""" + + +class TasksSamplingCapability(MCPModel): + """Capability for task-augmented sampling operations (2025-11-25 only).""" + + create_message: TasksCreateMessageCapability | None = None + + +class TasksCreateElicitationCapability(MCPModel): + """Capability for task-augmented elicitation/create requests (2025-11-25 only).""" + + +class TasksElicitationCapability(MCPModel): + """Capability for task-augmented elicitation operations (2025-11-25 only).""" + + create: TasksCreateElicitationCapability | None = None + + +class ClientTasksRequestsCapability(MCPModel): + """Specifies which request types the client can augment with tasks (2025-11-25 only).""" + + sampling: TasksSamplingCapability | None = None + elicitation: TasksElicitationCapability | None = None + + +class ClientTasksCapability(MCPModel): + """Capability for client tasks operations (2025-11-25 only).""" + + list: TasksListCapability | None = None + cancel: TasksCancelCapability | None = None + requests: ClientTasksRequestsCapability | None = None + + +class ClientCapabilities(MCPModel): + """Capabilities a client may support. + + Not a closed set: any client can define additional capabilities. Sent once in + `initialize` through 2025-11-25; per-request in `_meta` on 2026-07-28. + """ + + experimental: dict[str, dict[str, Any]] | None = None + """Experimental, non-standard capabilities that the client supports.""" + sampling: SamplingCapability | None = None + """ + Present if the client supports sampling from an LLM. + Can contain fine-grained capabilities like context and tools support. + """ + elicitation: ElicitationCapability | None = None + """Present if the client supports elicitation from the user.""" + roots: RootsCapability | None = None + """Present if the client supports listing roots.""" + extensions: dict[str, dict[str, Any]] | None = None + """MCP extensions the client supports (2026-07-28). Keys are extension + identifiers; values are per-extension settings (empty object = no settings).""" + tasks: ClientTasksCapability | None = None + """Present if the client supports task-augmented requests (2025-11-25 only).""" + + +class UnsupportedProtocolVersionErrorData(MCPModel): + """Error data for the -32022 unsupported-protocol-version error (2026-07-28).""" + + supported: list[str] + """Protocol versions the server supports; the client should pick one and retry.""" + + requested: str + + +class MissingRequiredClientCapabilityErrorData(MCPModel): + """Error data for the -32021 missing-required-client-capability error (2026-07-28).""" + + required_capabilities: ClientCapabilities + """The capabilities the server requires from the client to process this request.""" + + +class PromptsCapability(MCPModel): + """Capability for prompts operations.""" + + list_changed: bool | None = None + """Whether this server supports notifications for changes to the prompt list.""" + + +class ResourcesCapability(MCPModel): + """Capability for resources operations.""" + + subscribe: bool | None = None + """Whether this server supports subscribing to resource updates.""" + list_changed: bool | None = None + """Whether this server supports notifications for changes to the resource list.""" + + +class ToolsCapability(MCPModel): + """Capability for tools operations.""" + + list_changed: bool | None = None + """Whether this server supports notifications for changes to the tool list.""" + + +class LoggingCapability(MCPModel): + """Capability for logging operations.""" + + +class CompletionsCapability(MCPModel): + """Capability for completions operations.""" + + +class TasksCallCapability(MCPModel): + """Capability for task-augmented tools/call requests (2025-11-25 only).""" + + +class TasksToolsCapability(MCPModel): + """Capability for task-augmented tool operations (2025-11-25 only).""" + + call: TasksCallCapability | None = None + + +class ServerTasksRequestsCapability(MCPModel): + """Specifies which request types the server can augment with tasks (2025-11-25 only).""" + + tools: TasksToolsCapability | None = None + + +class ServerTasksCapability(MCPModel): + """Capability for server tasks operations (2025-11-25 only).""" + + list: TasksListCapability | None = None + cancel: TasksCancelCapability | None = None + requests: ServerTasksRequestsCapability | None = None + + +class ServerCapabilities(MCPModel): + """Capabilities that a server may support. Not a closed set.""" + + experimental: dict[str, dict[str, Any]] | None = None + """Experimental, non-standard capabilities that the server supports.""" + + logging: LoggingCapability | None = None + """Present if the server supports sending log messages to the client. + Deprecated in 2026-07-28 (SEP-2577).""" + + prompts: PromptsCapability | None = None + """Present if the server offers any prompt templates.""" + + resources: ResourcesCapability | None = None + """Present if the server offers any resources to read.""" + + tools: ToolsCapability | None = None + """Present if the server offers any tools to call.""" + + completions: CompletionsCapability | None = None + """Present if the server offers autocompletion suggestions for prompts and resources.""" + + extensions: dict[str, dict[str, Any]] | None = None + """MCP extensions the server supports (2026-07-28). Keys are extension + identifiers; values are per-extension settings (empty object = no settings).""" + + tasks: ServerTasksCapability | None = None + """Present if the server supports task-augmented requests (2025-11-25 only).""" + + +class InitializeRequestParams(RequestParams): + """Parameters for the `initialize` request. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + """ + + protocol_version: str + """The latest version of the Model Context Protocol that the client supports.""" + capabilities: ClientCapabilities + client_info: Implementation + + +class InitializeRequest(Request[InitializeRequestParams, Literal["initialize"]]): + """This request is sent from the client to the server when it first connects, asking it + to begin initialization. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + On 2026-07-28 the handshake is `server/discover` plus per-request `_meta`. + """ + + method: Literal["initialize"] = "initialize" + params: InitializeRequestParams + + +class InitializeResult(Result): + """After receiving an initialize request from the client, the server sends this response. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + """ + + protocol_version: str + """The version of the Model Context Protocol that the server wants to use. + If the client cannot support this version, it MUST disconnect.""" + capabilities: ServerCapabilities + server_info: Implementation + instructions: str | None = None + """Instructions describing how to use the server and its features. + + Clients may use this to improve an LLM's understanding of available tools, + resources, etc., for example by adding it to the system prompt. + """ + + +class InitializedNotification(Notification[NotificationParams | None, Literal["notifications/initialized"]]): + """This notification is sent from the client to the server after initialization has + finished. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + """ + + method: Literal["notifications/initialized"] = "notifications/initialized" + params: NotificationParams | None = None + + +class PingRequest(Request[RequestParams | None, Literal["ping"]]): + """A ping, issued by either the server or the client, to check that the other party is + still alive. The receiver must promptly respond, or else may be disconnected. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + """ + + method: Literal["ping"] = "ping" + params: RequestParams | None = None + + +class DiscoverRequest(Request[RequestParams | None, Literal["server/discover"]]): + """Asks the server to advertise its supported protocol versions, capabilities, + and other metadata (2026-07-28). + + Servers speaking 2026-07-28 MUST implement this; clients MAY call it but are + not required to (version negotiation can also happen via per-request `_meta`). + """ + + method: Literal["server/discover"] = "server/discover" + params: RequestParams | None = None + """Required on the 2026-07-28 wire (for `_meta`); the session layer materializes it.""" + + +class DiscoverResult(CacheableResult): + """The result returned by the server for a `server/discover` request (2026-07-28).""" + + supported_versions: list[str] + """MCP protocol versions this server supports; the client should pick one for subsequent requests.""" + + capabilities: ServerCapabilities + + instructions: str | None = None + """Natural-language guidance describing the server and its features, e.g. for + a system prompt. Should not duplicate information already in tool descriptions.""" + + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; required on the 2026-07-28 wire, + ignored by older peers, and defaulted on inbound bodies that omit it.""" + + +# Tasks: introduced in 2025-11-25, removed from the core spec in 2026-07-28 +# (continuing as an extension). Defined here types-only; their methods are not +# in the request/notification unions below, so they are never dispatched. + + +class ToolExecution(MCPModel): + """Execution-related properties for a tool (2025-11-25 only).""" + + task_support: Literal["forbidden", "optional", "required"] | None = None + """Whether this tool supports task-augmented execution. Absent means "forbidden".""" + + +class TaskMetadata(MCPModel): + """Metadata for augmenting a request with task execution (the `task` params field; 2025-11-25 only).""" + + ttl: int | None = None + """Requested duration in milliseconds to retain task from creation.""" + + +class RelatedTaskMetadata(MCPModel): + """Associates a message with a task, via `_meta["io.modelcontextprotocol/related-task"]` (2025-11-25 only).""" + + task_id: str + + +TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] +"""The status of a task (2025-11-25 only).""" + + +class Task(MCPModel): + """Data associated with a task (2025-11-25 only).""" + + task_id: str + + status: TaskStatus + + status_message: str | None = None + """Optional human-readable message describing the current task state.""" + + created_at: str + """ISO 8601 timestamp when the task was created.""" + + last_updated_at: str + """ISO 8601 timestamp when the task was last updated.""" + + ttl: int | None + """Actual retention duration from creation in milliseconds, null for unlimited.""" + + poll_interval: int | None = None + """Suggested polling interval in milliseconds.""" + + +class CreateTaskResult(Result): + """A response to a task-augmented request (2025-11-25 only).""" + + task: Task + + +class GetTaskRequestParams(RequestParams): + task_id: str + + +class GetTaskRequest(Request[GetTaskRequestParams, Literal["tasks/get"]]): + """A request to retrieve the state of a task (2025-11-25 only).""" + + method: Literal["tasks/get"] = "tasks/get" + params: GetTaskRequestParams + + +class GetTaskResult(Result, Task): + """The response to a tasks/get request (2025-11-25 only).""" + + +class CancelTaskRequestParams(RequestParams): + task_id: str + + +class CancelTaskRequest(Request[CancelTaskRequestParams, Literal["tasks/cancel"]]): + """A request to cancel a task (2025-11-25 only).""" + + method: Literal["tasks/cancel"] = "tasks/cancel" + params: CancelTaskRequestParams + + +class CancelTaskResult(Result, Task): + """The response to a tasks/cancel request (2025-11-25 only).""" + + +class TaskStatusNotificationParams(NotificationParams, Task): + """Parameters for a `notifications/tasks/status` notification.""" + + +class TaskStatusNotification(Notification[TaskStatusNotificationParams, Literal["notifications/tasks/status"]]): + """An optional notification informing the requestor that a task's status has changed (2025-11-25 only).""" + + method: Literal["notifications/tasks/status"] = "notifications/tasks/status" + params: TaskStatusNotificationParams + + +class GetTaskPayloadRequestParams(RequestParams): + """Parameters for a tasks/result request.""" + + task_id: str + + +class GetTaskPayloadRequest(Request[GetTaskPayloadRequestParams, Literal["tasks/result"]]): + """A request to retrieve the result of a completed task (2025-11-25 only).""" + + method: Literal["tasks/result"] = "tasks/result" + params: GetTaskPayloadRequestParams + + +class GetTaskPayloadResult(Result): + """The response to a tasks/result request (2025-11-25 only). + + The structure matches the result type of the original request. The payload + arrives as extra wire fields, which `MCPModel` does not retain; validate the + response into the original request's result type (e.g. `CallToolResult`) + instead of this class. + """ + + +class ListTasksRequest(PaginatedRequest[Literal["tasks/list"]]): + """A request to retrieve a list of tasks (2025-11-25 only).""" + + method: Literal["tasks/list"] = "tasks/list" + + +class ListTasksResult(PaginatedResult): + """The response to a tasks/list request (2025-11-25 only).""" + + tasks: list[Task] + + +class ProgressNotificationParams(NotificationParams): + """Parameters for progress notifications.""" + + progress_token: ProgressToken + """ + The progress token which was given in the initial request, used to associate this + notification with the request that is proceeding. + """ + progress: float + """ + The progress thus far. This should increase every time progress is made, even if the + total is unknown. + """ + total: float | None = None + """Total number of items to process (or total progress required), if known.""" + message: str | None = None + """Message related to progress. + + This should provide relevant human-readable progress information. + """ + + +class ProgressNotification(Notification[ProgressNotificationParams, Literal["notifications/progress"]]): + """An out-of-band notification used to inform the receiver of a progress update for a long-running request.""" + + method: Literal["notifications/progress"] = "notifications/progress" + params: ProgressNotificationParams + + +class ListResourcesRequest(PaginatedRequest[Literal["resources/list"]]): + """Sent from the client to request a list of resources the server has.""" + + method: Literal["resources/list"] = "resources/list" + + +class Annotations(MCPModel): + """Optional annotations the client can use to inform how objects are used or displayed.""" + + audience: list[Role] | None = None + """Who the intended audience is, e.g. `["user", "assistant"]`.""" + + priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None + """How important this data is for operating the server: 1 means effectively + required, 0 means entirely optional.""" + + last_modified: str | None = None + """ISO 8601 timestamp of when the item was last modified.""" + + +class Resource(BaseMetadata): + """A known resource that the server is capable of reading.""" + + uri: str + """The URI of this resource.""" + + description: str | None = None + """A description of what this resource represents.""" + + mime_type: str | None = None + """The MIME type of this resource, if known.""" + + size: int | None = None + """The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + + This can be used by Hosts to display file sizes and estimate context window usage. + """ + + icons: list[Icon] | None = None + """Optional set of sized icons that the client can display in a user interface.""" + + annotations: Annotations | None = None + """Optional annotations for the client.""" + + meta: Meta | None = Field(alias="_meta", default=None) + """See the MCP specification for notes on `_meta` usage.""" + + +class ResourceTemplate(BaseMetadata): + """A template description for resources available on the server.""" + + uri_template: str + """A URI template (according to RFC 6570) that can be used to construct resource URIs.""" + + description: str | None = None + """A description of what this template is for.""" + + mime_type: str | None = None + """The MIME type for all resources that match this template. + + This should only be included if all resources matching this template have the same type. + """ + + icons: list[Icon] | None = None + """An optional set of sized icons that the client can display in a user interface.""" + + annotations: Annotations | None = None + """Optional annotations for the client.""" + + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class ListResourcesResult(PaginatedResult, CacheableResult): + """The server's response to a resources/list request from the client.""" + + resources: list[Resource] + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class ListResourceTemplatesRequest(PaginatedRequest[Literal["resources/templates/list"]]): + """Sent from the client to request a list of resource templates the server has.""" + + method: Literal["resources/templates/list"] = "resources/templates/list" + + +class ListResourceTemplatesResult(PaginatedResult, CacheableResult): + """The server's response to a resources/templates/list request from the client.""" + + resource_templates: list[ResourceTemplate] + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class InputResponseRequestParams(RequestParams): + """Base params for client requests that can carry responses to a server's + input requests (2026-07-28 multi-round-trip flow). + + When a request returns an `InputRequiredResult`, the client retries the + original request with these fields populated. + """ + + input_responses: InputResponses | None = None + """Responses to the server's `InputRequiredResult.input_requests`, keyed identically.""" + request_state: str | None = None + """Opaque state from the `InputRequiredResult`, passed back verbatim on retry.""" + + +class ReadResourceRequestParams(InputResponseRequestParams): + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server + how to interpret it. + """ + + +class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/read"]]): + """Sent from the client to the server, to read a specific resource URI.""" + + method: Literal["resources/read"] = "resources/read" + params: ReadResourceRequestParams + + +class ResourceContents(MCPModel): + """The contents of a specific resource or sub-resource.""" + + uri: str + """The URI of this resource.""" + mime_type: str | None = None + """The MIME type of this resource, if known.""" + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class TextResourceContents(ResourceContents): + """Text contents of a resource.""" + + text: str + """ + The text of the item. This must only be set if the item can actually be represented + as text (not binary data). + """ + + +class BlobResourceContents(ResourceContents): + """Binary contents of a resource.""" + + blob: str + """A base64-encoded string representing the binary data of the item.""" + + +class ReadResourceResult(CacheableResult): + """The server's response to a resources/read request from the client.""" + + contents: list[TextResourceContents | BlobResourceContents] + """The contents of the resource or sub-resources that were read.""" + + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class ResourceListChangedNotification( + Notification[NotificationParams | None, Literal["notifications/resources/list_changed"]] +): + """An optional notification from the server to the client, informing it that the list + of resources it can read from has changed. + + May be sent spontaneously through 2025-11-25; on 2026-07-28 sessions the + client must opt in via `subscriptions/listen`. + """ + + method: Literal["notifications/resources/list_changed"] = "notifications/resources/list_changed" + params: NotificationParams | None = None + + +class SubscribeRequestParams(RequestParams): + """Parameters for subscribing to a resource. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + """ + + uri: str + """ + The URI of the resource to subscribe to. The URI can use any protocol; it is up to + the server how to interpret it. + """ + + +class SubscribeRequest(Request[SubscribeRequestParams, Literal["resources/subscribe"]]): + """Sent from the client to request resources/updated notifications from the server + whenever a particular resource changes. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + On 2026-07-28 use `subscriptions/listen` instead. + """ + + method: Literal["resources/subscribe"] = "resources/subscribe" + params: SubscribeRequestParams + + +class UnsubscribeRequestParams(RequestParams): + """Parameters for a resources/unsubscribe request. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + """ + + uri: str + """The URI of the resource to unsubscribe from.""" + + +class UnsubscribeRequest(Request[UnsubscribeRequestParams, Literal["resources/unsubscribe"]]): + """Sent from the client to request cancellation of resources/updated notifications + from the server. This should follow a previous resources/subscribe request. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + On 2026-07-28 use `subscriptions/listen` instead. + """ + + method: Literal["resources/unsubscribe"] = "resources/unsubscribe" + params: UnsubscribeRequestParams + + +class ResourceUpdatedNotificationParams(NotificationParams): + uri: str + """ + The URI of the resource that has been updated. This might be a sub-resource of the + one that the client actually subscribed to. + """ + + +class ResourceUpdatedNotification( + Notification[ResourceUpdatedNotificationParams, Literal["notifications/resources/updated"]] +): + """A notification from the server to the client, informing it that a resource has + changed and may need to be read again. + + Only sent if the client subscribed: via `resources/subscribe` through + 2025-11-25, or `subscriptions/listen` on 2026-07-28. + """ + + method: Literal["notifications/resources/updated"] = "notifications/resources/updated" + params: ResourceUpdatedNotificationParams + + +class SubscriptionFilter(MCPModel): + """The set of notification types a client opts in to via `subscriptions/listen` (2026-07-28). + + Each type is opt-in; the server MUST NOT send types not requested here. + Echoed back in `notifications/subscriptions/acknowledged` as the subset the + server agreed to honor. Extensions merge additional keys (e.g. `taskIds`), + so unknown keys round-trip. + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="allow") + + tools_list_changed: bool | None = None + """If true, receive notifications/tools/list_changed.""" + + prompts_list_changed: bool | None = None + """If true, receive notifications/prompts/list_changed.""" + + resources_list_changed: bool | None = None + """If true, receive notifications/resources/list_changed.""" + + resource_subscriptions: list[str] | None = None + """Subscribe to notifications/resources/updated for these resource URIs.""" + + +class SubscriptionsListenRequestParams(RequestParams): + notifications: SubscriptionFilter + """The notifications the client opts in to on this stream.""" + + +class SubscriptionsListenRequest(Request[SubscriptionsListenRequestParams, Literal["subscriptions/listen"]]): + """Opens a long-lived channel for receiving notifications outside the context + of a specific request (2026-07-28). + """ + + method: Literal["subscriptions/listen"] = "subscriptions/listen" + params: SubscriptionsListenRequestParams + + +class SubscriptionsAcknowledgedNotificationParams(NotificationParams): + notifications: SubscriptionFilter + """The subset of requested notification types the server agreed to honor. + Unsupported types are omitted.""" + + +class SubscriptionsAcknowledgedNotification( + Notification[ + SubscriptionsAcknowledgedNotificationParams, + Literal["notifications/subscriptions/acknowledged"], + ] +): + """First message on a `subscriptions/listen` stream: acknowledges the + subscription and reports which notification types the server will honor (2026-07-28). + """ + + method: Literal["notifications/subscriptions/acknowledged"] = "notifications/subscriptions/acknowledged" + params: SubscriptionsAcknowledgedNotificationParams + + +class SubscriptionsListenResult(Result): + """Signals that a `subscriptions/listen` stream has ended gracefully (2026-07-28). + + Because the listen stream is long-lived, this result is sent only when the + server tears the subscription down (for example during shutdown); an abrupt + transport close carries no response. The body is otherwise empty: the + `_meta["io.modelcontextprotocol/subscriptionId"]` key is required on the + wire and equals the JSON-RPC id of the originating `subscriptions/listen` + request. + """ + + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class ListPromptsRequest(PaginatedRequest[Literal["prompts/list"]]): + """Sent from the client to request a list of prompts and prompt templates the server has.""" + + method: Literal["prompts/list"] = "prompts/list" + + +class PromptArgument(BaseMetadata): + """Describes an argument that a prompt can accept.""" + + description: str | None = None + """A human-readable description of the argument.""" + required: bool | None = None + """Whether this argument must be provided.""" + + +class Prompt(BaseMetadata): + """A prompt or prompt template that the server offers.""" + + description: str | None = None + """An optional description of what this prompt provides.""" + arguments: list[PromptArgument] | None = None + """A list of arguments to use for templating the prompt.""" + icons: list[Icon] | None = None + """An optional list of icons for this prompt.""" + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class ListPromptsResult(PaginatedResult, CacheableResult): + """The server's response to a prompts/list request from the client.""" + + prompts: list[Prompt] + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class GetPromptRequestParams(InputResponseRequestParams): + name: str + """The name of the prompt or prompt template.""" + arguments: dict[str, str] | None = None + """Arguments to use for templating the prompt.""" + + +class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]): + """Used by the client to get a prompt provided by the server.""" + + method: Literal["prompts/get"] = "prompts/get" + params: GetPromptRequestParams + + +class TextContent(MCPModel): + """Text provided to or from an LLM.""" + + type: Literal["text"] = "text" + text: str + """The text content of the message.""" + annotations: Annotations | None = None + """Optional annotations for the client.""" + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class ImageContent(MCPModel): + """An image provided to or from an LLM.""" + + type: Literal["image"] = "image" + data: str + """The base64-encoded image data.""" + mime_type: str + """ + The MIME type of the image. Different providers may support different + image types. + """ + annotations: Annotations | None = None + """Optional annotations for the client.""" + meta: Meta | None = Field(alias="_meta", default=None) + """See the MCP specification's "General fields: _meta" section for notes on _meta usage.""" + + +class AudioContent(MCPModel): + """Audio provided to or from an LLM.""" + + type: Literal["audio"] = "audio" + data: str + """The base64-encoded audio data.""" + mime_type: str + """ + The MIME type of the audio. Different providers may support different + audio types. + """ + annotations: Annotations | None = None + """Optional annotations for the client.""" + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class ToolUseContent(MCPModel): + """An assistant's request to invoke a tool during sampling (2025-11-25+). + + Appears in `sampling/createMessage` results and replayed assistant messages. + The server should execute the tool and return a `ToolResultContent` in the + next user message. Deprecated in 2026-07-28 (SEP-2577). + """ + + type: Literal["tool_use"] = "tool_use" + """Discriminator for tool use content.""" + + name: str + """The name of the tool to invoke. Must match a tool name from the request's tools array.""" + + id: str + """Unique identifier for this tool call, used to correlate with ToolResultContent.""" + + input: dict[str, Any] + """Arguments to pass to the tool. Must conform to the tool's inputSchema.""" + + meta: Meta | None = Field(alias="_meta", default=None) + """Optional metadata. Clients SHOULD preserve this in subsequent sampling + requests to enable caching optimizations.""" + + +class ToolResultContent(MCPModel): + """The result of a tool use, provided by the user back to the assistant (2025-11-25+). + + Appears in sampling messages as a response to a `ToolUseContent` block. + Requires the `sampling.tools` client capability. Deprecated in 2026-07-28 (SEP-2577). + """ + + type: Literal["tool_result"] = "tool_result" + """Discriminator for tool result content.""" + + tool_use_id: str + """The `id` of the `ToolUseContent` this result corresponds to.""" + + content: list[ContentBlock] = [] + """The unstructured result content (same format as `CallToolResult.content`).""" + + structured_content: Any = None + """An optional structured result value. Any JSON value on 2026-07-28; + restricted to a JSON object on 2025-11-25.""" + + is_error: bool | None = None + """Whether the tool use resulted in an error. Absent is equivalent to false.""" + + meta: Meta | None = Field(alias="_meta", default=None) + """Optional metadata. Clients SHOULD preserve this in subsequent sampling + requests to enable caching optimizations.""" + + +SamplingMessageContentBlock: TypeAlias = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent +"""Content block types allowed in sampling messages. + +This is the widest (2025-11-25+) membership; older sessions allow only a subset +on the wire. Serialization never narrows a value to fit; version gating is the +session layer's responsibility. Deprecated in 2026-07-28 (SEP-2577). +""" + +SamplingContent: TypeAlias = TextContent | ImageContent | AudioContent +"""Basic content types for sampling responses (without tool use). + +Used for backwards-compatible CreateMessageResult when tools are not used. +""" + + +class SamplingMessage(MCPModel): + """Describes a message issued to or received from an LLM API.""" + + role: Role + content: SamplingMessageContentBlock | list[SamplingMessageContentBlock] + """ + Message content. Can be a single content block or an array of content blocks + for multi-modal messages and tool interactions. + """ + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + @property + def content_as_list(self) -> list[SamplingMessageContentBlock]: + """Returns the content as a list of content blocks, regardless of whether + it was originally a single block or a list.""" + return self.content if isinstance(self.content, list) else [self.content] + + +class EmbeddedResource(MCPModel): + """The contents of a resource, embedded into a prompt or tool call result. + + It is up to the client how best to render embedded resources for the benefit + of the LLM and/or the user. + """ + + type: Literal["resource"] = "resource" + resource: TextResourceContents | BlobResourceContents + annotations: Annotations | None = None + """Optional annotations for the client.""" + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class ResourceLink(Resource): + """A resource that the server is capable of reading, included in a prompt or tool call result. + + Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + """ + + type: Literal["resource_link"] = "resource_link" + + +ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource +"""A content block that can be used in prompts and tool results.""" + + +class PromptMessage(MCPModel): + """Describes a message returned as part of a prompt. + + Similar to `SamplingMessage`, but also supports embedded resources. + """ + + role: Role + content: ContentBlock + + +class GetPromptResult(Result): + """The server's response to a prompts/get request from the client.""" + + description: str | None = None + """An optional description for the prompt.""" + messages: list[PromptMessage] + """The messages composing the prompt, in the order they should be presented.""" + + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class PromptListChangedNotification( + Notification[NotificationParams | None, Literal["notifications/prompts/list_changed"]] +): + """An optional notification from the server to the client, informing it that the list + of prompts it offers has changed. + + May be sent spontaneously through 2025-11-25; on 2026-07-28 sessions the + client must opt in via `subscriptions/listen`. + """ + + method: Literal["notifications/prompts/list_changed"] = "notifications/prompts/list_changed" + params: NotificationParams | None = None + + +class ListToolsRequest(PaginatedRequest[Literal["tools/list"]]): + """Sent from the client to request a list of tools the server has.""" + + method: Literal["tools/list"] = "tools/list" + + +class ToolAnnotations(MCPModel): + """Additional properties describing a Tool to clients. + + NOTE: all properties in ToolAnnotations are **hints**. + They are not guaranteed to provide a faithful description of + tool behavior (including descriptive properties like `title`). + + Clients should never make tool use decisions based on ToolAnnotations + received from untrusted servers. + """ + + title: str | None = None + """A human-readable title for the tool.""" + + read_only_hint: bool | None = None + """ + If true, the tool does not modify its environment. + Default: false + """ + + destructive_hint: bool | None = None + """ + If true, the tool may perform destructive updates to its environment. + If false, the tool performs only additive updates. + (This property is meaningful only when `read_only_hint == false`) + Default: true + """ + + idempotent_hint: bool | None = None + """ + If true, calling the tool repeatedly with the same arguments + will have no additional effect on its environment. + (This property is meaningful only when `read_only_hint == false`) + Default: false + """ + + open_world_hint: bool | None = None + """ + If true, this tool may interact with an "open world" of external + entities. If false, the tool's domain of interaction is closed. + For example, the world of a web search tool is open, whereas that + of a memory tool is not. + Default: true + """ + + +class Tool(BaseMetadata): + """Definition for a tool the client can call.""" + + description: str | None = None + """A human-readable description of the tool.""" + input_schema: dict[str, Any] + """A JSON Schema object defining the expected parameters for the tool. + + `type: "object"` is required at the root. 2026-07-28 allows any JSON Schema + 2020-12 keyword; earlier versions define only `type`/`properties`/`required`. + """ + execution: ToolExecution | None = None + """Execution-related properties (2025-11-25 only; removed in 2026-07-28).""" + output_schema: dict[str, Any] | None = None + """An optional JSON Schema object defining the structure of the tool's output + returned in the `structured_content` field of a `CallToolResult`. + + Restricted to `type: "object"` at the root through 2025-11-25; any valid + JSON Schema 2020-12 on 2026-07-28. + """ + icons: list[Icon] | None = None + """Optional set of sized icons for display (2025-11-25+).""" + annotations: ToolAnnotations | None = None + """Optional additional tool information. + Display-name precedence: `title`, `annotations.title`, then `name`.""" + meta: Meta | None = Field(alias="_meta", default=None) + """See the MCP specification for notes on `_meta` usage.""" + + +class ListToolsResult(PaginatedResult, CacheableResult): + """The server's response to a tools/list request from the client.""" + + tools: list[Tool] + + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class CallToolRequestParams(InputResponseRequestParams): + name: str + arguments: dict[str, Any] | None = None + task: TaskMetadata | None = None + """If specified, the caller requests task-augmented execution (2025-11-25 only).""" + + +class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]): + """Used by the client to invoke a tool provided by the server.""" + + method: Literal["tools/call"] = "tools/call" + params: CallToolRequestParams + + +class CallToolResult(Result): + """The server's response to a tool call. + + Errors that originate from the tool SHOULD be reported inside the result + with `is_error` set to true, not as an MCP protocol-level error, so the LLM + can see and self-correct. Errors in finding the tool, or any other + exceptional condition, should be reported as an MCP error response. + """ + + content: list[ContentBlock] + """A list of content objects that represent the unstructured result of the tool call.""" + structured_content: Any = None + """An optional JSON value representing the structured result of the tool call. + + Any JSON value on 2026-07-28; restricted to a JSON object on 2025-06-18 and + 2025-11-25. + """ + is_error: bool = False + """Whether the tool call ended in an error.""" + + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class ToolListChangedNotification(Notification[NotificationParams | None, Literal["notifications/tools/list_changed"]]): + """An optional notification from the server to the client, informing it that the list + of tools it offers has changed. + + May be sent spontaneously through 2025-11-25; on 2026-07-28 sessions the + client must opt in via `subscriptions/listen`. + """ + + method: Literal["notifications/tools/list_changed"] = "notifications/tools/list_changed" + params: NotificationParams | None = None + + +LoggingLevel = Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] +"""The severity of a log message. + +These map to syslog severities (RFC-5424 section 6.2.1). Logging is deprecated +in 2026-07-28 (SEP-2577); the level scale is unchanged across versions. +""" + + +class SetLevelRequestParams(RequestParams): + """Parameters for setting the logging level. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + """ + + level: LoggingLevel + """The level of logging that the client wants to receive from the server. + The server should send all logs at this level and higher (more severe).""" + + +class SetLevelRequest(Request[SetLevelRequestParams, Literal["logging/setLevel"]]): + """A request from the client to the server, to enable or adjust logging. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + On 2026-07-28 the client opts in per-request via `_meta` (`LOG_LEVEL_META_KEY`). + """ + + method: Literal["logging/setLevel"] = "logging/setLevel" + params: SetLevelRequestParams + + +class LoggingMessageNotificationParams(NotificationParams): + level: LoggingLevel + """The severity of this log message.""" + logger: str | None = None + """An optional name of the logger issuing this message.""" + data: Any + """ + The data to be logged, such as a string message or an object. Any JSON serializable + type is allowed here. + """ + + +class LoggingMessageNotification(Notification[LoggingMessageNotificationParams, Literal["notifications/message"]]): + """Notification of a log message passed from server to client. + + Through 2025-11-25 the client subscribes via `logging/setLevel`. On + 2026-07-28 the client opts in per-request via `_meta` (`LOG_LEVEL_META_KEY`) + and the server MUST NOT send this without it. Deprecated in 2026-07-28 (SEP-2577). + """ + + method: Literal["notifications/message"] = "notifications/message" + params: LoggingMessageNotificationParams + + +IncludeContext = Literal["none", "thisServer", "allServers"] +"""Scope of MCP-server context a sampling request asks the client to attach. + +"thisServer" and "allServers" are deprecated (SEP-2596). +""" + + +class ModelHint(MCPModel): + """Hints to use for model selection. + + Keys not declared here are up to the client to interpret. Deprecated in + 2026-07-28 (SEP-2577) with the rest of sampling. + """ + + name: str | None = None + """A hint for a model name. + + The client SHOULD treat this as a substring (e.g. `sonnet` matches + `claude-3-5-sonnet-20241022`) and MAY map it to another provider's model + that fills a similar niche. + """ + + +class ModelPreferences(MCPModel): + """The server's preferences for model selection, requested of the client during + sampling. + + Because LLMs can vary along multiple dimensions, choosing the "best" model is + rarely straightforward. Different models excel in different areasโ€”some are + faster but less capable, others are more capable but more expensive, and so + on. This interface allows servers to express their priorities across multiple + dimensions to help clients make an appropriate selection for their use case. + + These preferences are always advisory. The client MAY ignore them. It is also + up to the client to decide how to interpret these preferences and how to + balance them against other considerations. + + Deprecated in 2026-07-28 (SEP-2577) with the rest of sampling. + """ + + hints: list[ModelHint] | None = None + """ + Optional hints to use for model selection. + + If multiple hints are specified, the client MUST evaluate them in order + (such that the first match is taken). + + The client SHOULD prioritize these hints over the numeric priorities, but + MAY still use the priorities to select from ambiguous matches. + """ + + cost_priority: float | None = None + """ + How much to prioritize cost when selecting a model. A value of 0 means cost + is not important, while a value of 1 means cost is the most important + factor. + """ + + speed_priority: float | None = None + """ + How much to prioritize sampling speed (latency) when selecting a model. A + value of 0 means speed is not important, while a value of 1 means speed is + the most important factor. + """ + + intelligence_priority: float | None = None + """ + How much to prioritize intelligence and capabilities when selecting a + model. A value of 0 means intelligence is not important, while a value of 1 + means intelligence is the most important factor. + """ + + +class ToolChoice(MCPModel): + """Controls tool selection behavior for sampling requests (2025-11-25+). + + The client MUST return an error if this is received without the + `sampling.tools` capability. Absent means `{"mode": "auto"}`. + """ + + mode: Literal["auto", "required", "none"] | None = None + """ + Controls the tool use ability of the model: + - "auto": Model decides whether to use tools (default) + - "required": Model MUST use at least one tool before completing + - "none": Model MUST NOT use any tools + """ + + +class CreateMessageRequestParams(RequestParams): + messages: list[SamplingMessage] + """The conversation to sample from.""" + model_preferences: ModelPreferences | None = None + """ + The server's preferences for which model to select. The client MAY ignore + these preferences. + """ + system_prompt: str | None = None + """An optional system prompt the server wants to use for sampling.""" + include_context: IncludeContext | None = None + """ + A request to include context from one or more MCP servers (including the + caller), to be attached to the prompt. The client MAY ignore this request. + Default is "none". "thisServer" and "allServers" are deprecated (SEP-2596). + """ + temperature: float | None = None + max_tokens: int + """The maximum number of tokens to sample, as requested by the server.""" + stop_sequences: list[str] | None = None + metadata: dict[str, Any] | None = None + """Optional metadata to pass through to the LLM provider. Provider-specific.""" + tools: list[Tool] | None = None + """Tools the model may use during generation (2025-11-25+). Requires the + `sampling.tools` client capability.""" + tool_choice: ToolChoice | None = None + """Controls how the model uses tools (2025-11-25+). Requires the + `sampling.tools` client capability.""" + task: TaskMetadata | None = None + """If specified, the caller requests task-augmented execution (2025-11-25 only).""" + + +class CreateMessageRequest(Request[CreateMessageRequestParams, Literal["sampling/createMessage"]]): + """A request from the server to sample an LLM via the client. + + The client has full discretion over which model to select and should inform + the user before sampling (human in the loop). A standalone JSON-RPC request + through 2025-11-25; on 2026-07-28 it is embedded in + `InputRequiredResult.input_requests` instead. Deprecated in 2026-07-28 (SEP-2577). + """ + + method: Literal["sampling/createMessage"] = "sampling/createMessage" + params: CreateMessageRequestParams + + +StopReason = Literal["endTurn", "stopSequence", "maxTokens", "toolUse"] | str +"""The reason why sampling stopped, if known. + +An open union to allow provider-specific stop reasons. "toolUse" is 2025-11-25+. +""" + + +class CreateMessageResult(Result): + """The client's response to a sampling/createMessage request from the server. + + This is the backwards-compatible version that returns single content (no arrays). + Used when the request does not include tools. + + On 2026-07-28 this travels embedded in an `InputResponses` map rather than + as a top-level JSON-RPC result. Deprecated in 2026-07-28 (SEP-2577). + """ + + role: Role + """The role of the message sender (typically 'assistant' for LLM responses).""" + content: SamplingContent + """Response content. Single content block (text, image, or audio).""" + model: str + """The name of the model that generated the message.""" + stop_reason: StopReason | None = None + """The reason why sampling stopped, if known.""" + + +class CreateMessageResultWithTools(Result): + """The client's response to a sampling/createMessage request when tools were provided. + + This version supports array content for tool use flows (2025-11-25 and later). + """ + + role: Role + """The role of the message sender (typically 'assistant' for LLM responses).""" + content: SamplingMessageContentBlock | list[SamplingMessageContentBlock] + """ + Response content. May be a single content block or an array. + May include ToolUseContent if stop_reason is 'toolUse'. + """ + model: str + """The name of the model that generated the message.""" + stop_reason: StopReason | None = None + """ + The reason why sampling stopped, if known. + 'toolUse' indicates the model wants to use a tool. + """ + + @property + def content_as_list(self) -> list[SamplingMessageContentBlock]: + """Returns the content as a list of content blocks, regardless of whether + it was originally a single block or a list.""" + return self.content if isinstance(self.content, list) else [self.content] + + +class ResourceTemplateReference(MCPModel): + """A reference to a resource or resource template definition.""" + + type: Literal["ref/resource"] = "ref/resource" + uri: str + """The URI or URI template of the resource.""" + + +# Not BaseMetadata: inheriting would reorder dump keys for existing callers. +class PromptReference(MCPModel): + """Identifies a prompt.""" + + type: Literal["ref/prompt"] = "ref/prompt" + name: str + """The name of the prompt or prompt template.""" + title: str | None = None + """Human-readable display title. If not provided, `name` should be used for display.""" + + +class CompletionArgument(MCPModel): + """The argument's information for completion requests.""" + + name: str + """The name of the argument.""" + value: str + """The value of the argument to use for completion matching.""" + + +class CompletionContext(MCPModel): + """Additional, optional context for completions.""" + + arguments: dict[str, str] | None = None + """Previously-resolved variables in a URI template or prompt.""" + + +class CompleteRequestParams(RequestParams): + ref: ResourceTemplateReference | PromptReference + """The prompt or resource-template reference to complete against.""" + argument: CompletionArgument + context: CompletionContext | None = None + """Additional, optional context for completions.""" + + +class CompleteRequest(Request[CompleteRequestParams, Literal["completion/complete"]]): + """A request from the client to the server, to ask for completion options.""" + + method: Literal["completion/complete"] = "completion/complete" + params: CompleteRequestParams + + +class Completion(MCPModel): + """Completion information.""" + + values: list[str] + """An array of completion values. Must not exceed 100 items.""" + total: int | None = None + """ + The total number of completion options available. This can exceed the number of + values actually sent in the response. + """ + has_more: bool | None = None + """ + Indicates whether there are additional completion options beyond those provided in + the current response, even if the exact total is unknown. + """ + + +class CompleteResult(Result): + """The server's response to a completion/complete request.""" + + completion: Completion + """The completion values, with optional total / has-more pagination hints.""" + + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + +class ListRootsRequest(Request[RequestParams | None, Literal["roots/list"]]): + """Sent from the server to request a list of root URIs from the client. Roots allow + servers to ask for specific directories or files to operate on. A common example + for roots is providing a set of repositories or directories a server should operate + on. + + This request is typically used when the server needs to understand the file system + structure or access specific locations that the client has permission to read from. + + A standalone JSON-RPC request through 2025-11-25; on 2026-07-28 it is + embedded in `InputRequiredResult.input_requests`. Deprecated in 2026-07-28 (SEP-2577). + """ + + method: Literal["roots/list"] = "roots/list" + params: RequestParams | None = None + """Stays optional on 2026-07-28 (reserved client `_meta` keys do not apply + to server-to-client payloads).""" + + +class Root(MCPModel): + """Represents a root directory or file that the server can operate on. + + Deprecated in 2026-07-28 (SEP-2577) with the rest of roots. + """ + + uri: FileUrl + """ + The URI identifying the root. This *must* start with file:// for now. + This restriction may be relaxed in future versions of the protocol to allow + other URI schemes. + """ + name: str | None = None + """ + An optional name for the root. This can be used to provide a human-readable + identifier for the root, which may be useful for display purposes or for + referencing the root in other parts of the application. + """ + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class ListRootsResult(Result): + """The client's response to a roots/list request from the server. + + This result contains an array of Root objects, each representing a root + directory or file that the server can operate on. + + On 2026-07-28 this is carried as an `InputResponses` entry, not a JSON-RPC + result. Deprecated in 2026-07-28 (SEP-2577). + """ + + roots: list[Root] + + +class RootsListChangedNotification( + Notification[NotificationParams | None, Literal["notifications/roots/list_changed"]] +): + """A notification from the client to the server, informing it that the list of + roots has changed. + + This notification should be sent whenever the client adds, removes, or + modifies any root. The server should then request an updated list of roots + using the ListRootsRequest. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating <= 2025-11-25. + """ + + method: Literal["notifications/roots/list_changed"] = "notifications/roots/list_changed" + params: NotificationParams | None = None + + +class CancelledNotificationParams(NotificationParams): + request_id: RequestId | None = None + """ + The ID of the request to cancel. + + This MUST correspond to the ID of a request previously issued in the same direction. + Required on the wire through 2025-06-18; optional at 2025-11-25; required again from + 2026-07-28, where it must name a request the client previously issued (servers send + this notification only to terminate a `subscriptions/listen` stream). + """ + reason: str | None = None + """An optional string describing the reason for the cancellation.""" + + +class CancelledNotification(Notification[CancelledNotificationParams, Literal["notifications/cancelled"]]): + """This notification can be sent by either side to indicate that it is canceling a + previously-issued request. + + The request SHOULD still be in-flight, but due to communication latency, it + is always possible that this notification MAY arrive after the request has + already finished. A client MUST NOT attempt to cancel its `initialize` request. + """ + + method: Literal["notifications/cancelled"] = "notifications/cancelled" + params: CancelledNotificationParams + + +class ElicitCompleteNotificationParams(NotificationParams): + """Parameters for elicitation completion notifications.""" + + elicitation_id: str + """The unique identifier of the elicitation that was completed.""" + + +class ElicitCompleteNotification( + Notification[ElicitCompleteNotificationParams, Literal["notifications/elicitation/complete"]] +): + """A notification from the server to the client, informing it that a URL mode + elicitation has been completed. + + Clients MAY use the notification to automatically retry requests that received a + URLElicitationRequiredError, update the user interface, or otherwise continue + an interaction. However, because delivery of the notification is not guaranteed, + clients must not wait indefinitely for a notification from the server. + + New in protocol 2025-11-25 with URL mode itself. + """ + + method: Literal["notifications/elicitation/complete"] = "notifications/elicitation/complete" + params: ElicitCompleteNotificationParams + + +# Kept as a raw JSON Schema dict so callers can hand it straight to a validator; +# the per-version packages model RequestedSchema/PrimitiveSchemaDefinition strictly. +ElicitRequestedSchema: TypeAlias = dict[str, Any] + + +class ElicitRequestFormParams(RequestParams): + """Parameters for form mode elicitation requests. + + Form mode collects non-sensitive information from the user via an in-band form + rendered by the client. + """ + + mode: Literal["form"] = "form" + """The elicitation mode (always "form" for this type).""" + + message: str + """The message to present to the user describing what information is being requested.""" + + requested_schema: ElicitRequestedSchema + """ + A restricted subset of JSON Schema defining the structure of the expected response. + Only top-level properties are allowed, without nesting. + """ + + task: TaskMetadata | None = None + """If specified, the caller requests task-augmented execution (2025-11-25 only).""" + + +class ElicitRequestURLParams(RequestParams): + """Parameters for URL mode elicitation requests. + + URL mode directs users to external URLs for sensitive out-of-band interactions + like OAuth flows, credential collection, or payment processing. New in 2025-11-25. + """ + + mode: Literal["url"] = "url" + """The elicitation mode (always "url" for this type).""" + + message: str + """The message to present to the user explaining why the interaction is needed.""" + + url: str + """The URL that the user should navigate to.""" + + elicitation_id: str | None = None + """The ID of the elicitation, which must be unique within the context of the server. + + The client MUST treat this ID as an opaque value. Required on the wire at + 2025-11-25; removed at 2026-07-28. + """ + + task: TaskMetadata | None = None + """If specified, the caller requests task-augmented execution (2025-11-25 only).""" + + +# Union type for elicitation request parameters +ElicitRequestParams: TypeAlias = ElicitRequestURLParams | ElicitRequestFormParams +"""Parameters for elicitation requests - either form or URL mode.""" + + +class ElicitRequest(Request[ElicitRequestParams, Literal["elicitation/create"]]): + """A request from the server to elicit additional information from the user via the client.""" + + method: Literal["elicitation/create"] = "elicitation/create" + params: ElicitRequestParams + + +class ElicitResult(Result): + """The client's response to an elicitation request.""" + + action: Literal["accept", "decline", "cancel"] + """ + The user action in response to the elicitation. + - "accept": User submitted the form/confirmed the action (or consented to URL navigation) + - "decline": User explicitly declined the action + - "cancel": User dismissed without making an explicit choice + """ + + content: dict[str, str | int | float | bool | list[str] | None] | None = None + """ + The submitted form data, only present when action is "accept" in form mode. + Contains values matching the requested schema. Values can be strings, integers, floats, + booleans, arrays of strings, or null. + For URL mode, this field is omitted. + """ + + +class ElicitationRequiredErrorData(MCPModel): + """Error data for the -32042 URL-elicitation-required error. + + Servers return this when a request cannot be processed until one or more + URL mode elicitations are completed. + + Removed in protocol 2026-07-28; sent/received on sessions negotiating 2025-11-25. + """ + + elicitations: list[ElicitRequestURLParams] + """List of URL mode elicitations that must be completed.""" + + +InputRequest: TypeAlias = CreateMessageRequest | ListRootsRequest | ElicitRequest +"""A single server-initiated input request embedded in `InputRequiredResult` (2026-07-28). + +Discriminated by `method`. On 2026-07-28 these embedded payloads take the place +of standalone server-to-client JSON-RPC requests. +""" + +InputRequests: TypeAlias = dict[str, InputRequest] +"""A map of server-initiated requests that the client must fulfill (2026-07-28). + +Keys are server-assigned identifiers. Carried by `InputRequiredResult.input_requests` +and by the tasks extension. +""" + +InputResponse: TypeAlias = CreateMessageResult | CreateMessageResultWithTools | ListRootsResult | ElicitResult +"""A client response to a single server-initiated input request (2026-07-28). + +`CreateMessageResultWithTools` is this SDK's array-content split of the schema's +single `CreateMessageResult` arm; the wire union has three arms. +""" + +InputResponses: TypeAlias = dict[str, InputResponse] +"""A map of client responses to server-initiated input requests (2026-07-28). + +Keys match those of the `InputRequests` map the server sent. Also used by the +tasks extension's `tasks/update` params. +""" + + +class InputRequiredResult(Result): + """The server needs additional input before the original request can complete (2026-07-28). + + Returned in place of the normal result of an interactive client request + (`tools/call`, `prompts/get`, `resources/read`). The client fulfills + `input_requests` and retries the original request, carrying the responses + and the echoed `request_state`. At least one of those two fields is + present on the wire (spec MUST). + """ + + result_type: Literal["input_required"] = "input_required" + """Discriminating tag for the dual-result response unions.""" + + input_requests: InputRequests | None = None + """Requests the client must complete before retrying. Keys are server-assigned.""" + + request_state: str | None = None + """Opaque state to pass back verbatim when the client retries the original request.""" + + @model_validator(mode="after") + def _require_one_field(self) -> Self: + if not self.input_requests and self.request_state is None: + raise ValueError("InputRequiredResult requires at least one of input_requests or request_state") + return self + + +# Forward refs to InputResponses; rebuild at import time rather than first use. +InputResponseRequestParams.model_rebuild() +ReadResourceRequestParams.model_rebuild() +GetPromptRequestParams.model_rebuild() +CallToolRequestParams.model_rebuild() + +# Top-level message unions: superset across all supported protocol versions. +# Per-version validity is recorded in `mcp_types.methods`, not enforced here. + +ClientRequest = ( + PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | DiscoverRequest + | SubscriptionsListenRequest +) +"""Union of client-to-server request payloads across all supported protocol versions. + +The 2025-11-25 task requests are deliberately excluded (types-only). +""" + +client_request_adapter = TypeAdapter[ClientRequest](ClientRequest) + + +ClientNotification = ( + CancelledNotification | ProgressNotification | InitializedNotification | RootsListChangedNotification +) +"""Notifications sent from the client to the server. + +`TaskStatusNotification` is deliberately excluded (types-only). +""" + +client_notification_adapter = TypeAdapter[ClientNotification](ClientNotification) + + +ClientResult = EmptyResult | CreateMessageResult | CreateMessageResultWithTools | ListRootsResult | ElicitResult +client_result_adapter = TypeAdapter[ClientResult](ClientResult) + + +ServerRequest = PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest +"""Union of standalone JSON-RPC requests a server can send to a client. + +Live through 2025-11-25 only: 2026-07-28 has no server-to-client JSON-RPC +requests (these payloads are embedded in `InputRequiredResult` instead). +""" + +server_request_adapter = TypeAdapter[ServerRequest](ServerRequest) + + +ServerNotification = ( + CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | ElicitCompleteNotification + | SubscriptionsAcknowledgedNotification +) +"""Union of server-to-client notification payloads across all supported protocol versions. + +`TaskStatusNotification` is deliberately excluded (types-only). +""" + +server_notification_adapter = TypeAdapter[ServerNotification](ServerNotification) + + +ServerResult = ( + EmptyResult + | InitializeResult + | DiscoverResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult + | SubscriptionsListenResult + | InputRequiredResult +) +"""Union of every result payload a server can return for a client request. + +`InputRequiredResult` is deliberately last: both of its fields are optional, +so an earlier position would shadow other members during union resolution. +""" +server_result_adapter = TypeAdapter[ServerResult](ServerResult) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/_v2025_11_25/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp_types/_v2025_11_25/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b639bf7af8becd91404c3876413ef0fa4f3c9c1a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types/_v2025_11_25/__init__.py @@ -0,0 +1,3526 @@ +"""Internal wire-shape models for protocol 2025-11-25. Generated; do not edit. + +Regenerate with `scripts/gen_surface_types.py` from `schema/2025-11-25.json` +(sha256 `4e01628360a2149892eab8f298ceee626d24a58862184eb8ec85d95b8f353e31`).""" +# pyright: reportIncompatibleVariableOverride=false, reportGeneralTypeIssues=false + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from mcp_types._wire_base import WireModel +from pydantic import ConfigDict, Field, RootModel + + +class BaseMetadata(WireModel): + """ + Base interface for metadata with name (identifier) and title (display name) properties. + """ + + model_config = ConfigDict( + extra="ignore", + ) + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class BlobResourceContents(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + blob: str + """ + A base64-encoded string representing the binary data of the item. + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + uri: str + """ + The URI of this resource. + """ + + +class BooleanSchema(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + default: bool | None = None + description: str | None = None + title: str | None = None + type: Literal["boolean"] + + +class Params(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + task_id: Annotated[str, Field(alias="taskId")] + """ + The task identifier to cancel. + """ + + +class Elicitation(WireModel): + """ + Present if the client supports elicitation from the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + form: dict[str, Any] | None = None + url: dict[str, Any] | None = None + + +class Roots(WireModel): + """ + Present if the client supports listing roots. + """ + + model_config = ConfigDict( + extra="ignore", + ) + list_changed: Annotated[bool | None, Field(alias="listChanged")] = None + """ + Whether the client supports notifications for changes to the roots list. + """ + + +class Sampling(WireModel): + """ + Present if the client supports sampling from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + context: dict[str, Any] | None = None + """ + Whether the client supports context inclusion via includeContext parameter. + If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + """ + tools: dict[str, Any] | None = None + """ + Whether the client supports tool use via tools and toolChoice parameters. + """ + + +class Elicitation1(WireModel): + """ + Task support for elicitation-related requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + create: dict[str, Any] | None = None + """ + Whether the client supports task-augmented elicitation/create requests. + """ + + +class Sampling1(WireModel): + """ + Task support for sampling-related requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + create_message: Annotated[dict[str, Any] | None, Field(alias="createMessage")] = None + """ + Whether the client supports task-augmented sampling/createMessage requests. + """ + + +class Requests(WireModel): + """ + Specifies which request types can be augmented with tasks. + """ + + model_config = ConfigDict( + extra="ignore", + ) + elicitation: Elicitation1 | None = None + """ + Task support for elicitation-related requests. + """ + sampling: Sampling1 | None = None + """ + Task support for sampling-related requests. + """ + + +class Tasks(WireModel): + """ + Present if the client supports task-augmented requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + cancel: dict[str, Any] | None = None + """ + Whether this client supports tasks/cancel. + """ + list: dict[str, Any] | None = None + """ + Whether this client supports tasks/list. + """ + requests: Requests | None = None + """ + Specifies which request types can be augmented with tasks. + """ + + +class ClientCapabilities(WireModel): + """ + Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + """ + + model_config = ConfigDict( + extra="ignore", + ) + elicitation: Elicitation | None = None + """ + Present if the client supports elicitation from the server. + """ + experimental: dict[str, dict[str, Any]] | None = None + """ + Experimental, non-standard capabilities that the client supports. + """ + roots: Roots | None = None + """ + Present if the client supports listing roots. + """ + sampling: Sampling | None = None + """ + Present if the client supports sampling from an LLM. + """ + tasks: Tasks | None = None + """ + Present if the client supports task-augmented requests. + """ + + +class Argument(WireModel): + """ + The argument's information + """ + + model_config = ConfigDict( + extra="ignore", + ) + name: str + """ + The name of the argument + """ + value: str + """ + The value of the argument to use for completion matching. + """ + + +class Context(WireModel): + """ + Additional, optional context for completions + """ + + model_config = ConfigDict( + extra="ignore", + ) + arguments: dict[str, str] | None = None + """ + Previously-resolved variables in a URI template or prompt. + """ + + +class Completion(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + has_more: Annotated[bool | None, Field(alias="hasMore")] = None + """ + Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + """ + total: int | None = None + """ + The total number of completion options available. This can exceed the number of values actually sent in the response. + """ + values: list[str] + """ + An array of completion values. Must not exceed 100 items. + """ + + +class CompleteResult(WireModel): + """ + The server's response to a completion/complete request + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + completion: Completion + + +class Cursor(RootModel[str]): + root: str + """ + An opaque token used to represent a cursor for pagination. + """ + + +class RequestedSchema(WireModel): + """ + A restricted subset of JSON Schema. + Only top-level properties are allowed, without nesting. + """ + + model_config = ConfigDict( + extra="ignore", + ) + schema_: Annotated[str | None, Field(alias="$schema")] = None + properties: dict[str, Any] + required: list[str] | None = None + type: Literal["object"] + + +class ElicitResult(WireModel): + """ + The client's response to an elicitation request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + action: Literal["accept", "cancel", "decline"] + """ + The user action in response to the elicitation. + - "accept": User submitted the form/confirmed the action + - "decline": User explicitly decline the action + - "cancel": User dismissed without making an explicit choice + """ + content: dict[str, list[str] | str | int | float | bool | None] | None = None + """ + The submitted form data, only present when action is "accept" and mode was "form". + Contains values matching the requested schema. + Omitted for out-of-band mode responses. + """ + + +class Params1(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + elicitation_id: Annotated[str, Field(alias="elicitationId")] + """ + The ID of the elicitation that completed. + """ + + +class ElicitationCompleteNotification(WireModel): + """ + An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/elicitation/complete"] + params: Params1 + + +class Error(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + code: int + """ + The error type that occurred. + """ + data: Any | None = None + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + message: str + """ + A short description of the error. The message SHOULD be limited to a concise single sentence. + """ + + +class Params2(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + task_id: Annotated[str, Field(alias="taskId")] + """ + The task identifier to retrieve results for. + """ + + +class GetTaskPayloadResult(WireModel): + """ + The response to a tasks/result request. + The structure matches the result type of the original request. + For example, a tools/call task would return the CallToolResult structure. + """ + + model_config = ConfigDict( + extra="allow", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + + +class Params3(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + task_id: Annotated[str, Field(alias="taskId")] + """ + The task identifier to query. + """ + + +class Icon(WireModel): + """ + An optionally-sized icon that can be displayed in a user interface. + """ + + model_config = ConfigDict( + extra="ignore", + ) + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + Optional MIME type override if the source MIME type is missing or generic. + For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + """ + sizes: list[str] | None = None + """ + Optional array of strings that specify sizes at which the icon can be used. + Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + + If not provided, the client should assume that the icon can be used at any size. + """ + src: str + """ + A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + `data:` URI with Base64-encoded image data. + + Consumers SHOULD takes steps to ensure URLs serving icons are from the + same domain as the client/server or a trusted domain. + + Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + executable JavaScript. + """ + theme: Literal["dark", "light"] | None = None + """ + Optional specifier for the theme this icon is designed for. `light` indicates + the icon is designed to be used with a light background, and `dark` indicates + the icon is designed to be used with a dark background. + + If not provided, the client should assume the icon can be used with any theme. + """ + + +class Icons(WireModel): + """ + Base interface to add `icons` property. + """ + + model_config = ConfigDict( + extra="ignore", + ) + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + + +class Implementation(WireModel): + """ + Describes the MCP implementation. + """ + + model_config = ConfigDict( + extra="ignore", + ) + description: str | None = None + """ + An optional human-readable description of what this implementation does. + + This can be used by clients or servers to provide context about their purpose + and capabilities. For example, a server might describe the types of resources + or tools it provides, while a client might describe its intended use case. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + version: str + website_url: Annotated[str | None, Field(alias="websiteUrl")] = None + """ + An optional URL of the website for this implementation. + """ + + +class JSONRPCNotification(WireModel): + """ + A notification which does not expect a response. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: str + params: dict[str, Any] | None = None + + +class LegacyTitledEnumSchema(WireModel): + """ + Use TitledSingleSelectEnumSchema instead. + This interface will be removed in a future version. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: str | None = None + description: str | None = None + enum: list[str] + enum_names: Annotated[list[str] | None, Field(alias="enumNames")] = None + """ + (Legacy) Display names for enum values. + Non-standard according to JSON schema 2020-12. + """ + title: str | None = None + type: Literal["string"] + + +class LoggingLevel( + RootModel[ + Literal[ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning", + ] + ] +): + root: Literal["alert", "critical", "debug", "emergency", "error", "info", "notice", "warning"] + """ + The severity of a log message. + + These map to syslog message severities, as specified in RFC-5424: + https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + """ + + +class LoggingMessageNotificationParams(WireModel): + """ + Parameters for a `notifications/message` notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + data: Any + """ + The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + """ + level: LoggingLevel + """ + The severity of this log message. + """ + logger: str | None = None + """ + An optional name of the logger issuing this message. + """ + + +class ModelHint(WireModel): + """ + Hints to use for model selection. + + Keys not declared here are currently left unspecified by the spec and are up + to the client to interpret. + """ + + model_config = ConfigDict( + extra="ignore", + ) + name: str | None = None + """ + A hint for a model name. + + The client SHOULD treat this as a substring of a model name; for example: + - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + - `claude` should match any Claude model + + The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + """ + + +class ModelPreferences(WireModel): + """ + The server's preferences for model selection, requested of the client during sampling. + + Because LLMs can vary along multiple dimensions, choosing the "best" model is + rarely straightforward. Different models excel in different areasโ€”some are + faster but less capable, others are more capable but more expensive, and so + on. This interface allows servers to express their priorities across multiple + dimensions to help clients make an appropriate selection for their use case. + + These preferences are always advisory. The client MAY ignore them. It is also + up to the client to decide how to interpret these preferences and how to + balance them against other considerations. + """ + + model_config = ConfigDict( + extra="ignore", + ) + cost_priority: Annotated[float | None, Field(alias="costPriority", ge=0.0, le=1.0)] = None + """ + How much to prioritize cost when selecting a model. A value of 0 means cost + is not important, while a value of 1 means cost is the most important + factor. + """ + hints: list[ModelHint] | None = None + """ + Optional hints to use for model selection. + + If multiple hints are specified, the client MUST evaluate them in order + (such that the first match is taken). + + The client SHOULD prioritize these hints over the numeric priorities, but + MAY still use the priorities to select from ambiguous matches. + """ + intelligence_priority: Annotated[float | None, Field(alias="intelligencePriority", ge=0.0, le=1.0)] = None + """ + How much to prioritize intelligence and capabilities when selecting a + model. A value of 0 means intelligence is not important, while a value of 1 + means intelligence is the most important factor. + """ + speed_priority: Annotated[float | None, Field(alias="speedPriority", ge=0.0, le=1.0)] = None + """ + How much to prioritize sampling speed (latency) when selecting a model. A + value of 0 means speed is not important, while a value of 1 means speed is + the most important factor. + """ + + +class Notification(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + method: str + params: dict[str, Any] | None = None + + +class NotificationParams(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + + +class NumberSchema(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + default: int | float | None = None + description: str | None = None + maximum: int | float | None = None + minimum: int | float | None = None + title: str | None = None + type: Literal["integer", "number"] + + +class PaginatedResult(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + + +class ProgressToken(RootModel[str | int]): + root: str | int + """ + A progress token, used to associate progress notifications with the original request. + """ + + +class PromptArgument(WireModel): + """ + Describes an argument that a prompt can accept. + """ + + model_config = ConfigDict( + extra="ignore", + ) + description: str | None = None + """ + A human-readable description of the argument. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + required: bool | None = None + """ + Whether this argument must be provided. + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class PromptListChangedNotification(WireModel): + """ + An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/prompts/list_changed"] + params: NotificationParams | None = None + + +class PromptReference(WireModel): + """ + Identifies a prompt. + """ + + model_config = ConfigDict( + extra="ignore", + ) + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + type: Literal["ref/prompt"] + + +class Meta(WireModel): + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + + model_config = ConfigDict( + extra="allow", + ) + progress_token: Annotated[ProgressToken | None, Field(alias="progressToken")] = None + """ + If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + """ + + +class ReadResourceRequestParams(WireModel): + """ + Parameters for a `resources/read` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + """ + + +class RelatedTaskMetadata(WireModel): + """ + Metadata for associating messages with a task. + Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + task_id: Annotated[str, Field(alias="taskId")] + """ + The task identifier this message is associated with. + """ + + +class Request(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + method: str + params: dict[str, Any] | None = None + + +class RequestId(RootModel[str | int]): + root: str | int + """ + A uniquely identifying ID for a request in JSON-RPC. + """ + + +class RequestParams(WireModel): + """ + Common params for any request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + + +class ResourceContents(WireModel): + """ + The contents of a specific resource or sub-resource. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + uri: str + """ + The URI of this resource. + """ + + +class ResourceListChangedNotification(WireModel): + """ + An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/resources/list_changed"] + params: NotificationParams | None = None + + +class ResourceRequestParams(WireModel): + """ + Common parameters when working with resources. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + """ + + +class ResourceTemplateReference(WireModel): + """ + A reference to a resource or resource template definition. + """ + + model_config = ConfigDict( + extra="ignore", + ) + type: Literal["ref/resource"] + uri: str + """ + The URI or URI template of the resource. + """ + + +class ResourceUpdatedNotificationParams(WireModel): + """ + Parameters for a `notifications/resources/updated` notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + uri: str + """ + The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + """ + + +class Result(WireModel): + model_config = ConfigDict( + extra="allow", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + + +class Role(RootModel[Literal["assistant", "user"]]): + root: Literal["assistant", "user"] + """ + The sender or recipient of messages and data in a conversation. + """ + + +class Root(WireModel): + """ + Represents a root directory or file that the server can operate on. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + name: str | None = None + """ + An optional name for the root. This can be used to provide a human-readable + identifier for the root, which may be useful for display purposes or for + referencing the root in other parts of the application. + """ + uri: str + """ + The URI identifying the root. This *must* start with file:// for now. + This restriction may be relaxed in future versions of the protocol to allow + other URI schemes. + """ + + +class RootsListChangedNotification(WireModel): + """ + A notification from the client to the server, informing it that the list of roots has changed. + This notification should be sent whenever the client adds, removes, or modifies any root. + The server should then request an updated list of roots using the ListRootsRequest. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/roots/list_changed"] + params: NotificationParams | None = None + + +class Prompts(WireModel): + """ + Present if the server offers any prompt templates. + """ + + model_config = ConfigDict( + extra="ignore", + ) + list_changed: Annotated[bool | None, Field(alias="listChanged")] = None + """ + Whether this server supports notifications for changes to the prompt list. + """ + + +class Resources(WireModel): + """ + Present if the server offers any resources to read. + """ + + model_config = ConfigDict( + extra="ignore", + ) + list_changed: Annotated[bool | None, Field(alias="listChanged")] = None + """ + Whether this server supports notifications for changes to the resource list. + """ + subscribe: bool | None = None + """ + Whether this server supports subscribing to resource updates. + """ + + +class Tools(WireModel): + """ + Task support for tool-related requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + call: dict[str, Any] | None = None + """ + Whether the server supports task-augmented tools/call requests. + """ + + +class Requests1(WireModel): + """ + Specifies which request types can be augmented with tasks. + """ + + model_config = ConfigDict( + extra="ignore", + ) + tools: Tools | None = None + """ + Task support for tool-related requests. + """ + + +class Tasks1(WireModel): + """ + Present if the server supports task-augmented requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + cancel: dict[str, Any] | None = None + """ + Whether this server supports tasks/cancel. + """ + list: dict[str, Any] | None = None + """ + Whether this server supports tasks/list. + """ + requests: Requests1 | None = None + """ + Specifies which request types can be augmented with tasks. + """ + + +class Tools1(WireModel): + """ + Present if the server offers any tools to call. + """ + + model_config = ConfigDict( + extra="ignore", + ) + list_changed: Annotated[bool | None, Field(alias="listChanged")] = None + """ + Whether this server supports notifications for changes to the tool list. + """ + + +class ServerCapabilities(WireModel): + """ + Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + """ + + model_config = ConfigDict( + extra="ignore", + ) + completions: dict[str, Any] | None = None + """ + Present if the server supports argument autocompletion suggestions. + """ + experimental: dict[str, dict[str, Any]] | None = None + """ + Experimental, non-standard capabilities that the server supports. + """ + logging: dict[str, Any] | None = None + """ + Present if the server supports sending log messages to the client. + """ + prompts: Prompts | None = None + """ + Present if the server offers any prompt templates. + """ + resources: Resources | None = None + """ + Present if the server offers any resources to read. + """ + tasks: Tasks1 | None = None + """ + Present if the server supports task-augmented requests. + """ + tools: Tools1 | None = None + """ + Present if the server offers any tools to call. + """ + + +class SetLevelRequestParams(WireModel): + """ + Parameters for a `logging/setLevel` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + level: LoggingLevel + """ + The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + """ + + +class StringSchema(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + default: str | None = None + description: str | None = None + format: Literal["date", "date-time", "email", "uri"] | None = None + max_length: Annotated[int | None, Field(alias="maxLength")] = None + min_length: Annotated[int | None, Field(alias="minLength")] = None + title: str | None = None + type: Literal["string"] + + +class SubscribeRequestParams(WireModel): + """ + Parameters for a `resources/subscribe` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + """ + + +class TaskMetadata(WireModel): + """ + Metadata for augmenting a request with task execution. + Include this in the `task` field of the request parameters. + """ + + model_config = ConfigDict( + extra="ignore", + ) + ttl: int | None = None + """ + Requested duration in milliseconds to retain task from creation. + """ + + +class TaskStatus(RootModel[Literal["cancelled", "completed", "failed", "input_required", "working"]]): + root: Literal["cancelled", "completed", "failed", "input_required", "working"] + """ + The status of a task. + """ + + +class TextResourceContents(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + text: str + """ + The text of the item. This must only be set if the item can actually be represented as text (not binary data). + """ + uri: str + """ + The URI of this resource. + """ + + +class AnyOfItem(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + const: str + """ + The constant enum value. + """ + title: str + """ + Display title for this option. + """ + + +class Items(WireModel): + """ + Schema for array items with enum options and display labels. + """ + + model_config = ConfigDict( + extra="ignore", + ) + any_of: Annotated[list[AnyOfItem], Field(alias="anyOf")] + """ + Array of enum options with values and display labels. + """ + + +class TitledMultiSelectEnumSchema(WireModel): + """ + Schema for multiple-selection enumeration with display titles for each option. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: list[str] | None = None + """ + Optional default value. + """ + description: str | None = None + """ + Optional description for the enum field. + """ + items: Items + """ + Schema for array items with enum options and display labels. + """ + max_items: Annotated[int | None, Field(alias="maxItems")] = None + """ + Maximum number of items to select. + """ + min_items: Annotated[int | None, Field(alias="minItems")] = None + """ + Minimum number of items to select. + """ + title: str | None = None + """ + Optional title for the enum field. + """ + type: Literal["array"] + + +class OneOfItem(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + const: str + """ + The enum value. + """ + title: str + """ + Display label for this option. + """ + + +class TitledSingleSelectEnumSchema(WireModel): + """ + Schema for single-selection enumeration with display titles for each option. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: str | None = None + """ + Optional default value. + """ + description: str | None = None + """ + Optional description for the enum field. + """ + one_of: Annotated[list[OneOfItem], Field(alias="oneOf")] + """ + Array of enum options with values and display labels. + """ + title: str | None = None + """ + Optional title for the enum field. + """ + type: Literal["string"] + + +class InputSchema(WireModel): + """ + A JSON Schema object defining the expected parameters for the tool. + """ + + model_config = ConfigDict( + extra="allow", + ) + schema_: Annotated[str | None, Field(alias="$schema")] = None + properties: dict[str, dict[str, Any]] | None = None + required: list[str] | None = None + type: Literal["object"] + + +class OutputSchema(WireModel): + """ + An optional JSON Schema object defining the structure of the tool's output returned in + the structuredContent field of a CallToolResult. + + Defaults to JSON Schema 2020-12 when no explicit $schema is provided. + Currently restricted to type: "object" at the root level. + """ + + model_config = ConfigDict( + extra="allow", + ) + schema_: Annotated[str | None, Field(alias="$schema")] = None + properties: dict[str, dict[str, Any]] | None = None + required: list[str] | None = None + type: Literal["object"] + + +class ToolAnnotations(WireModel): + """ + Additional properties describing a Tool to clients. + + NOTE: all properties in ToolAnnotations are **hints**. + They are not guaranteed to provide a faithful description of + tool behavior (including descriptive properties like `title`). + + Clients should never make tool use decisions based on ToolAnnotations + received from untrusted servers. + """ + + model_config = ConfigDict( + extra="ignore", + ) + destructive_hint: Annotated[bool | None, Field(alias="destructiveHint")] = None + """ + If true, the tool may perform destructive updates to its environment. + If false, the tool performs only additive updates. + + (This property is meaningful only when `readOnlyHint == false`) + + Default: true + """ + idempotent_hint: Annotated[bool | None, Field(alias="idempotentHint")] = None + """ + If true, calling the tool repeatedly with the same arguments + will have no additional effect on its environment. + + (This property is meaningful only when `readOnlyHint == false`) + + Default: false + """ + open_world_hint: Annotated[bool | None, Field(alias="openWorldHint")] = None + """ + If true, this tool may interact with an "open world" of external + entities. If false, the tool's domain of interaction is closed. + For example, the world of a web search tool is open, whereas that + of a memory tool is not. + + Default: true + """ + read_only_hint: Annotated[bool | None, Field(alias="readOnlyHint")] = None + """ + If true, the tool does not modify its environment. + + Default: false + """ + title: str | None = None + """ + A human-readable title for the tool. + """ + + +class ToolChoice(WireModel): + """ + Controls tool selection behavior for sampling requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + mode: Literal["auto", "none", "required"] | None = None + """ + Controls the tool use ability of the model: + - "auto": Model decides whether to use tools (default) + - "required": Model MUST use at least one tool before completing + - "none": Model MUST NOT use any tools + """ + + +class ToolExecution(WireModel): + """ + Execution-related properties for a tool. + """ + + model_config = ConfigDict( + extra="ignore", + ) + task_support: Annotated[Literal["forbidden", "optional", "required"] | None, Field(alias="taskSupport")] = None + """ + Indicates whether this tool supports task-augmented execution. + This allows clients to handle long-running operations through polling + the task system. + + - "forbidden": Tool does not support task-augmented execution (default when absent) + - "optional": Tool may support task-augmented execution + - "required": Tool requires task-augmented execution + + Default: "forbidden" + """ + + +class ToolListChangedNotification(WireModel): + """ + An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/tools/list_changed"] + params: NotificationParams | None = None + + +class ToolUseContent(WireModel): + """ + A request from the assistant to call a tool. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + Optional metadata about the tool use. Clients SHOULD preserve this field when + including tool uses in subsequent sampling requests to enable caching optimizations. + + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + id: str + """ + A unique identifier for this tool use. + + This ID is used to match tool results to their corresponding tool uses. + """ + input: dict[str, Any] + """ + The arguments to pass to the tool, conforming to the tool's input schema. + """ + name: str + """ + The name of the tool to call. + """ + type: Literal["tool_use"] + + +class UnsubscribeRequestParams(WireModel): + """ + Parameters for a `resources/unsubscribe` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + """ + + +class Items1(WireModel): + """ + Schema for the array items. + """ + + model_config = ConfigDict( + extra="ignore", + ) + enum: list[str] + """ + Array of enum values to choose from. + """ + type: Literal["string"] + + +class UntitledMultiSelectEnumSchema(WireModel): + """ + Schema for multiple-selection enumeration without display titles for options. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: list[str] | None = None + """ + Optional default value. + """ + description: str | None = None + """ + Optional description for the enum field. + """ + items: Items1 + """ + Schema for the array items. + """ + max_items: Annotated[int | None, Field(alias="maxItems")] = None + """ + Maximum number of items to select. + """ + min_items: Annotated[int | None, Field(alias="minItems")] = None + """ + Minimum number of items to select. + """ + title: str | None = None + """ + Optional title for the enum field. + """ + type: Literal["array"] + + +class UntitledSingleSelectEnumSchema(WireModel): + """ + Schema for single-selection enumeration without display titles for options. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: str | None = None + """ + Optional default value. + """ + description: str | None = None + """ + Optional description for the enum field. + """ + enum: list[str] + """ + Array of enum values to choose from. + """ + title: str | None = None + """ + Optional title for the enum field. + """ + type: Literal["string"] + + +class Annotations(WireModel): + """ + Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + """ + + model_config = ConfigDict( + extra="ignore", + ) + audience: list[Role] | None = None + """ + Describes who the intended audience of this object or data is. + + It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + """ + last_modified: Annotated[str | None, Field(alias="lastModified")] = None + """ + The moment the resource was last modified, as an ISO 8601 formatted string. + + Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + + Examples: last activity timestamp in an open file, timestamp when the resource + was attached, etc. + """ + priority: Annotated[float | None, Field(ge=0.0, le=1.0)] = None + """ + Describes how important this data is for operating the server. + + A value of 1 means "most important," and indicates that the data is + effectively required, while 0 means "least important," and indicates that + the data is entirely optional. + """ + + +class AudioContent(WireModel): + """ + Audio provided to or from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + data: str + """ + The base64-encoded audio data. + """ + mime_type: Annotated[str, Field(alias="mimeType")] + """ + The MIME type of the audio. Different providers may support different audio types. + """ + type: Literal["audio"] + + +class CallToolRequestParams(WireModel): + """ + Parameters for a `tools/call` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + arguments: dict[str, Any] | None = None + """ + Arguments to use for the tool call. + """ + name: str + """ + The name of the tool. + """ + task: TaskMetadata | None = None + """ + If specified, the caller is requesting task-augmented execution for this request. + The request will return a CreateTaskResult immediately, and the actual result can be + retrieved later via tasks/result. + + Task augmentation is subject to capability negotiation - receivers MUST declare support + for task augmentation of specific request types in their capabilities. + """ + + +class CancelTaskRequest(WireModel): + """ + A request to cancel a task. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tasks/cancel"] + params: Params + + +class CancelledNotificationParams(WireModel): + """ + Parameters for a `notifications/cancelled` notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + reason: str | None = None + """ + An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + """ + request_id: Annotated[RequestId | None, Field(alias="requestId")] = None + """ + The ID of the request to cancel. + + This MUST correspond to the ID of a request previously issued in the same direction. + This MUST be provided for cancelling non-task requests. + This MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead). + """ + + +class CompleteRequestParams(WireModel): + """ + Parameters for a `completion/complete` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + argument: Argument + """ + The argument's information + """ + context: Context | None = None + """ + Additional, optional context for completions + """ + ref: PromptReference | ResourceTemplateReference + + +class ElicitRequestFormParams(WireModel): + """ + The parameters for a request to elicit non-sensitive information from the user via a form in the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + message: str + """ + The message to present to the user describing what information is being requested. + """ + mode: Literal["form"] = "form" + """ + The elicitation mode. + """ + requested_schema: Annotated[RequestedSchema, Field(alias="requestedSchema")] + """ + A restricted subset of JSON Schema. + Only top-level properties are allowed, without nesting. + """ + task: TaskMetadata | None = None + """ + If specified, the caller is requesting task-augmented execution for this request. + The request will return a CreateTaskResult immediately, and the actual result can be + retrieved later via tasks/result. + + Task augmentation is subject to capability negotiation - receivers MUST declare support + for task augmentation of specific request types in their capabilities. + """ + + +class ElicitRequestURLParams(WireModel): + """ + The parameters for a request to elicit information from the user via a URL in the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + elicitation_id: Annotated[str, Field(alias="elicitationId")] + """ + The ID of the elicitation, which must be unique within the context of the server. + The client MUST treat this ID as an opaque value. + """ + message: str + """ + The message to present to the user explaining why the interaction is needed. + """ + mode: Literal["url"] + """ + The elicitation mode. + """ + task: TaskMetadata | None = None + """ + If specified, the caller is requesting task-augmented execution for this request. + The request will return a CreateTaskResult immediately, and the actual result can be + retrieved later via tasks/result. + + Task augmentation is subject to capability negotiation - receivers MUST declare support + for task augmentation of specific request types in their capabilities. + """ + url: str + """ + The URL that the user should navigate to. + """ + + +class EmbeddedResource(WireModel): + """ + The contents of a resource, embedded into a prompt or tool call result. + + It is up to the client how best to render embedded resources for the benefit + of the LLM and/or the user. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + resource: TextResourceContents | BlobResourceContents + type: Literal["resource"] + + +class EmptyResult(RootModel[Result]): + root: Result + + +class EnumSchema( + RootModel[ + UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema + ] +): + root: ( + UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema + ) + + +class GetPromptRequestParams(WireModel): + """ + Parameters for a `prompts/get` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + arguments: dict[str, str] | None = None + """ + Arguments to use for templating the prompt. + """ + name: str + """ + The name of the prompt or prompt template. + """ + + +class GetTaskPayloadRequest(WireModel): + """ + A request to retrieve the result of a completed task. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tasks/result"] + params: Params2 + + +class GetTaskRequest(WireModel): + """ + A request to retrieve the state of a task. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tasks/get"] + params: Params3 + + +class ImageContent(WireModel): + """ + An image provided to or from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + data: str + """ + The base64-encoded image data. + """ + mime_type: Annotated[str, Field(alias="mimeType")] + """ + The MIME type of the image. Different providers may support different image types. + """ + type: Literal["image"] + + +class InitializeRequestParams(WireModel): + """ + Parameters for an `initialize` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + capabilities: ClientCapabilities + client_info: Annotated[Implementation, Field(alias="clientInfo")] + protocol_version: Annotated[str, Field(alias="protocolVersion")] + """ + The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + """ + + +class InitializeResult(WireModel): + """ + After receiving an initialize request from the client, the server sends this response. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + capabilities: ServerCapabilities + instructions: str | None = None + """ + Instructions describing how to use the server and its features. + + This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + """ + protocol_version: Annotated[str, Field(alias="protocolVersion")] + """ + The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + """ + server_info: Annotated[Implementation, Field(alias="serverInfo")] + + +class InitializedNotification(WireModel): + """ + This notification is sent from the client to the server after initialization has finished. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/initialized"] + params: NotificationParams | None = None + + +class JSONRPCErrorResponse(WireModel): + """ + A response to a request that indicates an error occurred. + """ + + model_config = ConfigDict( + extra="ignore", + ) + error: Error + id: RequestId | None = None + jsonrpc: Literal["2.0"] + + +class JSONRPCRequest(WireModel): + """ + A request that expects a response. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: str + params: dict[str, Any] | None = None + + +class JSONRPCResultResponse(WireModel): + """ + A successful (non-error) response to a request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: Result + + +class ListRootsRequest(WireModel): + """ + Sent from the server to request a list of root URIs from the client. Roots allow + servers to ask for specific directories or files to operate on. A common example + for roots is providing a set of repositories or directories a server should operate + on. + + This request is typically used when the server needs to understand the file system + structure or access specific locations that the client has permission to read from. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["roots/list"] + params: RequestParams | None = None + + +class ListRootsResult(WireModel): + """ + The client's response to a roots/list request from the server. + This result contains an array of Root objects, each representing a root directory + or file that the server can operate on. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + roots: list[Root] + + +class LoggingMessageNotification(WireModel): + """ + JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/message"] + params: LoggingMessageNotificationParams + + +class MultiSelectEnumSchema(RootModel[UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema]): + root: UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema + + +class PaginatedRequestParams(WireModel): + """ + Common parameters for paginated requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + cursor: str | None = None + """ + An opaque token representing the current pagination position. + If provided, the server should return results starting after this cursor. + """ + + +class PingRequest(WireModel): + """ + A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["ping"] + params: RequestParams | None = None + + +class PrimitiveSchemaDefinition( + RootModel[ + StringSchema + | NumberSchema + | BooleanSchema + | UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema + ] +): + root: ( + StringSchema + | NumberSchema + | BooleanSchema + | UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema + ) + """ + Restricted schema definitions that only allow primitive types + without nested objects or arrays. + """ + + +class ProgressNotificationParams(WireModel): + """ + Parameters for a `notifications/progress` notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + message: str | None = None + """ + An optional message describing the current progress. + """ + progress: float + """ + The progress thus far. This should increase every time progress is made, even if the total is unknown. + """ + progress_token: Annotated[ProgressToken, Field(alias="progressToken")] + """ + The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + """ + total: float | None = None + """ + Total number of items to process (or total progress required), if known. + """ + + +class Prompt(WireModel): + """ + A prompt or prompt template that the server offers. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + arguments: list[PromptArgument] | None = None + """ + A list of arguments to use for templating the prompt. + """ + description: str | None = None + """ + An optional description of what this prompt provides + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class ReadResourceRequest(WireModel): + """ + Sent from the client to the server, to read a specific resource URI. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/read"] + params: ReadResourceRequestParams + + +class ReadResourceResult(WireModel): + """ + The server's response to a resources/read request from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + contents: list[TextResourceContents | BlobResourceContents] + + +class Resource(WireModel): + """ + A known resource that the server is capable of reading. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + description: str | None = None + """ + A description of what this resource represents. + + This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + size: int | None = None + """ + The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + + This can be used by Hosts to display file sizes and estimate context window usage. + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + uri: str + """ + The URI of this resource. + """ + + +class ResourceLink(WireModel): + """ + A resource that the server is capable of reading, included in a prompt or tool call result. + + Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + description: str | None = None + """ + A description of what this resource represents. + + This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + size: int | None = None + """ + The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + + This can be used by Hosts to display file sizes and estimate context window usage. + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + type: Literal["resource_link"] + uri: str + """ + The URI of this resource. + """ + + +class ResourceTemplate(WireModel): + """ + A template description for resources available on the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + description: str | None = None + """ + A description of what this template is for. + + This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + uri_template: Annotated[str, Field(alias="uriTemplate")] + """ + A URI template (according to RFC 6570) that can be used to construct resource URIs. + """ + + +class ResourceUpdatedNotification(WireModel): + """ + A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/resources/updated"] + params: ResourceUpdatedNotificationParams + + +class SetLevelRequest(WireModel): + """ + A request from the client to the server, to enable or adjust logging. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["logging/setLevel"] + params: SetLevelRequestParams + + +class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): + root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema + + +class SubscribeRequest(WireModel): + """ + Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/subscribe"] + params: SubscribeRequestParams + + +class Task(WireModel): + """ + Data associated with a task. + """ + + model_config = ConfigDict( + extra="ignore", + ) + created_at: Annotated[str, Field(alias="createdAt")] + """ + ISO 8601 timestamp when the task was created. + """ + last_updated_at: Annotated[str, Field(alias="lastUpdatedAt")] + """ + ISO 8601 timestamp when the task was last updated. + """ + poll_interval: Annotated[int | None, Field(alias="pollInterval")] = None + """ + Suggested polling interval in milliseconds. + """ + status: TaskStatus + """ + Current task state. + """ + status_message: Annotated[str | None, Field(alias="statusMessage")] = None + """ + Optional human-readable message describing the current task state. + This can provide context for any status, including: + - Reasons for "cancelled" status + - Summaries for "completed" status + - Diagnostic information for "failed" status (e.g., error details, what went wrong) + """ + task_id: Annotated[str, Field(alias="taskId")] + """ + The task identifier. + """ + ttl: int | None + """ + Actual retention duration from creation in milliseconds, null for unlimited. + """ + + +class TaskAugmentedRequestParams(WireModel): + """ + Common params for any task-augmented request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + task: TaskMetadata | None = None + """ + If specified, the caller is requesting task-augmented execution for this request. + The request will return a CreateTaskResult immediately, and the actual result can be + retrieved later via tasks/result. + + Task augmentation is subject to capability negotiation - receivers MUST declare support + for task augmentation of specific request types in their capabilities. + """ + + +class TaskStatusNotificationParams(NotificationParams, Task): + """ + Parameters for a `notifications/tasks/status` notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + + +class TextContent(WireModel): + """ + Text provided to or from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + text: str + """ + The text content of the message. + """ + type: Literal["text"] + + +class Tool(WireModel): + """ + Definition for a tool the client can call. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + annotations: ToolAnnotations | None = None + """ + Optional additional tool information. + + Display name precedence order is: title, annotations.title, then name. + """ + description: str | None = None + """ + A human-readable description of the tool. + + This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. + """ + execution: ToolExecution | None = None + """ + Execution-related properties for this tool. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + input_schema: Annotated[InputSchema, Field(alias="inputSchema")] + """ + A JSON Schema object defining the expected parameters for the tool. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + output_schema: Annotated[OutputSchema | None, Field(alias="outputSchema")] = None + """ + An optional JSON Schema object defining the structure of the tool's output returned in + the structuredContent field of a CallToolResult. + + Defaults to JSON Schema 2020-12 when no explicit $schema is provided. + Currently restricted to type: "object" at the root level. + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class Data(WireModel): + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + + model_config = ConfigDict( + extra="allow", + ) + elicitations: list[ElicitRequestURLParams] + + +class Error1(Error): + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32042] + """ + The error type that occurred. + """ + data: Data + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + + +class URLElicitationRequiredError(WireModel): + """ + An error response that indicates that the server requires the client to provide additional information via an elicitation request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + error: Error1 + id: RequestId | None = None + jsonrpc: Literal["2.0"] + + +class UnsubscribeRequest(WireModel): + """ + Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/unsubscribe"] + params: UnsubscribeRequestParams + + +class CallToolRequest(WireModel): + """ + Used by the client to invoke a tool provided by the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tools/call"] + params: CallToolRequestParams + + +class CancelTaskResult(Result, Task): + """ + The response to a tasks/cancel request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + + +class CancelledNotification(WireModel): + """ + This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + + The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + + This notification indicates that the result will be unused, so any associated processing SHOULD cease. + + A client MUST NOT attempt to cancel its `initialize` request. + + For task cancellation, use the `tasks/cancel` request instead of this notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/cancelled"] + params: CancelledNotificationParams + + +class CompleteRequest(WireModel): + """ + A request from the client to the server, to ask for completion options. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["completion/complete"] + params: CompleteRequestParams + + +class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): + root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource + + +class CreateTaskResult(WireModel): + """ + A response to a task-augmented request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + task: Task + + +class ElicitRequestParams(RootModel[ElicitRequestURLParams | ElicitRequestFormParams]): + root: ElicitRequestURLParams | ElicitRequestFormParams + """ + The parameters for a request to elicit additional information from the user via the client. + """ + + +class GetPromptRequest(WireModel): + """ + Used by the client to get a prompt provided by the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["prompts/get"] + params: GetPromptRequestParams + + +class GetTaskResult(Result, Task): + """ + The response to a tasks/get request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + + +class InitializeRequest(WireModel): + """ + This request is sent from the client to the server when it first connects, asking it to begin initialization. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["initialize"] + params: InitializeRequestParams + + +class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]): + root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse + """ + Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + """ + + +class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): + root: JSONRPCResultResponse | JSONRPCErrorResponse + """ + A response to a request, containing either the result or error. + """ + + +class ListPromptsRequest(WireModel): + """ + Sent from the client to request a list of prompts and prompt templates the server has. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["prompts/list"] + params: PaginatedRequestParams | None = None + + +class ListPromptsResult(WireModel): + """ + The server's response to a prompts/list request from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + prompts: list[Prompt] + + +class ListResourceTemplatesRequest(WireModel): + """ + Sent from the client to request a list of resource templates the server has. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/templates/list"] + params: PaginatedRequestParams | None = None + + +class ListResourceTemplatesResult(WireModel): + """ + The server's response to a resources/templates/list request from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + resource_templates: Annotated[list[ResourceTemplate], Field(alias="resourceTemplates")] + + +class ListResourcesRequest(WireModel): + """ + Sent from the client to request a list of resources the server has. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/list"] + params: PaginatedRequestParams | None = None + + +class ListResourcesResult(WireModel): + """ + The server's response to a resources/list request from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + resources: list[Resource] + + +class ListTasksRequest(WireModel): + """ + A request to retrieve a list of tasks. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tasks/list"] + params: PaginatedRequestParams | None = None + + +class ListTasksResult(WireModel): + """ + The response to a tasks/list request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + tasks: list[Task] + + +class ListToolsRequest(WireModel): + """ + Sent from the client to request a list of tools the server has. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tools/list"] + params: PaginatedRequestParams | None = None + + +class ListToolsResult(WireModel): + """ + The server's response to a tools/list request from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + tools: list[Tool] + + +class PaginatedRequest(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: str + params: PaginatedRequestParams | None = None + + +class ProgressNotification(WireModel): + """ + An out-of-band notification used to inform the receiver of a progress update for a long-running request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/progress"] + params: ProgressNotificationParams + + +class PromptMessage(WireModel): + """ + Describes a message returned as part of a prompt. + + This is similar to `SamplingMessage`, but also supports the embedding of + resources from the MCP server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + content: ContentBlock + role: Role + + +class TaskStatusNotification(WireModel): + """ + An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/tasks/status"] + params: TaskStatusNotificationParams + + +class ToolResultContent(WireModel): + """ + The result of a tool use, provided by the user back to the assistant. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + Optional metadata about the tool result. Clients SHOULD preserve this field when + including tool results in subsequent sampling requests to enable caching optimizations. + + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + content: list[ContentBlock] + """ + The unstructured result content of the tool use. + + This has the same format as CallToolResult.content and can include text, images, + audio, resource links, and embedded resources. + """ + is_error: Annotated[bool | None, Field(alias="isError")] = None + """ + Whether the tool use resulted in an error. + + If true, the content typically describes the error that occurred. + Default: false + """ + structured_content: Annotated[dict[str, Any] | None, Field(alias="structuredContent")] = None + """ + An optional structured result object. + + If the tool defined an outputSchema, this SHOULD conform to that schema. + """ + tool_use_id: Annotated[str, Field(alias="toolUseId")] + """ + The ID of the tool use this result corresponds to. + + This MUST match the ID from a previous ToolUseContent. + """ + type: Literal["tool_result"] + + +class CallToolResult(WireModel): + """ + The server's response to a tool call. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + content: list[ContentBlock] + """ + A list of content objects that represent the unstructured result of the tool call. + """ + is_error: Annotated[bool | None, Field(alias="isError")] = None + """ + Whether the tool call ended in an error. + + If not set, this is assumed to be false (the call was successful). + + Any errors that originate from the tool SHOULD be reported inside the result + object, with `isError` set to true, _not_ as an MCP protocol-level error + response. Otherwise, the LLM would not be able to see that an error occurred + and self-correct. + + However, any errors in _finding_ the tool, an error indicating that the + server does not support tool calls, or any other exceptional conditions, + should be reported as an MCP error response. + """ + structured_content: Annotated[dict[str, Any] | None, Field(alias="structuredContent")] = None + """ + An optional JSON object that represents the structured result of the tool call. + """ + + +class ClientNotification( + RootModel[ + CancelledNotification + | InitializedNotification + | ProgressNotification + | TaskStatusNotification + | RootsListChangedNotification + ] +): + root: ( + CancelledNotification + | InitializedNotification + | ProgressNotification + | TaskStatusNotification + | RootsListChangedNotification + ) + + +class ClientRequest( + RootModel[ + InitializeRequest + | PingRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | ListPromptsRequest + | GetPromptRequest + | ListToolsRequest + | CallToolRequest + | GetTaskRequest + | GetTaskPayloadRequest + | CancelTaskRequest + | ListTasksRequest + | SetLevelRequest + | CompleteRequest + ] +): + root: ( + InitializeRequest + | PingRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | ListPromptsRequest + | GetPromptRequest + | ListToolsRequest + | CallToolRequest + | GetTaskRequest + | GetTaskPayloadRequest + | CancelTaskRequest + | ListTasksRequest + | SetLevelRequest + | CompleteRequest + ) + + +class ElicitRequest(WireModel): + """ + A request from the server to elicit additional information from the user via the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["elicitation/create"] + params: ElicitRequestParams + + +class GetPromptResult(WireModel): + """ + The server's response to a prompts/get request from the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + description: str | None = None + """ + An optional description for the prompt. + """ + messages: list[PromptMessage] + + +class SamplingMessageContentBlock( + RootModel[TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent] +): + root: TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent + + +class ServerNotification( + RootModel[ + CancelledNotification + | ProgressNotification + | ResourceListChangedNotification + | ResourceUpdatedNotification + | PromptListChangedNotification + | ToolListChangedNotification + | TaskStatusNotification + | LoggingMessageNotification + | ElicitationCompleteNotification + ] +): + root: ( + CancelledNotification + | ProgressNotification + | ResourceListChangedNotification + | ResourceUpdatedNotification + | PromptListChangedNotification + | ToolListChangedNotification + | TaskStatusNotification + | LoggingMessageNotification + | ElicitationCompleteNotification + ) + + +class ServerResult( + RootModel[ + Result + | InitializeResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | ListPromptsResult + | GetPromptResult + | ListToolsResult + | CallToolResult + | GetTaskResult + | GetTaskPayloadResult + | CancelTaskResult + | ListTasksResult + | CompleteResult + ] +): + root: ( + Result + | InitializeResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | ListPromptsResult + | GetPromptResult + | ListToolsResult + | CallToolResult + | GetTaskResult + | GetTaskPayloadResult + | CancelTaskResult + | ListTasksResult + | CompleteResult + ) + + +class CreateMessageResult(WireModel): + """ + The client's response to a sampling/createMessage request from the server. + The client should inform the user before returning the sampled message, to allow them + to inspect the response (human in the loop) and decide whether to allow the server to see it. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + content: ( + TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent + | list[SamplingMessageContentBlock] + ) + model: str + """ + The name of the model that generated the message. + """ + role: Role + stop_reason: Annotated[str | None, Field(alias="stopReason")] = None + """ + The reason why sampling stopped, if known. + + Standard values: + - "endTurn": Natural end of the assistant's turn + - "stopSequence": A stop sequence was encountered + - "maxTokens": Maximum token limit was reached + - "toolUse": The model wants to use one or more tools + + This field is an open string to allow for provider-specific stop reasons. + """ + + +class SamplingMessage(WireModel): + """ + Describes a message issued to or received from an LLM API. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[dict[str, Any] | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + content: ( + TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent + | list[SamplingMessageContentBlock] + ) + role: Role + + +class ClientResult( + RootModel[ + Result + | GetTaskResult + | GetTaskPayloadResult + | CancelTaskResult + | ListTasksResult + | CreateMessageResult + | ListRootsResult + | ElicitResult + ] +): + root: ( + Result + | GetTaskResult + | GetTaskPayloadResult + | CancelTaskResult + | ListTasksResult + | CreateMessageResult + | ListRootsResult + | ElicitResult + ) + + +class CreateMessageRequestParams(WireModel): + """ + Parameters for a `sampling/createMessage` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[Meta | None, Field(alias="_meta")] = None + """ + See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + """ + include_context: Annotated[ + Literal["allServers", "none", "thisServer"] | None, + Field(alias="includeContext"), + ] = None + """ + A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + The client MAY ignore this request. + + Default is "none". Values "thisServer" and "allServers" are soft-deprecated. Servers SHOULD only use these values if the client + declares ClientCapabilities.sampling.context. These values may be removed in future spec releases. + """ + max_tokens: Annotated[int, Field(alias="maxTokens")] + """ + The requested maximum number of tokens to sample (to prevent runaway completions). + + The client MAY choose to sample fewer tokens than the requested maximum. + """ + messages: list[SamplingMessage] + metadata: dict[str, Any] | None = None + """ + Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + """ + model_preferences: Annotated[ModelPreferences | None, Field(alias="modelPreferences")] = None + """ + The server's preferences for which model to select. The client MAY ignore these preferences. + """ + stop_sequences: Annotated[list[str] | None, Field(alias="stopSequences")] = None + system_prompt: Annotated[str | None, Field(alias="systemPrompt")] = None + """ + An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + """ + task: TaskMetadata | None = None + """ + If specified, the caller is requesting task-augmented execution for this request. + The request will return a CreateTaskResult immediately, and the actual result can be + retrieved later via tasks/result. + + Task augmentation is subject to capability negotiation - receivers MUST declare support + for task augmentation of specific request types in their capabilities. + """ + temperature: float | None = None + tool_choice: Annotated[ToolChoice | None, Field(alias="toolChoice")] = None + """ + Controls how the model uses tools. + The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + Default is `{ mode: "auto" }`. + """ + tools: list[Tool] | None = None + """ + Tools that the model may use during generation. + The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + """ + + +class CreateMessageRequest(WireModel): + """ + A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["sampling/createMessage"] + params: CreateMessageRequestParams + + +class ServerRequest( + RootModel[ + PingRequest + | GetTaskRequest + | GetTaskPayloadRequest + | CancelTaskRequest + | ListTasksRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest + ] +): + root: ( + PingRequest + | GetTaskRequest + | GetTaskPayloadRequest + | CancelTaskRequest + | ListTasksRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/_v2026_07_28/__init__.py b/.mcp-venv/lib/python3.13/site-packages/mcp_types/_v2026_07_28/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fb168b30590c62d33f452b79dc59d98b6959a902 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types/_v2026_07_28/__init__.py @@ -0,0 +1,3746 @@ +"""Internal wire-shape models for protocol 2026-07-28. Generated; do not edit. + +Regenerate with `scripts/gen_surface_types.py` from `schema/2026-07-28.json` +(sha256 `6293cdfe015c14bd36eda4b1331ce37bda377609e58ed6d09d16f28e7d3c7ad4`).""" +# pyright: reportIncompatibleVariableOverride=false, reportGeneralTypeIssues=false + +from __future__ import annotations + +from typing import Annotated, Any, Literal, Union + +from mcp_types._wire_base import WireModel +from pydantic import ConfigDict, Field, RootModel + + +class BaseMetadata(WireModel): + """ + Base interface for metadata with name (identifier) and title (display name) properties. + """ + + model_config = ConfigDict( + extra="ignore", + ) + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class BooleanSchema(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + default: bool | None = None + description: str | None = None + title: str | None = None + type: Literal["boolean"] + + +class Argument(WireModel): + """ + The argument's information + """ + + model_config = ConfigDict( + extra="ignore", + ) + name: str + """ + The name of the argument + """ + value: str + """ + The value of the argument to use for completion matching. + """ + + +class Context(WireModel): + """ + Additional, optional context for completions + """ + + model_config = ConfigDict( + extra="ignore", + ) + arguments: dict[str, str] | None = None + """ + Previously-resolved variables in a URI template or prompt. + """ + + +class Completion(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + has_more: Annotated[bool | None, Field(alias="hasMore")] = None + """ + Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + """ + total: int | None = None + """ + The total number of completion options available. This can exceed the number of values actually sent in the response. + """ + values: Annotated[list[str], Field(max_length=100)] + """ + An array of completion values. Must not exceed 100 items. + """ + + +class Cursor(RootModel[str]): + root: str + """ + An opaque token used to represent a cursor for pagination. + """ + + +class RequestedSchema(WireModel): + """ + A restricted subset of JSON Schema. + Only top-level properties are allowed, without nesting. + """ + + model_config = ConfigDict( + extra="ignore", + ) + schema_: Annotated[str | None, Field(alias="$schema")] = None + properties: dict[str, Any] + required: list[str] | None = None + type: Literal["object"] + + +class ElicitRequestFormParams(WireModel): + """ + The parameters for a request to elicit non-sensitive information from the user via a form in the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + message: str + """ + The message to present to the user describing what information is being requested. + """ + mode: Literal["form"] = "form" + """ + The elicitation mode. + """ + requested_schema: Annotated[RequestedSchema, Field(alias="requestedSchema")] + """ + A restricted subset of JSON Schema. + Only top-level properties are allowed, without nesting. + """ + + +class ElicitRequestURLParams(WireModel): + """ + The parameters for a request to elicit information from the user via a URL in the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + message: str + """ + The message to present to the user explaining why the interaction is needed. + """ + mode: Literal["url"] + """ + The elicitation mode. + """ + url: str + """ + The URL that the user should navigate to. + """ + + +class ElicitResult(WireModel): + """ + The result returned by the client for an {@link ElicitRequestelicitation/create} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + action: Literal["accept", "cancel", "decline"] + """ + The user action in response to the elicitation. + - `"accept"`: User submitted the form/confirmed the action + - `"decline"`: User explicitly declined the action + - `"cancel"`: User dismissed without making an explicit choice + """ + content: dict[str, list[str] | str | int | float | bool | None] | None = None + """ + The submitted form data, only present when action is `"accept"` and mode was `"form"`. + Contains values matching the requested schema. + Omitted for out-of-band mode responses. + """ + + +class Error(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + code: int + """ + The error type that occurred. + """ + data: Any | None = None + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + message: str + """ + A short description of the error. The message SHOULD be limited to a concise single sentence. + """ + + +class Error1(Error): + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32020] + """ + The error type that occurred. + """ + + +class Icon(WireModel): + """ + An optionally-sized icon that can be displayed in a user interface. + """ + + model_config = ConfigDict( + extra="ignore", + ) + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + Optional MIME type override if the source MIME type is missing or generic. + For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + """ + sizes: list[str] | None = None + """ + Optional array of strings that specify sizes at which the icon can be used. + Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + + If not provided, the client should assume that the icon can be used at any size. + """ + src: str + """ + A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + `data:` URI with Base64-encoded image data. + + Consumers SHOULD take steps to ensure URLs serving icons are from the + same domain as the client/server or a trusted domain. + + Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + executable JavaScript. + """ + theme: Literal["dark", "light"] | None = None + """ + Optional specifier for the theme this icon is designed for. `"light"` indicates + the icon is designed to be used with a light background, and `"dark"` indicates + the icon is designed to be used with a dark background. + + If not provided, the client should assume the icon can be used with any theme. + """ + + +class Icons(WireModel): + """ + Base interface to add `icons` property. + """ + + model_config = ConfigDict( + extra="ignore", + ) + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + + +class Implementation(WireModel): + """ + Describes the MCP implementation. + """ + + model_config = ConfigDict( + extra="ignore", + ) + description: str | None = None + """ + An optional human-readable description of what this implementation does. + + This can be used by clients or servers to provide context about their purpose + and capabilities. For example, a server might describe the types of resources + or tools it provides, while a client might describe its intended use case. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + version: str + """ + The version of this implementation. + """ + website_url: Annotated[str | None, Field(alias="websiteUrl")] = None + """ + An optional URL of the website for this implementation. + """ + + +class InternalError(WireModel): + """ + A JSON-RPC error indicating that an internal error occurred on the receiver. This error is returned when the receiver encounters an unexpected condition that prevents it from fulfilling the request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32603] + """ + The error type that occurred. + """ + data: Any | None = None + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + message: str + """ + A short description of the error. The message SHOULD be limited to a concise single sentence. + """ + + +class InvalidParamsError(WireModel): + """ + A JSON-RPC error indicating that the method parameters are invalid or malformed. + + In MCP, this error is returned in various contexts when request parameters fail validation: + + - **Tools**: Unknown tool name or invalid tool arguments + - **Prompts**: Unknown prompt name or missing required arguments + - **Pagination**: Invalid or expired cursor values + - **Logging**: Invalid log level + - **Elicitation**: Server requests an elicitation mode not declared in client capabilities + - **Sampling**: Missing tool result or tool results mixed with other content + """ + + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32602] + """ + The error type that occurred. + """ + data: Any | None = None + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + message: str + """ + A short description of the error. The message SHOULD be limited to a concise single sentence. + """ + + +class InvalidRequestError(WireModel): + """ + A JSON-RPC error indicating that the request is not a valid request object. This error is returned when the message structure does not conform to the JSON-RPC 2.0 specification requirements for a request (e.g., missing required fields like `jsonrpc` or `method`, or using invalid types for these fields). + """ + + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32600] + """ + The error type that occurred. + """ + data: Any | None = None + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + message: str + """ + A short description of the error. The message SHOULD be limited to a concise single sentence. + """ + + +class JSONRPCNotification(WireModel): + """ + A notification which does not expect a response. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: str + params: dict[str, Any] | None = None + + +class LegacyTitledEnumSchema(WireModel): + """ + Use {@link TitledSingleSelectEnumSchema} instead. + This interface will be removed in a future version. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: str | None = None + description: str | None = None + enum: list[str] + enum_names: Annotated[list[str] | None, Field(alias="enumNames")] = None + """ + (Legacy) Display names for enum values. + Non-standard according to JSON schema 2020-12. + """ + title: str | None = None + type: Literal["string"] + + +class LoggingLevel( + RootModel[ + Literal[ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning", + ] + ] +): + root: Literal["alert", "critical", "debug", "emergency", "error", "info", "notice", "warning"] + """ + The severity of a log message. + + These map to syslog message severities, as specified in RFC-5424: + https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + """ + + +class MetaObject(WireModel): + """ + Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions. + + Certain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions. + + Valid keys have two segments: + + **Prefix:** + - Optional โ€” if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`). + - Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`). + - Implementations SHOULD use reverse DNS notation (e.g., `com.example/` rather than `example.com/`). + - Any prefix where the second label is `modelcontextprotocol` or `mcp` is **reserved** for MCP use. For example: `io.modelcontextprotocol/`, `dev.mcp/`, `org.modelcontextprotocol.api/`, and `com.mcp.tools/` are all reserved. However, `com.example.mcp/` is NOT reserved, as the second label is `example`. + + **Name:** + - Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`). + - Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`). + """ + + model_config = ConfigDict( + extra="allow", + ) + + +class MethodNotFoundError(WireModel): + """ + A JSON-RPC error indicating that the requested method does not exist or is not available. + + In MCP, a server returns this error when a client invokes a method the server does not implement โ€” either a genuinely unknown method, or one gated behind a server capability the server did not advertise (e.g., calling `prompts/list` when the `prompts` capability was not advertised). + + A request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32021`). + """ + + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32601] + """ + The error type that occurred. + """ + data: Any | None = None + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + message: str + """ + A short description of the error. The message SHOULD be limited to a concise single sentence. + """ + + +class ModelHint(WireModel): + """ + Hints to use for model selection. + + Keys not declared here are currently left unspecified by the spec and are up + to the client to interpret. + """ + + model_config = ConfigDict( + extra="ignore", + ) + name: str | None = None + """ + A hint for a model name. + + The client SHOULD treat this as a substring of a model name; for example: + - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + - `claude` should match any Claude model + + The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + """ + + +class ModelPreferences(WireModel): + """ + The server's preferences for model selection, requested of the client during sampling. + + Because LLMs can vary along multiple dimensions, choosing the "best" model is + rarely straightforward. Different models excel in different areasโ€”some are + faster but less capable, others are more capable but more expensive, and so + on. This interface allows servers to express their priorities across multiple + dimensions to help clients make an appropriate selection for their use case. + + These preferences are always advisory. The client MAY ignore them. It is also + up to the client to decide how to interpret these preferences and how to + balance them against other considerations. + """ + + model_config = ConfigDict( + extra="ignore", + ) + cost_priority: Annotated[float | None, Field(alias="costPriority", ge=0.0, le=1.0)] = None + """ + How much to prioritize cost when selecting a model. A value of 0 means cost + is not important, while a value of 1 means cost is the most important + factor. + """ + hints: list[ModelHint] | None = None + """ + Optional hints to use for model selection. + + If multiple hints are specified, the client MUST evaluate them in order + (such that the first match is taken). + + The client SHOULD prioritize these hints over the numeric priorities, but + MAY still use the priorities to select from ambiguous matches. + """ + intelligence_priority: Annotated[float | None, Field(alias="intelligencePriority", ge=0.0, le=1.0)] = None + """ + How much to prioritize intelligence and capabilities when selecting a + model. A value of 0 means intelligence is not important, while a value of 1 + means intelligence is the most important factor. + """ + speed_priority: Annotated[float | None, Field(alias="speedPriority", ge=0.0, le=1.0)] = None + """ + How much to prioritize sampling speed (latency) when selecting a model. A + value of 0 means speed is not important, while a value of 1 means speed is + the most important factor. + """ + + +class Notification(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + method: str + params: dict[str, Any] | None = None + + +class NumberSchema(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + default: int | float | None = None + description: str | None = None + maximum: int | float | None = None + minimum: int | float | None = None + title: str | None = None + type: Literal["integer", "number"] + + +class ParseError(WireModel): + """ + A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message. + """ + + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32700] + """ + The error type that occurred. + """ + data: Any | None = None + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + message: str + """ + A short description of the error. The message SHOULD be limited to a concise single sentence. + """ + + +class ProgressToken(RootModel[str | int]): + root: str | int + """ + A progress token, used to associate progress notifications with the original request. + """ + + +class PromptArgument(WireModel): + """ + Describes an argument that a prompt can accept. + """ + + model_config = ConfigDict( + extra="ignore", + ) + description: str | None = None + """ + A human-readable description of the argument. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + required: bool | None = None + """ + Whether this argument must be provided. + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class PromptReference(WireModel): + """ + Identifies a prompt. + """ + + model_config = ConfigDict( + extra="ignore", + ) + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + type: Literal["ref/prompt"] + + +class Request(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + method: str + params: dict[str, Any] | None = None + + +class RequestId(RootModel[str | int]): + root: str | int + """ + A uniquely identifying ID for a request in JSON-RPC. + """ + + +class ResourceContents(WireModel): + """ + The contents of a specific resource or sub-resource. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + uri: str + """ + The URI of this resource. + """ + + +class ResourceTemplateReference(WireModel): + """ + A reference to a resource or resource template definition. + """ + + model_config = ConfigDict( + extra="ignore", + ) + type: Literal["ref/resource"] + uri: str + """ + The URI or URI template of the resource. + """ + + +class ResultMetaObject(WireModel): + """ + Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply. + """ + + model_config = ConfigDict( + extra="allow", + ) + io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None + """ + Identifies the server software producing the response. Servers SHOULD + include this field on every response unless specifically configured not + to do so. + + The {@link Implementation} schema requires `name` and `version`; other + fields are optional. + + The value is self-reported by the server and is not verified by the + protocol. It is intended for display, logging, and debugging. Clients + SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + security decisions. + """ + + +class ResultType(RootModel[str]): + root: str + """ + Indicates the type of a {@link Result} object, allowing the client to + determine how to parse the response. + + complete - the request completed successfully and the result contains the final content. + input_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request. + """ + + +class Role(RootModel[Literal["assistant", "user"]]): + root: Literal["assistant", "user"] + """ + The sender or recipient of messages and data in a conversation. + """ + + +class Root(WireModel): + """ + Represents a root directory or file that the server can operate on. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + name: str | None = None + """ + An optional name for the root. This can be used to provide a human-readable + identifier for the root, which may be useful for display purposes or for + referencing the root in other parts of the application. + """ + uri: str + """ + The URI identifying the root. This *must* start with `file://` for now. + This restriction may be relaxed in future versions of the protocol to allow + other URI schemes. + """ + + +class Prompts(WireModel): + """ + Present if the server offers any prompt templates. + """ + + model_config = ConfigDict( + extra="ignore", + ) + list_changed: Annotated[bool | None, Field(alias="listChanged")] = None + """ + Whether this server supports notifications for changes to the prompt list. + """ + + +class Resources(WireModel): + """ + Present if the server offers any resources to read. + """ + + model_config = ConfigDict( + extra="ignore", + ) + list_changed: Annotated[bool | None, Field(alias="listChanged")] = None + """ + Whether this server supports notifications for changes to the resource list. + """ + subscribe: bool | None = None + """ + Whether this server supports subscribing to resource updates. + """ + + +class Tools(WireModel): + """ + Present if the server offers any tools to call. + """ + + model_config = ConfigDict( + extra="ignore", + ) + list_changed: Annotated[bool | None, Field(alias="listChanged")] = None + """ + Whether this server supports notifications for changes to the tool list. + """ + + +class StringSchema(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + default: str | None = None + description: str | None = None + format: Literal["date", "date-time", "email", "uri"] | None = None + max_length: Annotated[int | None, Field(alias="maxLength")] = None + min_length: Annotated[int | None, Field(alias="minLength")] = None + title: str | None = None + type: Literal["string"] + + +class SubscriptionFilter(WireModel): + """ + The set of notification types a client may opt in to on a + {@link SubscriptionsListenRequestsubscriptions/listen} request. + + Each notification type is **opt-in**; the server **MUST NOT** send + notification types the client has not explicitly requested here. + """ + + model_config = ConfigDict( + extra="ignore", + ) + prompts_list_changed: Annotated[bool | None, Field(alias="promptsListChanged")] = None + """ + If true, receive {@link PromptListChangedNotificationnotifications/prompts/list_changed}. + """ + resource_subscriptions: Annotated[list[str] | None, Field(alias="resourceSubscriptions")] = None + """ + Subscribe to {@link ResourceUpdatedNotificationnotifications/resources/updated} for these resource URIs. + Replaces the former `resources/subscribe` RPC. + """ + resources_list_changed: Annotated[bool | None, Field(alias="resourcesListChanged")] = None + """ + If true, receive {@link ResourceListChangedNotificationnotifications/resources/list_changed}. + """ + tools_list_changed: Annotated[bool | None, Field(alias="toolsListChanged")] = None + """ + If true, receive {@link ToolListChangedNotificationnotifications/tools/list_changed}. + """ + + +class SubscriptionsListenResultMeta(WireModel): + """ + Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a + {@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply. + """ + + model_config = ConfigDict( + extra="allow", + ) + io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None + """ + Identifies the server software producing the response. Servers SHOULD + include this field on every response unless specifically configured not + to do so. + + The {@link Implementation} schema requires `name` and `version`; other + fields are optional. + + The value is self-reported by the server and is not verified by the + protocol. It is intended for display, logging, and debugging. Clients + SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + security decisions. + """ + io_modelcontextprotocol_subscription_id: Annotated[RequestId, Field(alias="io.modelcontextprotocol/subscriptionId")] + """ + Identifies the subscription stream this response closes, so the client can + correlate it with the originating subscription โ€” mirroring the same key on + the stream's notifications. The value is the JSON-RPC ID of the + `subscriptions/listen` request that opened the stream (and equals this + response's `id`). + """ + + +class TextResourceContents(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + text: str + """ + The text of the item. This must only be set if the item can actually be represented as text (not binary data). + """ + uri: str + """ + The URI of this resource. + """ + + +class AnyOfItem(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + const: str + """ + The constant enum value. + """ + title: str + """ + Display title for this option. + """ + + +class Items(WireModel): + """ + Schema for array items with enum options and display labels. + """ + + model_config = ConfigDict( + extra="ignore", + ) + any_of: Annotated[list[AnyOfItem], Field(alias="anyOf")] + """ + Array of enum options with values and display labels. + """ + + +class TitledMultiSelectEnumSchema(WireModel): + """ + Schema for multiple-selection enumeration with display titles for each option. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: list[str] | None = None + """ + Optional default value. + """ + description: str | None = None + """ + Optional description for the enum field. + """ + items: Items + """ + Schema for array items with enum options and display labels. + """ + max_items: Annotated[int | None, Field(alias="maxItems")] = None + """ + Maximum number of items to select. + """ + min_items: Annotated[int | None, Field(alias="minItems")] = None + """ + Minimum number of items to select. + """ + title: str | None = None + """ + Optional title for the enum field. + """ + type: Literal["array"] + + +class OneOfItem(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + const: str + """ + The enum value. + """ + title: str + """ + Display label for this option. + """ + + +class TitledSingleSelectEnumSchema(WireModel): + """ + Schema for single-selection enumeration with display titles for each option. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: str | None = None + """ + Optional default value. + """ + description: str | None = None + """ + Optional description for the enum field. + """ + one_of: Annotated[list[OneOfItem], Field(alias="oneOf")] + """ + Array of enum options with values and display labels. + """ + title: str | None = None + """ + Optional title for the enum field. + """ + type: Literal["string"] + + +class InputSchema(WireModel): + """ + A JSON Schema object defining the expected parameters for the tool. + + Tool arguments are always JSON objects, so `type: "object"` is required at the root. + Beyond that, any JSON Schema 2020-12 keyword may appear alongside `type` โ€” including + composition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords + (`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other + standard validation or annotation keywords. + + Property schemas may carry an `x-mcp-header` annotation to mirror the + argument value into an HTTP header on the Streamable HTTP transport. See + the Streamable HTTP transport specification for the validity and + extraction rules. + + Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. + """ + + model_config = ConfigDict( + extra="allow", + ) + schema_: Annotated[str | None, Field(alias="$schema")] = None + type: Literal["object"] + + +class OutputSchema(WireModel): + """ + An optional JSON Schema object defining the structure of the tool's output returned in + the structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12. + + Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. + """ + + model_config = ConfigDict( + extra="allow", + ) + schema_: Annotated[str | None, Field(alias="$schema")] = None + + +class ToolAnnotations(WireModel): + """ + Additional properties describing a {@link Tool} to clients. + + NOTE: all properties in `ToolAnnotations` are **hints**. + They are not guaranteed to provide a faithful description of + tool behavior (including descriptive properties like `title`). + + Clients should never make tool use decisions based on `ToolAnnotations` + received from untrusted servers. + """ + + model_config = ConfigDict( + extra="ignore", + ) + destructive_hint: Annotated[bool | None, Field(alias="destructiveHint")] = None + """ + If true, the tool may perform destructive updates to its environment. + If false, the tool performs only additive updates. + + (This property is meaningful only when `readOnlyHint == false`) + + Default: true + """ + idempotent_hint: Annotated[bool | None, Field(alias="idempotentHint")] = None + """ + If true, calling the tool repeatedly with the same arguments + will have no additional effect on its environment. + + (This property is meaningful only when `readOnlyHint == false`) + + Default: false + """ + open_world_hint: Annotated[bool | None, Field(alias="openWorldHint")] = None + """ + If true, this tool may interact with an "open world" of external + entities. If false, the tool's domain of interaction is closed. + For example, the world of a web search tool is open, whereas that + of a memory tool is not. + + Default: true + """ + read_only_hint: Annotated[bool | None, Field(alias="readOnlyHint")] = None + """ + If true, the tool does not modify its environment. + + Default: false + """ + title: str | None = None + """ + A human-readable title for the tool. + """ + + +class ToolChoice(WireModel): + """ + Controls tool selection behavior for sampling requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + mode: Literal["auto", "none", "required"] | None = None + """ + Controls the tool use ability of the model: + - `"auto"`: Model decides whether to use tools (default) + - `"required"`: Model MUST use at least one tool before completing + - `"none"`: Model MUST NOT use any tools + """ + + +class ToolUseContent(WireModel): + """ + A request from the assistant to call a tool. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + """ + Optional metadata about the tool use. Clients SHOULD preserve this field when + including tool uses in subsequent sampling requests to enable caching optimizations. + """ + id: str + """ + A unique identifier for this tool use. + + This ID is used to match tool results to their corresponding tool uses. + """ + input: dict[str, Any] + """ + The arguments to pass to the tool, conforming to the tool's input schema. + """ + name: str + """ + The name of the tool to call. + """ + type: Literal["tool_use"] + + +class Data1(WireModel): + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + + model_config = ConfigDict( + extra="ignore", + ) + requested: str + """ + The protocol version that was requested by the client. + """ + supported: list[str] + """ + Protocol versions the server supports. The client should choose a + mutually supported version from this list and retry. + """ + + +class Error3(Error): + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32022] + """ + The error type that occurred. + """ + data: Data1 + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + + +class UnsupportedProtocolVersionError(WireModel): + """ + Returned when the request's protocol version is unknown to the server or + unsupported (e.g., a known experimental or draft version the server has + chosen not to implement). For HTTP, the response status code MUST be + `400 Bad Request`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + error: Error3 + id: RequestId | None = None + jsonrpc: Literal["2.0"] + + +class Items1(WireModel): + """ + Schema for the array items. + """ + + model_config = ConfigDict( + extra="ignore", + ) + enum: list[str] + """ + Array of enum values to choose from. + """ + type: Literal["string"] + + +class UntitledMultiSelectEnumSchema(WireModel): + """ + Schema for multiple-selection enumeration without display titles for options. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: list[str] | None = None + """ + Optional default value. + """ + description: str | None = None + """ + Optional description for the enum field. + """ + items: Items1 + """ + Schema for the array items. + """ + max_items: Annotated[int | None, Field(alias="maxItems")] = None + """ + Maximum number of items to select. + """ + min_items: Annotated[int | None, Field(alias="minItems")] = None + """ + Minimum number of items to select. + """ + title: str | None = None + """ + Optional title for the enum field. + """ + type: Literal["array"] + + +class UntitledSingleSelectEnumSchema(WireModel): + """ + Schema for single-selection enumeration without display titles for options. + """ + + model_config = ConfigDict( + extra="ignore", + ) + default: str | None = None + """ + Optional default value. + """ + description: str | None = None + """ + Optional description for the enum field. + """ + enum: list[str] + """ + Array of enum values to choose from. + """ + title: str | None = None + """ + Optional title for the enum field. + """ + type: Literal["string"] + + +class Annotations(WireModel): + """ + Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + """ + + model_config = ConfigDict( + extra="ignore", + ) + audience: list[Role] | None = None + """ + Describes who the intended audience of this object or data is. + + It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + """ + last_modified: Annotated[str | None, Field(alias="lastModified")] = None + """ + The moment the resource was last modified, as an ISO 8601 formatted string. + + Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + + Examples: last activity timestamp in an open file, timestamp when the resource + was attached, etc. + """ + priority: Annotated[float | None, Field(ge=0.0, le=1.0)] = None + """ + Describes how important this data is for operating the server. + + A value of 1 means "most important," and indicates that the data is + effectively required, while 0 means "least important," and indicates that + the data is entirely optional. + """ + + +class AudioContent(WireModel): + """ + Audio provided to or from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + data: str + """ + The base64-encoded audio data. + """ + mime_type: Annotated[str, Field(alias="mimeType")] + """ + The MIME type of the audio. Different providers may support different audio types. + """ + type: Literal["audio"] + + +class BlobResourceContents(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + blob: str + """ + A base64-encoded string representing the binary data of the item. + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + uri: str + """ + The URI of this resource. + """ + + +class CacheableResult(WireModel): + """ + A result that supports a time-to-live (TTL) hint for client-side caching. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] + """ + Indicates the intended scope of the cached response, analogous to HTTP + `Cache-Control: public` vs `Cache-Control: private`. + + - `"public"`: The response does not contain user-specific data. Any + client or intermediary (e.g., shared gateway, caching proxy) MAY cache + the response and serve it across authorization contexts. + - `"private"`: The response MAY be cached and reused only within the + same authorization context. Caches MUST NOT be shared across + authorization contexts (e.g., a different access token requires a + different cache). + """ + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] + """ + A hint from the server indicating how long (in milliseconds) the + client MAY cache this response before re-fetching. Semantics are + analogous to HTTP Cache-Control max-age. + + - If 0, The response SHOULD be considered immediately stale, + The client MAY re-fetch every time the result is needed. + - If positive, the client SHOULD consider the result fresh for this many + milliseconds after receiving the response. + """ + + +class CompleteResult(WireModel): + """ + The result returned by the server for a {@link CompleteRequestcompletion/complete} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + completion: Completion + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + +class CompleteResultResponse(WireModel): + """ + A successful response from the server for a {@link CompleteRequestcompletion/complete} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: CompleteResult + + +class ElicitRequestParams(RootModel[ElicitRequestFormParams | ElicitRequestURLParams]): + root: ElicitRequestFormParams | ElicitRequestURLParams + """ + The parameters for a request to elicit additional information from the user via the client. + """ + + +class EmbeddedResource(WireModel): + """ + The contents of a resource, embedded into a prompt or tool call result. + + It is up to the client how best to render embedded resources for the benefit + of the LLM and/or the user. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + resource: TextResourceContents | BlobResourceContents + type: Literal["resource"] + + +class EnumSchema( + RootModel[ + UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema + ] +): + root: ( + UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema + ) + + +class HeaderMismatchError(WireModel): + """ + Returned when a server rejects a request because the values in the HTTP + headers do not match the corresponding values in the request body, or + because required headers are missing or malformed. For HTTP, the response + status code MUST be `400 Bad Request`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + error: Error1 + id: RequestId | None = None + jsonrpc: Literal["2.0"] + + +class ImageContent(WireModel): + """ + An image provided to or from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + data: str + """ + The base64-encoded image data. + """ + mime_type: Annotated[str, Field(alias="mimeType")] + """ + The MIME type of the image. Different providers may support different image types. + """ + type: Literal["image"] + + +class JSONRPCErrorResponse(WireModel): + """ + A response to a request that indicates an error occurred. + """ + + model_config = ConfigDict( + extra="ignore", + ) + error: Error + id: RequestId | None = None + jsonrpc: Literal["2.0"] + + +class JSONRPCRequest(WireModel): + """ + A request that expects a response. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: str + params: dict[str, Any] | None = None + + +class Params(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + + +class ListRootsRequest(WireModel): + """ + Sent from the server to request a list of root URIs from the client. Roots allow + servers to ask for specific directories or files to operate on. A common example + for roots is providing a set of repositories or directories a server should operate + on. + + This request is typically used when the server needs to understand the file system + structure or access specific locations that the client has permission to read from. + """ + + model_config = ConfigDict( + extra="ignore", + ) + method: Literal["roots/list"] + params: Params | None = None + + +class ListRootsResult(WireModel): + """ + The result returned by the client for a {@link ListRootsRequestroots/list} request. + This result contains an array of {@link Root} objects, each representing a root directory + or file that the server can operate on. + """ + + model_config = ConfigDict( + extra="ignore", + ) + roots: list[Root] + + +class MultiSelectEnumSchema(RootModel[UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema]): + root: UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema + + +class NotificationMetaObject(WireModel): + """ + Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply. + """ + + model_config = ConfigDict( + extra="allow", + ) + io_modelcontextprotocol_subscription_id: Annotated[ + RequestId | None, Field(alias="io.modelcontextprotocol/subscriptionId") + ] = None + """ + Identifies the subscription stream a notification was delivered on. The + server MUST include this key on every notification delivered via a + {@link SubscriptionsListenRequestsubscriptions/listen} stream, so the + client can correlate the notification with the originating subscription. + The key is absent on notifications not delivered via a subscription + stream (e.g. progress notifications for an in-flight request), which is + why it is optional here. + + The value is the JSON-RPC ID of the `subscriptions/listen` request that + opened the stream. + """ + + +class NotificationParams(WireModel): + """ + Common params for any notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None + + +class PaginatedResult(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + +class PrimitiveSchemaDefinition( + RootModel[ + StringSchema + | NumberSchema + | BooleanSchema + | UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema + ] +): + root: ( + StringSchema + | NumberSchema + | BooleanSchema + | UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema + ) + """ + Restricted schema definitions that only allow primitive types + without nested objects or arrays. + """ + + +class ProgressNotificationParams(WireModel): + """ + Parameters for a {@link ProgressNotificationnotifications/progress} notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None + message: str | None = None + """ + An optional message describing the current progress. + """ + progress: float + """ + The progress thus far. This should increase every time progress is made, even if the total is unknown. + """ + progress_token: Annotated[ProgressToken, Field(alias="progressToken")] + """ + The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + """ + total: float | None = None + """ + Total number of items to process (or total progress required), if known. + """ + + +class Prompt(WireModel): + """ + A prompt or prompt template that the server offers. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + arguments: list[PromptArgument] | None = None + """ + A list of arguments to use for templating the prompt. + """ + description: str | None = None + """ + An optional description of what this prompt provides + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class PromptListChangedNotification(WireModel): + """ + An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `promptsListChanged` filter field. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/prompts/list_changed"] + params: NotificationParams | None = None + + +class ReadResourceResult(WireModel): + """ + The result returned by the server for a {@link ReadResourceRequestresources/read} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] + """ + Indicates the intended scope of the cached response, analogous to HTTP + `Cache-Control: public` vs `Cache-Control: private`. + + - `"public"`: The response does not contain user-specific data. Any + client or intermediary (e.g., shared gateway, caching proxy) MAY cache + the response and serve it across authorization contexts. + - `"private"`: The response MAY be cached and reused only within the + same authorization context. Caches MUST NOT be shared across + authorization contexts (e.g., a different access token requires a + different cache). + """ + contents: list[TextResourceContents | BlobResourceContents] + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] + """ + A hint from the server indicating how long (in milliseconds) the + client MAY cache this response before re-fetching. Semantics are + analogous to HTTP Cache-Control max-age. + + - If 0, The response SHOULD be considered immediately stale, + The client MAY re-fetch every time the result is needed. + - If positive, the client SHOULD consider the result fresh for this many + milliseconds after receiving the response. + """ + + +class Resource(WireModel): + """ + A known resource that the server is capable of reading. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + description: str | None = None + """ + A description of what this resource represents. + + This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + size: int | None = None + """ + The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + + This can be used by Hosts to display file sizes and estimate context window usage. + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + uri: str + """ + The URI of this resource. + """ + + +class ResourceLink(WireModel): + """ + A resource that the server is capable of reading, included in a prompt or tool call result. + + Note: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequestresources/list} requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + description: str | None = None + """ + A description of what this resource represents. + + This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type of this resource, if known. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + size: int | None = None + """ + The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + + This can be used by Hosts to display file sizes and estimate context window usage. + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + type: Literal["resource_link"] + uri: str + """ + The URI of this resource. + """ + + +class ResourceListChangedNotification(WireModel): + """ + An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `resourcesListChanged` filter field. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/resources/list_changed"] + params: NotificationParams | None = None + + +class ResourceTemplate(WireModel): + """ + A template description for resources available on the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + description: str | None = None + """ + A description of what this template is for. + + This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + """ + The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + uri_template: Annotated[str, Field(alias="uriTemplate")] + """ + A URI template (according to RFC 6570) that can be used to construct resource URIs. + """ + + +class ResourceUpdatedNotificationParams(WireModel): + """ + Parameters for a `notifications/resources/updated` notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None + uri: str + """ + The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + """ + + +class Result(WireModel): + """ + Common result fields. + """ + + model_config = ConfigDict( + extra="allow", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + +class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): + root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema + + +class SubscriptionsAcknowledgedNotificationParams(WireModel): + """ + Parameters for a {@link SubscriptionsAcknowledgedNotificationnotifications/subscriptions/acknowledged} notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None + notifications: SubscriptionFilter + """ + The subset of requested notification types the server agreed to honor. + Only includes notification types the server actually supports; if the + client requested an unsupported type (e.g., `promptsListChanged` when + the server has no prompts), it is omitted from this set. + """ + + +class SubscriptionsListenResult(WireModel): + """ + The response to a {@link SubscriptionsListenRequestsubscriptions/listen} + request, signalling that the subscription has ended gracefully (for example, + during server shutdown). Because the listen stream is long-lived, this result + is sent only when the server tears the subscription down; an abrupt transport + close carries no response. The result body is otherwise empty. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[SubscriptionsListenResultMeta, Field(alias="_meta")] + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + +class TextContent(WireModel): + """ + Text provided to or from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + annotations: Annotations | None = None + """ + Optional annotations for the client. + """ + text: str + """ + The text content of the message. + """ + type: Literal["text"] + + +class Tool(WireModel): + """ + Definition for a tool the client can call. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + annotations: ToolAnnotations | None = None + """ + Optional additional tool information. + + Display name precedence order is: `title`, `annotations.title`, then `name`. + """ + description: str | None = None + """ + A human-readable description of the tool. + + This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. + """ + icons: list[Icon] | None = None + """ + Optional set of sized icons that the client can display in a user interface. + + Clients that support rendering icons MUST support at least the following MIME types: + - `image/png` - PNG images (safe, universal compatibility) + - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + + Clients that support rendering icons SHOULD also support: + - `image/svg+xml` - SVG images (scalable but requires security precautions) + - `image/webp` - WebP images (modern, efficient format) + """ + input_schema: Annotated[InputSchema, Field(alias="inputSchema")] + """ + A JSON Schema object defining the expected parameters for the tool. + + Tool arguments are always JSON objects, so `type: "object"` is required at the root. + Beyond that, any JSON Schema 2020-12 keyword may appear alongside `type` โ€” including + composition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords + (`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other + standard validation or annotation keywords. + + Property schemas may carry an `x-mcp-header` annotation to mirror the + argument value into an HTTP header on the Streamable HTTP transport. See + the Streamable HTTP transport specification for the validity and + extraction rules. + + Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. + """ + name: str + """ + Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + """ + output_schema: Annotated[OutputSchema | None, Field(alias="outputSchema")] = None + """ + An optional JSON Schema object defining the structure of the tool's output returned in + the structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12. + + Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. + """ + title: str | None = None + """ + Intended for UI and end-user contexts โ€” optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for {@link Tool}, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class ToolListChangedNotification(WireModel): + """ + An optional notification from the server to the client, informing it that the list of tools it offers has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `toolsListChanged` filter field. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/tools/list_changed"] + params: NotificationParams | None = None + + +class CancelledNotificationParams(WireModel): + """ + Parameters for a `notifications/cancelled` notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None + reason: str | None = None + """ + An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + """ + request_id: Annotated[RequestId, Field(alias="requestId")] + """ + The ID of the request to cancel. + + This MUST correspond to the ID of a request the client previously issued. + """ + + +class ClientNotification(WireModel): + """ + This notification is sent by the client to indicate that it is cancelling a request it previously issued. + + On stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request. + + The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + + This notification indicates that the result will be unused, so any associated processing SHOULD cease. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/cancelled"] + params: CancelledNotificationParams + + +class ClientResult(RootModel[Result]): + root: Result + """ + Common result fields. + """ + + +class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): + root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource + + +class ElicitRequest(WireModel): + """ + A request from the server to elicit additional information from the user via the client. + """ + + model_config = ConfigDict( + extra="ignore", + ) + method: Literal["elicitation/create"] + params: ElicitRequestParams + + +class EmptyResult(RootModel[Result]): + root: Result + """ + Common result fields. + """ + + +class JSONRPCResultResponse(WireModel): + """ + A successful (non-error) response to a request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: Result + + +class ListPromptsResult(WireModel): + """ + The result returned by the server for a {@link ListPromptsRequestprompts/list} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] + """ + Indicates the intended scope of the cached response, analogous to HTTP + `Cache-Control: public` vs `Cache-Control: private`. + + - `"public"`: The response does not contain user-specific data. Any + client or intermediary (e.g., shared gateway, caching proxy) MAY cache + the response and serve it across authorization contexts. + - `"private"`: The response MAY be cached and reused only within the + same authorization context. Caches MUST NOT be shared across + authorization contexts (e.g., a different access token requires a + different cache). + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + prompts: list[Prompt] + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] + """ + A hint from the server indicating how long (in milliseconds) the + client MAY cache this response before re-fetching. Semantics are + analogous to HTTP Cache-Control max-age. + + - If 0, The response SHOULD be considered immediately stale, + The client MAY re-fetch every time the result is needed. + - If positive, the client SHOULD consider the result fresh for this many + milliseconds after receiving the response. + """ + + +class ListPromptsResultResponse(WireModel): + """ + A successful response from the server for a {@link ListPromptsRequestprompts/list} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: ListPromptsResult + + +class ListResourceTemplatesResult(WireModel): + """ + The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] + """ + Indicates the intended scope of the cached response, analogous to HTTP + `Cache-Control: public` vs `Cache-Control: private`. + + - `"public"`: The response does not contain user-specific data. Any + client or intermediary (e.g., shared gateway, caching proxy) MAY cache + the response and serve it across authorization contexts. + - `"private"`: The response MAY be cached and reused only within the + same authorization context. Caches MUST NOT be shared across + authorization contexts (e.g., a different access token requires a + different cache). + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + resource_templates: Annotated[list[ResourceTemplate], Field(alias="resourceTemplates")] + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] + """ + A hint from the server indicating how long (in milliseconds) the + client MAY cache this response before re-fetching. Semantics are + analogous to HTTP Cache-Control max-age. + + - If 0, The response SHOULD be considered immediately stale, + The client MAY re-fetch every time the result is needed. + - If positive, the client SHOULD consider the result fresh for this many + milliseconds after receiving the response. + """ + + +class ListResourceTemplatesResultResponse(WireModel): + """ + A successful response from the server for a {@link ListResourceTemplatesRequestresources/templates/list} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: ListResourceTemplatesResult + + +class ListResourcesResult(WireModel): + """ + The result returned by the server for a {@link ListResourcesRequestresources/list} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] + """ + Indicates the intended scope of the cached response, analogous to HTTP + `Cache-Control: public` vs `Cache-Control: private`. + + - `"public"`: The response does not contain user-specific data. Any + client or intermediary (e.g., shared gateway, caching proxy) MAY cache + the response and serve it across authorization contexts. + - `"private"`: The response MAY be cached and reused only within the + same authorization context. Caches MUST NOT be shared across + authorization contexts (e.g., a different access token requires a + different cache). + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + resources: list[Resource] + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] + """ + A hint from the server indicating how long (in milliseconds) the + client MAY cache this response before re-fetching. Semantics are + analogous to HTTP Cache-Control max-age. + + - If 0, The response SHOULD be considered immediately stale, + The client MAY re-fetch every time the result is needed. + - If positive, the client SHOULD consider the result fresh for this many + milliseconds after receiving the response. + """ + + +class ListResourcesResultResponse(WireModel): + """ + A successful response from the server for a {@link ListResourcesRequestresources/list} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: ListResourcesResult + + +class ListToolsResult(WireModel): + """ + The result returned by the server for a {@link ListToolsRequesttools/list} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] + """ + Indicates the intended scope of the cached response, analogous to HTTP + `Cache-Control: public` vs `Cache-Control: private`. + + - `"public"`: The response does not contain user-specific data. Any + client or intermediary (e.g., shared gateway, caching proxy) MAY cache + the response and serve it across authorization contexts. + - `"private"`: The response MAY be cached and reused only within the + same authorization context. Caches MUST NOT be shared across + authorization contexts (e.g., a different access token requires a + different cache). + """ + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + tools: list[Tool] + ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] + """ + A hint from the server indicating how long (in milliseconds) the + client MAY cache this response before re-fetching. Semantics are + analogous to HTTP Cache-Control max-age. + + - If 0, The response SHOULD be considered immediately stale, + The client MAY re-fetch every time the result is needed. + - If positive, the client SHOULD consider the result fresh for this many + milliseconds after receiving the response. + """ + + +class ListToolsResultResponse(WireModel): + """ + A successful response from the server for a {@link ListToolsRequesttools/list} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: ListToolsResult + + +class LoggingMessageNotificationParams(WireModel): + """ + Parameters for a `notifications/message` notification. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None + data: Any + """ + The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + """ + level: LoggingLevel + """ + The severity of this log message. + """ + logger: str | None = None + """ + An optional name of the logger issuing this message. + """ + + +class ProgressNotification(WireModel): + """ + An out-of-band notification used to inform the receiver of a progress update for a long-running request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/progress"] + params: ProgressNotificationParams + + +class PromptMessage(WireModel): + """ + Describes a message returned as part of a prompt. + + This is similar to {@link SamplingMessage}, but also supports the embedding of + resources from the MCP server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + content: ContentBlock + role: Role + + +class ResourceUpdatedNotification(WireModel): + """ + A notification from the server to the client, informing it that a resource has changed and may need to be read again. This is only sent for resources the client opted in to via the `resourceSubscriptions` field of a {@link SubscriptionsListenRequestsubscriptions/listen} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/resources/updated"] + params: ResourceUpdatedNotificationParams + + +class SubscriptionsAcknowledgedNotification(WireModel): + """ + Sent by the server to acknowledge that a + {@link SubscriptionsListenRequestsubscriptions/listen} subscription has been + established and to report which notification types it agreed to honor. + + This notification MUST be the first message the server sends carrying the + subscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST + NOT send any notification on the subscription before acknowledging it. On + stdio, where every subscription shares one channel, this ordering is defined + per subscription ID and not per channel: messages belonging to other + subscriptions MAY be interleaved before it. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/subscriptions/acknowledged"] + params: SubscriptionsAcknowledgedNotificationParams + + +class ToolResultContent(WireModel): + """ + The result of a tool use, provided by the user back to the assistant. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + """ + Optional metadata about the tool result. Clients SHOULD preserve this field when + including tool results in subsequent sampling requests to enable caching optimizations. + """ + content: list[ContentBlock] + """ + The unstructured result content of the tool use. + + This has the same format as {@link CallToolResult.content} and can include text, images, + audio, resource links, and embedded resources. + """ + is_error: Annotated[bool | None, Field(alias="isError")] = None + """ + Whether the tool use resulted in an error. + + If true, the content typically describes the error that occurred. + Default: false + """ + structured_content: Annotated[Any | None, Field(alias="structuredContent")] = None + """ + An optional structured result value. + + This can be any JSON value (object, array, string, number, boolean, or null). + If the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema. + """ + tool_use_id: Annotated[str, Field(alias="toolUseId")] + """ + The ID of the tool use this result corresponds to. + + This MUST match the ID from a previous {@link ToolUseContent}. + """ + type: Literal["tool_result"] + + +class CallToolResult(WireModel): + """ + The result returned by the server for a {@link CallToolRequesttools/call} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + content: list[ContentBlock] + """ + A list of content objects that represent the unstructured result of the tool call. + """ + is_error: Annotated[bool | None, Field(alias="isError")] = None + """ + Whether the tool call ended in an error. + + If not set, this is assumed to be false (the call was successful). + + Any errors that originate from the tool SHOULD be reported inside the result + object, with `isError` set to true, _not_ as an MCP protocol-level error + response. Otherwise, the LLM would not be able to see that an error occurred + and self-correct. + + However, any errors in _finding_ the tool, an error indicating that the + server does not support tool calls, or any other exceptional conditions, + should be reported as an MCP error response. + """ + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + structured_content: Annotated[Any | None, Field(alias="structuredContent")] = None + """ + An optional JSON value that represents the structured result of the tool call. + + This can be any JSON value (object, array, string, number, boolean, or null) + that conforms to the tool's outputSchema if one is defined. + """ + + +class CancelledNotification(WireModel): + """ + This notification is sent by the client to indicate that it is cancelling a request it previously issued. + + On stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request. + + The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + + This notification indicates that the result will be unused, so any associated processing SHOULD cease. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/cancelled"] + params: CancelledNotificationParams + + +class GetPromptResult(WireModel): + """ + The result returned by the server for a {@link GetPromptRequestprompts/get} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + description: str | None = None + """ + An optional description for the prompt. + """ + messages: list[PromptMessage] + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + +class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]): + root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse + """ + Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + """ + + +class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): + root: JSONRPCResultResponse | JSONRPCErrorResponse + """ + A response to a request, containing either the result or error. + """ + + +class LoggingMessageNotification(WireModel): + """ + JSONRPCNotification of a log message passed from server to client. The client opts in by setting `"io.modelcontextprotocol/logLevel"` in a request's `_meta`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + jsonrpc: Literal["2.0"] + method: Literal["notifications/message"] + params: LoggingMessageNotificationParams + + +class SamplingMessageContentBlock( + RootModel[TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent] +): + root: TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent + + +class ServerNotification( + RootModel[ + CancelledNotification + | ProgressNotification + | ResourceListChangedNotification + | SubscriptionsAcknowledgedNotification + | ResourceUpdatedNotification + | PromptListChangedNotification + | ToolListChangedNotification + | LoggingMessageNotification + ] +): + root: ( + CancelledNotification + | ProgressNotification + | ResourceListChangedNotification + | SubscriptionsAcknowledgedNotification + | ResourceUpdatedNotification + | PromptListChangedNotification + | ToolListChangedNotification + | LoggingMessageNotification + ) + + +class CreateMessageResult(WireModel): + """ + The result returned by the client for a {@link CreateMessageRequestsampling/createMessage} request. + The client should inform the user before returning the sampled message, to allow them + to inspect the response (human in the loop) and decide whether to allow the server to see it. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + content: ( + TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent + | list[SamplingMessageContentBlock] + ) + model: str + """ + The name of the model that generated the message. + """ + role: Role + stop_reason: Annotated[str | None, Field(alias="stopReason")] = None + """ + The reason why sampling stopped, if known. + + Standard values: + - `"endTurn"`: Natural end of the assistant's turn + - `"stopSequence"`: A stop sequence was encountered + - `"maxTokens"`: Maximum token limit was reached + - `"toolUse"`: The model wants to use one or more tools + + This field is an open string to allow for provider-specific stop reasons. + """ + + +class InputResponse(RootModel[CreateMessageResult | ListRootsResult | ElicitResult]): + root: CreateMessageResult | ListRootsResult | ElicitResult + + +class InputResponses(RootModel[dict[str, InputResponse]]): + """ + A map of client responses to server-initiated requests. + Keys correspond to the keys in the {@link InputRequests} map; + values are the client's result for each request. + """ + + root: dict[str, InputResponse] + + +class SamplingMessage(WireModel): + """ + Describes a message issued to or received from an LLM API. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + content: ( + TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent + | list[SamplingMessageContentBlock] + ) + role: Role + + +class CallToolRequest(WireModel): + """ + Used by the client to invoke a tool provided by the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tools/call"] + params: CallToolRequestParams + + +class CallToolRequestParams(WireModel): + """ + Parameters for a `tools/call` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + arguments: dict[str, Any] | None = None + """ + Arguments to use for the tool call. + """ + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + name: str + """ + The name of the tool. + """ + request_state: Annotated[str | None, Field(alias="requestState")] = None + + +class CallToolResultResponse(WireModel): + """ + A successful response from the server for a {@link CallToolRequesttools/call} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: InputRequiredResult | CallToolResult + + +class Elicitation(WireModel): + """ + Present if the client supports elicitation from the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + form: JSONObject | None = None + url: JSONObject | None = None + + +class Sampling(WireModel): + """ + Present if the client supports sampling from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + context: JSONObject | None = None + """ + Whether the client supports context inclusion via `includeContext` parameter. + If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + """ + tools: JSONObject | None = None + """ + Whether the client supports tool use via `tools` and `toolChoice` parameters. + """ + + +class ClientCapabilities(WireModel): + """ + Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + """ + + model_config = ConfigDict( + extra="ignore", + ) + elicitation: Elicitation | None = None + """ + Present if the client supports elicitation from the server. + """ + experimental: dict[str, JSONObject] | None = None + """ + Experimental, non-standard capabilities that the client supports. + """ + extensions: dict[str, JSONObject] | None = None + """ + Optional MCP extensions that the client supports. Keys are extension identifiers + (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are + per-extension settings objects. An empty object indicates support with no settings. + + Keys MUST follow the {@link MetaObject`_meta` key naming rules}, with a + mandatory prefix. + """ + roots: dict[str, Any] | None = None + """ + Present if the client supports listing roots. + """ + sampling: Sampling | None = None + """ + Present if the client supports sampling from an LLM. + """ + + +class CompleteRequest(WireModel): + """ + A request from the client to the server, to ask for completion options. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["completion/complete"] + params: CompleteRequestParams + + +class CompleteRequestParams(WireModel): + """ + Parameters for a `completion/complete` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + argument: Argument + """ + The argument's information + """ + context: Context | None = None + """ + Additional, optional context for completions + """ + ref: PromptReference | ResourceTemplateReference + + +class CreateMessageRequest(WireModel): + """ + A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + """ + + model_config = ConfigDict( + extra="ignore", + ) + method: Literal["sampling/createMessage"] + params: CreateMessageRequestParams + + +class CreateMessageRequestParams(WireModel): + """ + Parameters for a `sampling/createMessage` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + include_context: Annotated[ + Literal["allServers", "none", "thisServer"] | None, + Field(alias="includeContext"), + ] = None + """ + A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + The client MAY ignore this request. + + Default is `"none"`. The values `"thisServer"` and `"allServers"` are deprecated (SEP-2596): servers SHOULD + omit this field or use `"none"`, and SHOULD only use the deprecated values if the client declares + {@link ClientCapabilities.sampling.context}. + """ + max_tokens: Annotated[int, Field(alias="maxTokens")] + """ + The requested maximum number of tokens to sample (to prevent runaway completions). + + The client MAY choose to sample fewer tokens than the requested maximum. + """ + messages: list[SamplingMessage] + metadata: JSONObject | None = None + """ + Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + """ + model_preferences: Annotated[ModelPreferences | None, Field(alias="modelPreferences")] = None + """ + The server's preferences for which model to select. The client MAY ignore these preferences. + """ + stop_sequences: Annotated[list[str] | None, Field(alias="stopSequences")] = None + system_prompt: Annotated[str | None, Field(alias="systemPrompt")] = None + """ + An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + """ + temperature: float | None = None + tool_choice: Annotated[ToolChoice | None, Field(alias="toolChoice")] = None + """ + Controls how the model uses tools. + The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + Default is `{ mode: "auto" }`. + """ + tools: list[Tool] | None = None + """ + Tools that the model may use during generation. + The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + """ + + +class DiscoverRequest(WireModel): + """ + A request from the client asking the server to advertise its supported + protocol versions, capabilities, and other metadata. Servers **MUST** + implement `server/discover`. Clients **MAY** call it but are not required + to โ€” version negotiation can also happen inline via per-request `_meta`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["server/discover"] + params: RequestParams + + +class DiscoverResult(WireModel): + """ + The result returned by the server for a {@link DiscoverRequestserver/discover} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] + """ + Indicates the intended scope of the cached response, analogous to HTTP + `Cache-Control: public` vs `Cache-Control: private`. + + - `"public"`: The response does not contain user-specific data. Any + client or intermediary (e.g., shared gateway, caching proxy) MAY cache + the response and serve it across authorization contexts. + - `"private"`: The response MAY be cached and reused only within the + same authorization context. Caches MUST NOT be shared across + authorization contexts (e.g., a different access token requires a + different cache). + """ + capabilities: ServerCapabilities + """ + The capabilities of the server. + """ + instructions: str | None = None + """ + Natural-language guidance describing the server and its features. + + This can be used by clients to improve an LLM's understanding of + available tools (e.g., by including it in a system prompt). It should + focus on information that helps the model use the server effectively + and should not duplicate information already in tool descriptions. + """ + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + supported_versions: Annotated[list[str], Field(alias="supportedVersions")] + """ + MCP Protocol Versions this server supports. The client should choose a + version from this list for use in subsequent requests. + """ + ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] + """ + A hint from the server indicating how long (in milliseconds) the + client MAY cache this response before re-fetching. Semantics are + analogous to HTTP Cache-Control max-age. + + - If 0, The response SHOULD be considered immediately stale, + The client MAY re-fetch every time the result is needed. + - If positive, the client SHOULD consider the result fresh for this many + milliseconds after receiving the response. + """ + + +class DiscoverResultResponse(WireModel): + """ + A successful response from the server for a {@link DiscoverRequestserver/discover} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: DiscoverResult + + +class GetPromptRequest(WireModel): + """ + Used by the client to get a prompt provided by the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["prompts/get"] + params: GetPromptRequestParams + + +class GetPromptRequestParams(WireModel): + """ + Parameters for a `prompts/get` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + arguments: dict[str, str] | None = None + """ + Arguments to use for templating the prompt. + """ + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + name: str + """ + The name of the prompt or prompt template. + """ + request_state: Annotated[str | None, Field(alias="requestState")] = None + + +class GetPromptResultResponse(WireModel): + """ + A successful response from the server for a {@link GetPromptRequestprompts/get} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: InputRequiredResult | GetPromptResult + + +class InputRequiredResult(WireModel): + """ + An InputRequiredResult sent by the server to indicate that additional input is needed + before the request can be completed. + + At least one of `inputRequests` or `requestState` MUST be present. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + input_requests: Annotated[InputRequests | None, Field(alias="inputRequests")] = None + request_state: Annotated[str | None, Field(alias="requestState")] = None + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + +class InputResponseRequestParams(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + request_state: Annotated[str | None, Field(alias="requestState")] = None + + +class ListPromptsRequest(WireModel): + """ + Sent from the client to request a list of prompts and prompt templates the server has. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["prompts/list"] + params: PaginatedRequestParams + + +class ListResourceTemplatesRequest(WireModel): + """ + Sent from the client to request a list of resource templates the server has. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/templates/list"] + params: PaginatedRequestParams + + +class ListResourcesRequest(WireModel): + """ + Sent from the client to request a list of resources the server has. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/list"] + params: PaginatedRequestParams + + +class ListToolsRequest(WireModel): + """ + Sent from the client to request a list of tools the server has. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tools/list"] + params: PaginatedRequestParams + + +class Data(WireModel): + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + + model_config = ConfigDict( + extra="ignore", + ) + required_capabilities: Annotated[ClientCapabilities, Field(alias="requiredCapabilities")] + """ + The capabilities the server requires from the client to process this request. + """ + + +class Error2(Error): + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32021] + """ + The error type that occurred. + """ + data: Data + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + + +class MissingRequiredClientCapabilityError(WireModel): + """ + Returned when processing a request requires a capability the client did not + declare in `clientCapabilities`. For HTTP, the response status code MUST be + `400 Bad Request`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + error: Error2 + id: RequestId | None = None + jsonrpc: Literal["2.0"] + + +class PaginatedRequest(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: str + params: PaginatedRequestParams + + +class PaginatedRequestParams(WireModel): + """ + Common params for paginated requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + cursor: str | None = None + """ + An opaque token representing the current pagination position. + If provided, the server should return results starting after this cursor. + """ + + +class ReadResourceRequest(WireModel): + """ + Sent from the client to the server, to read a specific resource URI. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/read"] + params: ReadResourceRequestParams + + +class ReadResourceRequestParams(WireModel): + """ + Parameters for a `resources/read` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + request_state: Annotated[str | None, Field(alias="requestState")] = None + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + """ + + +class ReadResourceResultResponse(WireModel): + """ + A successful response from the server for a {@link ReadResourceRequestresources/read} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: InputRequiredResult | ReadResourceResult + + +class RequestMetaObject(WireModel): + """ + Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. + """ + + model_config = ConfigDict( + extra="allow", + ) + io_modelcontextprotocol_client_capabilities: Annotated[ + ClientCapabilities, Field(alias="io.modelcontextprotocol/clientCapabilities") + ] + """ + The client's capabilities for this specific request. Required. + + Capabilities are declared per-request rather than once at initialization; + an empty object means the client supports no optional capabilities. + Servers MUST NOT infer capabilities from prior requests. + """ + io_modelcontextprotocol_client_info: Annotated[ + Implementation | None, Field(alias="io.modelcontextprotocol/clientInfo") + ] = None + """ + Identifies the client software making the request. Clients SHOULD + include this field on every request unless specifically configured not + to do so. + + The {@link Implementation} schema requires `name` and `version`; other + fields are optional. + + The value is self-reported by the client and is not verified by the + protocol. It is intended for display, logging, and debugging. Servers + SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + security decisions. + """ + io_modelcontextprotocol_log_level: Annotated[ + LoggingLevel | None, Field(alias="io.modelcontextprotocol/logLevel") + ] = None + """ + The desired log level for this request. Optional. + + If absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message} + notifications for this request. The client opts in to log messages by + explicitly setting a level. Replaces the former `logging/setLevel` RPC. + """ + io_modelcontextprotocol_protocol_version: Annotated[str, Field(alias="io.modelcontextprotocol/protocolVersion")] + """ + The MCP Protocol Version being used for this request. Required. + + For the HTTP transport, this value MUST match the `MCP-Protocol-Version` + header; otherwise the server MUST return a `400 Bad Request`. If the + server does not support the requested version, it MUST return an + {@link UnsupportedProtocolVersionError}. + """ + progress_token: Annotated[ProgressToken | None, Field(alias="progressToken")] = None + """ + If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + """ + + +class RequestParams(WireModel): + """ + Common params for any request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + + +class ResourceRequestParams(WireModel): + """ + Common params for resource-related requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + """ + + +class ServerCapabilities(WireModel): + """ + Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + """ + + model_config = ConfigDict( + extra="ignore", + ) + completions: JSONObject | None = None + """ + Present if the server supports argument autocompletion suggestions. + """ + experimental: dict[str, JSONObject] | None = None + """ + Experimental, non-standard capabilities that the server supports. + """ + extensions: dict[str, JSONObject] | None = None + """ + Optional MCP extensions that the server supports. Keys are extension identifiers + (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings + objects. An empty object indicates support with no settings. + + Keys MUST follow the {@link MetaObject`_meta` key naming rules}, with a + mandatory prefix. + """ + logging: JSONObject | None = None + """ + Present if the server supports sending log messages to the client. + """ + prompts: Prompts | None = None + """ + Present if the server offers any prompt templates. + """ + resources: Resources | None = None + """ + Present if the server offers any resources to read. + """ + tools: Tools | None = None + """ + Present if the server offers any tools to call. + """ + + +class SubscriptionsListenRequest(WireModel): + """ + Sent from the client to open a long-lived channel for receiving notifications + outside the context of a specific request. Replaces the previous HTTP GET + endpoint and ensures consistent behavior between HTTP and STDIO. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["subscriptions/listen"] + params: SubscriptionsListenRequestParams + + +class SubscriptionsListenRequestParams(WireModel): + """ + Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + notifications: SubscriptionFilter + """ + The notifications the client opts in to on this stream. The server + **MUST NOT** send notification types the client has not explicitly + requested. + """ + + +class InputRequest(RootModel[CreateMessageRequest | ListRootsRequest | ElicitRequest]): + root: CreateMessageRequest | ListRootsRequest | ElicitRequest + + +class ServerResult( + RootModel[ + Result + | InputRequiredResult + | DiscoverResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | SubscriptionsListenResult + | ListPromptsResult + | GetPromptResult + | ListToolsResult + | CallToolResult + | CompleteResult + ] +): + root: ( + Result + | InputRequiredResult + | DiscoverResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | SubscriptionsListenResult + | ListPromptsResult + | GetPromptResult + | ListToolsResult + | CallToolResult + | CompleteResult + ) + + +class ClientRequest( + RootModel[ + DiscoverRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscriptionsListenRequest + | ListPromptsRequest + | GetPromptRequest + | ListToolsRequest + | CallToolRequest + | CompleteRequest + ] +): + root: ( + DiscoverRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscriptionsListenRequest + | ListPromptsRequest + | GetPromptRequest + | ListToolsRequest + | CallToolRequest + | CompleteRequest + ) + + +class InputRequests(RootModel[dict[str, InputRequest]]): + """ + A map of server-initiated requests that the client must fulfill. + Keys are server-assigned identifiers; values are the request objects. + """ + + root: dict[str, InputRequest] + + +class JSONArray(RootModel[list["JSONValue"]]): + root: list["JSONValue"] + + +class JSONObject(RootModel[dict[str, "JSONValue"]]): + root: dict[str, "JSONValue"] + + +class JSONValue(RootModel[Union[JSONObject, list["JSONValue"], str | int | float | bool | None]]): + root: Union[JSONObject, list["JSONValue"], str | int | float | bool | None] + + +AnyCallToolResult = CallToolResult | InputRequiredResult +AnyGetPromptResult = GetPromptResult | InputRequiredResult +AnyReadResourceResult = ReadResourceResult | InputRequiredResult + + +CallToolRequest.model_rebuild() +CallToolRequestParams.model_rebuild() +CallToolResultResponse.model_rebuild() +Elicitation.model_rebuild() +Sampling.model_rebuild() +ClientCapabilities.model_rebuild() +CompleteRequest.model_rebuild() +CompleteRequestParams.model_rebuild() +CreateMessageRequest.model_rebuild() +CreateMessageRequestParams.model_rebuild() +DiscoverRequest.model_rebuild() +DiscoverResult.model_rebuild() +GetPromptRequest.model_rebuild() +GetPromptRequestParams.model_rebuild() +GetPromptResultResponse.model_rebuild() +InputRequiredResult.model_rebuild() +InputResponseRequestParams.model_rebuild() +ListPromptsRequest.model_rebuild() +ListResourceTemplatesRequest.model_rebuild() +ListResourcesRequest.model_rebuild() +ListToolsRequest.model_rebuild() +PaginatedRequest.model_rebuild() +PaginatedRequestParams.model_rebuild() +ReadResourceRequest.model_rebuild() +ReadResourceRequestParams.model_rebuild() +ServerCapabilities.model_rebuild() +SubscriptionsListenRequest.model_rebuild() +JSONArray.model_rebuild() +JSONObject.model_rebuild() diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/_wire_base.py b/.mcp-venv/lib/python3.13/site-packages/mcp_types/_wire_base.py new file mode 100644 index 0000000000000000000000000000000000000000..69254a850b14816010a392a1ddb10f84f117cf94 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types/_wire_base.py @@ -0,0 +1,9 @@ +"""Shared pydantic base for the generated `mcp_types._v*` wire-shape packages.""" + +from pydantic import BaseModel, ConfigDict + + +class WireModel(BaseModel): + """Base for generated wire models: enables `populate_by_name`; subclasses set `extra` themselves.""" + + model_config = ConfigDict(populate_by_name=True) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/jsonrpc.py b/.mcp-venv/lib/python3.13/site-packages/mcp_types/jsonrpc.py new file mode 100644 index 0000000000000000000000000000000000000000..e9c6db96b46980b73da0936f4792a8405c8dff96 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types/jsonrpc.py @@ -0,0 +1,148 @@ +"""This module follows the JSON-RPC 2.0 specification: https://www.jsonrpc.org/specification.""" + +from __future__ import annotations + +from typing import Annotated, Any, Final, Literal + +from pydantic import BaseModel, Field, TypeAdapter + +__all__ = [ + "CONNECTION_CLOSED", + "HEADER_MISMATCH", + "INTERNAL_ERROR", + "INVALID_PARAMS", + "INVALID_REQUEST", + "JSONRPC_VERSION", + "METHOD_NOT_FOUND", + "MISSING_REQUIRED_CLIENT_CAPABILITY", + "PARSE_ERROR", + "REQUEST_TIMEOUT", + "UNSUPPORTED_PROTOCOL_VERSION", + "URL_ELICITATION_REQUIRED", + "ErrorData", + "JSONRPCError", + "JSONRPCMessage", + "JSONRPCNotification", + "JSONRPCRequest", + "JSONRPCResponse", + "RequestId", + "jsonrpc_message_adapter", +] + +RequestId = Annotated[int, Field(strict=True)] | str +"""The ID of a JSON-RPC request.""" + +JSONRPC_VERSION: Final[Literal["2.0"]] = "2.0" +"""The JSON-RPC version string carried by every MCP message envelope.""" + + +class JSONRPCRequest(BaseModel): + """A JSON-RPC request that expects a response.""" + + jsonrpc: Literal["2.0"] + id: RequestId + method: str + params: dict[str, Any] | None = None + + +class JSONRPCNotification(BaseModel): + """A JSON-RPC notification which does not expect a response.""" + + jsonrpc: Literal["2.0"] + method: str + params: dict[str, Any] | None = None + + +class JSONRPCResponse(BaseModel): + """A successful (non-error) response to a request. + + Named `JSONRPCResultResponse` in the 2025-11-25+ schemas; the SDK keeps the original name. + """ + + jsonrpc: Literal["2.0"] + id: RequestId + result: dict[str, Any] + + +# MCP error codes occupy the JSON-RPC server-error range -32000..-32099. +# Per the 2026-07-28 spec's allocation policy: +# -32000..-32019 implementation-defined +# -32020..-32099 reserved for spec-defined codes, allocated sequentially from -32020 +# -32002, -32042 reserved-never-reused (retired by earlier protocol versions) + +HEADER_MISMATCH = -32020 +"""HTTP headers do not match the request body, or required headers are missing/malformed (protocol 2026-07-28).""" + +MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 +"""The server requires a client capability the request did not declare (protocol 2026-07-28).""" + +UNSUPPORTED_PROTOCOL_VERSION = -32022 +"""The request's protocol version is not supported by the server (protocol 2026-07-28).""" + +URL_ELICITATION_REQUIRED = -32042 +"""A URL-mode elicitation is required before the request can be processed (protocol 2025-11-25 only).""" + +# SDK error codes: SDK-internal allocations in the implementation-defined band +# -32000..-32019; not defined by the MCP schema. +CONNECTION_CLOSED = -32000 +"""SDK-only: the connection closed before a response arrived; never emitted on the wire.""" + +REQUEST_TIMEOUT = -32001 +"""SDK-only: a request timed out waiting for its response.""" + +# Standard JSON-RPC error codes +PARSE_ERROR = -32700 +"""Standard JSON-RPC: invalid JSON was received.""" + +INVALID_REQUEST = -32600 +"""Standard JSON-RPC: the message is not a valid request object.""" + +METHOD_NOT_FOUND = -32601 +"""Standard JSON-RPC: the requested method does not exist or is not available.""" + +INVALID_PARAMS = -32602 +"""Standard JSON-RPC: invalid method parameters.""" + +INTERNAL_ERROR = -32603 +"""Standard JSON-RPC: an internal error occurred on the receiver. + +The SDK uses the generic `ErrorData` envelope; the schema's per-code wrapper types are not constructed. +""" + + +class ErrorData(BaseModel): + """Error information for JSON-RPC error responses.""" + + code: int + """The error type that occurred.""" + + message: str + """A short description of the error. + + The message SHOULD be limited to a concise single sentence. + """ + + data: Any = None + """Additional information about the error. + + The value of this member is defined by the sender (e.g. detailed error information, nested errors, etc.). + """ + + +class JSONRPCError(BaseModel): + """A response to a request that indicates an error occurred.""" + + jsonrpc: Literal["2.0"] + id: RequestId | None + """The id of the request this error responds to. + + Required but nullable per JSON-RPC 2.0: `None` encodes `"id": null` (the id could not be determined). + """ + + error: ErrorData + + +JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError +"""Any JSON-RPC envelope that can be decoded off the wire or encoded to be sent.""" + +jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/methods.py b/.mcp-venv/lib/python3.13/site-packages/mcp_types/methods.py new file mode 100644 index 0000000000000000000000000000000000000000..41959c56d7425b0a69cb06f49031e1491419d4bd --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types/methods.py @@ -0,0 +1,746 @@ +"""Per-version method maps and parse/serialize functions for MCP traffic. + +This module is supported public API; the `mcp_types._v*` packages it draws on +are internal validators and not for direct import. + +Surface maps key `(method, version)` to per-version wire types (key absence is +the version gate; shape validation is per schema era, i.e. 2025-11-25 for every +pre-2026 version and 2026-07-28 for 2026). Monolith maps key `method` to the +version-free `mcp_types` models user code receives.""" + +from __future__ import annotations + +from collections.abc import Mapping +from functools import cache +from types import MappingProxyType, UnionType +from typing import Any, Final, Literal, TypeGuard, TypeVar, cast, get_args + +from pydantic import BaseModel, TypeAdapter + +import mcp_types as types +import mcp_types._v2025_11_25 as v2025 +import mcp_types._v2026_07_28 as v2026 +from mcp_types.version import KNOWN_PROTOCOL_VERSIONS + +__all__ = [ + "CACHEABLE_METHODS", + "CLIENT_NOTIFICATIONS", + "CLIENT_REQUESTS", + "CLIENT_RESULTS", + "CacheableMethod", + "INPUT_REQUIRED_METHODS", + "MONOLITH_NOTIFICATIONS", + "MONOLITH_REQUESTS", + "MONOLITH_RESULTS", + "SERVER_NOTIFICATIONS", + "SERVER_REQUESTS", + "SERVER_RESULTS", + "SPEC_CLIENT_METHODS", + "SPEC_CLIENT_NOTIFICATION_METHODS", + "is_input_required", + "parse_client_notification", + "parse_client_request", + "parse_client_result", + "parse_server_notification", + "parse_server_request", + "parse_server_result", + "serialize_server_result", + "validate_client_notification", + "validate_client_request", + "validate_client_result", + "validate_server_result", +] + + +# --- Surface maps: client-to-server --- + +CLIENT_REQUESTS: Final[Mapping[tuple[str, str], type[BaseModel]]] = MappingProxyType( + { + # 2024-11-05 + ("completion/complete", "2024-11-05"): v2025.CompleteRequest, + ("initialize", "2024-11-05"): v2025.InitializeRequest, + ("logging/setLevel", "2024-11-05"): v2025.SetLevelRequest, + ("ping", "2024-11-05"): v2025.PingRequest, + ("prompts/get", "2024-11-05"): v2025.GetPromptRequest, + ("prompts/list", "2024-11-05"): v2025.ListPromptsRequest, + ("resources/list", "2024-11-05"): v2025.ListResourcesRequest, + ("resources/read", "2024-11-05"): v2025.ReadResourceRequest, + ("resources/subscribe", "2024-11-05"): v2025.SubscribeRequest, + ("resources/templates/list", "2024-11-05"): v2025.ListResourceTemplatesRequest, + ("resources/unsubscribe", "2024-11-05"): v2025.UnsubscribeRequest, + ("tools/call", "2024-11-05"): v2025.CallToolRequest, + ("tools/list", "2024-11-05"): v2025.ListToolsRequest, + # 2025-03-26 + ("completion/complete", "2025-03-26"): v2025.CompleteRequest, + ("initialize", "2025-03-26"): v2025.InitializeRequest, + ("logging/setLevel", "2025-03-26"): v2025.SetLevelRequest, + ("ping", "2025-03-26"): v2025.PingRequest, + ("prompts/get", "2025-03-26"): v2025.GetPromptRequest, + ("prompts/list", "2025-03-26"): v2025.ListPromptsRequest, + ("resources/list", "2025-03-26"): v2025.ListResourcesRequest, + ("resources/read", "2025-03-26"): v2025.ReadResourceRequest, + ("resources/subscribe", "2025-03-26"): v2025.SubscribeRequest, + ("resources/templates/list", "2025-03-26"): v2025.ListResourceTemplatesRequest, + ("resources/unsubscribe", "2025-03-26"): v2025.UnsubscribeRequest, + ("tools/call", "2025-03-26"): v2025.CallToolRequest, + ("tools/list", "2025-03-26"): v2025.ListToolsRequest, + # 2025-06-18 + ("completion/complete", "2025-06-18"): v2025.CompleteRequest, + ("initialize", "2025-06-18"): v2025.InitializeRequest, + ("logging/setLevel", "2025-06-18"): v2025.SetLevelRequest, + ("ping", "2025-06-18"): v2025.PingRequest, + ("prompts/get", "2025-06-18"): v2025.GetPromptRequest, + ("prompts/list", "2025-06-18"): v2025.ListPromptsRequest, + ("resources/list", "2025-06-18"): v2025.ListResourcesRequest, + ("resources/read", "2025-06-18"): v2025.ReadResourceRequest, + ("resources/subscribe", "2025-06-18"): v2025.SubscribeRequest, + ("resources/templates/list", "2025-06-18"): v2025.ListResourceTemplatesRequest, + ("resources/unsubscribe", "2025-06-18"): v2025.UnsubscribeRequest, + ("tools/call", "2025-06-18"): v2025.CallToolRequest, + ("tools/list", "2025-06-18"): v2025.ListToolsRequest, + # 2025-11-25 (tasks/* deliberately absent) + ("completion/complete", "2025-11-25"): v2025.CompleteRequest, + ("initialize", "2025-11-25"): v2025.InitializeRequest, + ("logging/setLevel", "2025-11-25"): v2025.SetLevelRequest, + ("ping", "2025-11-25"): v2025.PingRequest, + ("prompts/get", "2025-11-25"): v2025.GetPromptRequest, + ("prompts/list", "2025-11-25"): v2025.ListPromptsRequest, + ("resources/list", "2025-11-25"): v2025.ListResourcesRequest, + ("resources/read", "2025-11-25"): v2025.ReadResourceRequest, + ("resources/subscribe", "2025-11-25"): v2025.SubscribeRequest, + ("resources/templates/list", "2025-11-25"): v2025.ListResourceTemplatesRequest, + ("resources/unsubscribe", "2025-11-25"): v2025.UnsubscribeRequest, + ("tools/call", "2025-11-25"): v2025.CallToolRequest, + ("tools/list", "2025-11-25"): v2025.ListToolsRequest, + # 2026-07-28 (lifecycle, logging, subscribe pair removed; discover/listen added) + ("completion/complete", "2026-07-28"): v2026.CompleteRequest, + ("prompts/get", "2026-07-28"): v2026.GetPromptRequest, + ("prompts/list", "2026-07-28"): v2026.ListPromptsRequest, + ("resources/list", "2026-07-28"): v2026.ListResourcesRequest, + ("resources/read", "2026-07-28"): v2026.ReadResourceRequest, + ("resources/templates/list", "2026-07-28"): v2026.ListResourceTemplatesRequest, + ("server/discover", "2026-07-28"): v2026.DiscoverRequest, + ("subscriptions/listen", "2026-07-28"): v2026.SubscriptionsListenRequest, + ("tools/call", "2026-07-28"): v2026.CallToolRequest, + ("tools/list", "2026-07-28"): v2026.ListToolsRequest, + } +) + +CLIENT_NOTIFICATIONS: Final[Mapping[tuple[str, str], type[BaseModel]]] = MappingProxyType( + { + # 2024-11-05 + ("notifications/cancelled", "2024-11-05"): v2025.CancelledNotification, + ("notifications/initialized", "2024-11-05"): v2025.InitializedNotification, + ("notifications/progress", "2024-11-05"): v2025.ProgressNotification, + ("notifications/roots/list_changed", "2024-11-05"): v2025.RootsListChangedNotification, + # 2025-03-26 + ("notifications/cancelled", "2025-03-26"): v2025.CancelledNotification, + ("notifications/initialized", "2025-03-26"): v2025.InitializedNotification, + ("notifications/progress", "2025-03-26"): v2025.ProgressNotification, + ("notifications/roots/list_changed", "2025-03-26"): v2025.RootsListChangedNotification, + # 2025-06-18 + ("notifications/cancelled", "2025-06-18"): v2025.CancelledNotification, + ("notifications/initialized", "2025-06-18"): v2025.InitializedNotification, + ("notifications/progress", "2025-06-18"): v2025.ProgressNotification, + ("notifications/roots/list_changed", "2025-06-18"): v2025.RootsListChangedNotification, + # 2025-11-25 (tasks/status deliberately absent) + ("notifications/cancelled", "2025-11-25"): v2025.CancelledNotification, + ("notifications/initialized", "2025-11-25"): v2025.InitializedNotification, + ("notifications/progress", "2025-11-25"): v2025.ProgressNotification, + ("notifications/roots/list_changed", "2025-11-25"): v2025.RootsListChangedNotification, + # 2026-07-28 (initialized, progress and roots/list_changed removed) + ("notifications/cancelled", "2026-07-28"): v2026.CancelledNotification, + } +) + + +# --- Surface maps: server-to-client --- + +SERVER_REQUESTS: Final[Mapping[tuple[str, str], type[BaseModel]]] = MappingProxyType( + { + # 2024-11-05 + ("ping", "2024-11-05"): v2025.PingRequest, + ("roots/list", "2024-11-05"): v2025.ListRootsRequest, + ("sampling/createMessage", "2024-11-05"): v2025.CreateMessageRequest, + # 2025-03-26 + ("ping", "2025-03-26"): v2025.PingRequest, + ("roots/list", "2025-03-26"): v2025.ListRootsRequest, + ("sampling/createMessage", "2025-03-26"): v2025.CreateMessageRequest, + # 2025-06-18 (adds elicitation/create) + ("elicitation/create", "2025-06-18"): v2025.ElicitRequest, + ("ping", "2025-06-18"): v2025.PingRequest, + ("roots/list", "2025-06-18"): v2025.ListRootsRequest, + ("sampling/createMessage", "2025-06-18"): v2025.CreateMessageRequest, + # 2025-11-25 (tasks/* deliberately absent) + ("elicitation/create", "2025-11-25"): v2025.ElicitRequest, + ("ping", "2025-11-25"): v2025.PingRequest, + ("roots/list", "2025-11-25"): v2025.ListRootsRequest, + ("sampling/createMessage", "2025-11-25"): v2025.CreateMessageRequest, + # 2026-07-28: none (schema defines no ServerRequest union) + } +) + +SERVER_NOTIFICATIONS: Final[Mapping[tuple[str, str], type[BaseModel]]] = MappingProxyType( + { + # 2024-11-05 + ("notifications/cancelled", "2024-11-05"): v2025.CancelledNotification, + ("notifications/message", "2024-11-05"): v2025.LoggingMessageNotification, + ("notifications/progress", "2024-11-05"): v2025.ProgressNotification, + ("notifications/prompts/list_changed", "2024-11-05"): v2025.PromptListChangedNotification, + ("notifications/resources/list_changed", "2024-11-05"): v2025.ResourceListChangedNotification, + ("notifications/resources/updated", "2024-11-05"): v2025.ResourceUpdatedNotification, + ("notifications/tools/list_changed", "2024-11-05"): v2025.ToolListChangedNotification, + # 2025-03-26 + ("notifications/cancelled", "2025-03-26"): v2025.CancelledNotification, + ("notifications/message", "2025-03-26"): v2025.LoggingMessageNotification, + ("notifications/progress", "2025-03-26"): v2025.ProgressNotification, + ("notifications/prompts/list_changed", "2025-03-26"): v2025.PromptListChangedNotification, + ("notifications/resources/list_changed", "2025-03-26"): v2025.ResourceListChangedNotification, + ("notifications/resources/updated", "2025-03-26"): v2025.ResourceUpdatedNotification, + ("notifications/tools/list_changed", "2025-03-26"): v2025.ToolListChangedNotification, + # 2025-06-18 + ("notifications/cancelled", "2025-06-18"): v2025.CancelledNotification, + ("notifications/message", "2025-06-18"): v2025.LoggingMessageNotification, + ("notifications/progress", "2025-06-18"): v2025.ProgressNotification, + ("notifications/prompts/list_changed", "2025-06-18"): v2025.PromptListChangedNotification, + ("notifications/resources/list_changed", "2025-06-18"): v2025.ResourceListChangedNotification, + ("notifications/resources/updated", "2025-06-18"): v2025.ResourceUpdatedNotification, + ("notifications/tools/list_changed", "2025-06-18"): v2025.ToolListChangedNotification, + # 2025-11-25 (adds elicitation/complete; tasks/status deliberately absent) + ("notifications/cancelled", "2025-11-25"): v2025.CancelledNotification, + ("notifications/elicitation/complete", "2025-11-25"): v2025.ElicitationCompleteNotification, + ("notifications/message", "2025-11-25"): v2025.LoggingMessageNotification, + ("notifications/progress", "2025-11-25"): v2025.ProgressNotification, + ("notifications/prompts/list_changed", "2025-11-25"): v2025.PromptListChangedNotification, + ("notifications/resources/list_changed", "2025-11-25"): v2025.ResourceListChangedNotification, + ("notifications/resources/updated", "2025-11-25"): v2025.ResourceUpdatedNotification, + ("notifications/tools/list_changed", "2025-11-25"): v2025.ToolListChangedNotification, + # 2026-07-28 (adds subscriptions/acknowledged; elicitation/complete removed) + ("notifications/cancelled", "2026-07-28"): v2026.CancelledNotification, + ("notifications/message", "2026-07-28"): v2026.LoggingMessageNotification, + ("notifications/progress", "2026-07-28"): v2026.ProgressNotification, + ("notifications/prompts/list_changed", "2026-07-28"): v2026.PromptListChangedNotification, + ("notifications/resources/list_changed", "2026-07-28"): v2026.ResourceListChangedNotification, + ("notifications/resources/updated", "2026-07-28"): v2026.ResourceUpdatedNotification, + ("notifications/subscriptions/acknowledged", "2026-07-28"): v2026.SubscriptionsAcknowledgedNotification, + ("notifications/tools/list_changed", "2026-07-28"): v2026.ToolListChangedNotification, + } +) + + +# --- Surface maps: results --- + +SERVER_RESULTS: Final[Mapping[tuple[str, str], type[BaseModel] | UnionType]] = MappingProxyType( + { + # 2024-11-05 + ("completion/complete", "2024-11-05"): v2025.CompleteResult, + ("initialize", "2024-11-05"): v2025.InitializeResult, + ("logging/setLevel", "2024-11-05"): v2025.EmptyResult, + ("ping", "2024-11-05"): v2025.EmptyResult, + ("prompts/get", "2024-11-05"): v2025.GetPromptResult, + ("prompts/list", "2024-11-05"): v2025.ListPromptsResult, + ("resources/list", "2024-11-05"): v2025.ListResourcesResult, + ("resources/read", "2024-11-05"): v2025.ReadResourceResult, + ("resources/subscribe", "2024-11-05"): v2025.EmptyResult, + ("resources/templates/list", "2024-11-05"): v2025.ListResourceTemplatesResult, + ("resources/unsubscribe", "2024-11-05"): v2025.EmptyResult, + ("tools/call", "2024-11-05"): v2025.CallToolResult, + ("tools/list", "2024-11-05"): v2025.ListToolsResult, + # 2025-03-26 + ("completion/complete", "2025-03-26"): v2025.CompleteResult, + ("initialize", "2025-03-26"): v2025.InitializeResult, + ("logging/setLevel", "2025-03-26"): v2025.EmptyResult, + ("ping", "2025-03-26"): v2025.EmptyResult, + ("prompts/get", "2025-03-26"): v2025.GetPromptResult, + ("prompts/list", "2025-03-26"): v2025.ListPromptsResult, + ("resources/list", "2025-03-26"): v2025.ListResourcesResult, + ("resources/read", "2025-03-26"): v2025.ReadResourceResult, + ("resources/subscribe", "2025-03-26"): v2025.EmptyResult, + ("resources/templates/list", "2025-03-26"): v2025.ListResourceTemplatesResult, + ("resources/unsubscribe", "2025-03-26"): v2025.EmptyResult, + ("tools/call", "2025-03-26"): v2025.CallToolResult, + ("tools/list", "2025-03-26"): v2025.ListToolsResult, + # 2025-06-18 + ("completion/complete", "2025-06-18"): v2025.CompleteResult, + ("initialize", "2025-06-18"): v2025.InitializeResult, + ("logging/setLevel", "2025-06-18"): v2025.EmptyResult, + ("ping", "2025-06-18"): v2025.EmptyResult, + ("prompts/get", "2025-06-18"): v2025.GetPromptResult, + ("prompts/list", "2025-06-18"): v2025.ListPromptsResult, + ("resources/list", "2025-06-18"): v2025.ListResourcesResult, + ("resources/read", "2025-06-18"): v2025.ReadResourceResult, + ("resources/subscribe", "2025-06-18"): v2025.EmptyResult, + ("resources/templates/list", "2025-06-18"): v2025.ListResourceTemplatesResult, + ("resources/unsubscribe", "2025-06-18"): v2025.EmptyResult, + ("tools/call", "2025-06-18"): v2025.CallToolResult, + ("tools/list", "2025-06-18"): v2025.ListToolsResult, + # 2025-11-25 + ("completion/complete", "2025-11-25"): v2025.CompleteResult, + ("initialize", "2025-11-25"): v2025.InitializeResult, + ("logging/setLevel", "2025-11-25"): v2025.EmptyResult, + ("ping", "2025-11-25"): v2025.EmptyResult, + ("prompts/get", "2025-11-25"): v2025.GetPromptResult, + ("prompts/list", "2025-11-25"): v2025.ListPromptsResult, + ("resources/list", "2025-11-25"): v2025.ListResourcesResult, + ("resources/read", "2025-11-25"): v2025.ReadResourceResult, + ("resources/subscribe", "2025-11-25"): v2025.EmptyResult, + ("resources/templates/list", "2025-11-25"): v2025.ListResourceTemplatesResult, + ("resources/unsubscribe", "2025-11-25"): v2025.EmptyResult, + ("tools/call", "2025-11-25"): v2025.CallToolResult, + ("tools/list", "2025-11-25"): v2025.ListToolsResult, + # 2026-07-28 (dual-result rows use the version's union aliases) + ("completion/complete", "2026-07-28"): v2026.CompleteResult, + ("prompts/get", "2026-07-28"): v2026.AnyGetPromptResult, + ("prompts/list", "2026-07-28"): v2026.ListPromptsResult, + ("resources/list", "2026-07-28"): v2026.ListResourcesResult, + ("resources/read", "2026-07-28"): v2026.AnyReadResourceResult, + ("resources/templates/list", "2026-07-28"): v2026.ListResourceTemplatesResult, + ("server/discover", "2026-07-28"): v2026.DiscoverResult, + ("subscriptions/listen", "2026-07-28"): v2026.SubscriptionsListenResult, + ("tools/call", "2026-07-28"): v2026.AnyCallToolResult, + ("tools/list", "2026-07-28"): v2026.ListToolsResult, + } +) +"""Results servers send, keyed by the originating client request's (method, version).""" + +CLIENT_RESULTS: Final[Mapping[tuple[str, str], type[BaseModel] | UnionType]] = MappingProxyType( + { + # 2024-11-05 + ("ping", "2024-11-05"): v2025.EmptyResult, + ("roots/list", "2024-11-05"): v2025.ListRootsResult, + ("sampling/createMessage", "2024-11-05"): v2025.CreateMessageResult, + # 2025-03-26 + ("ping", "2025-03-26"): v2025.EmptyResult, + ("roots/list", "2025-03-26"): v2025.ListRootsResult, + ("sampling/createMessage", "2025-03-26"): v2025.CreateMessageResult, + # 2025-06-18 + ("elicitation/create", "2025-06-18"): v2025.ElicitResult, + ("ping", "2025-06-18"): v2025.EmptyResult, + ("roots/list", "2025-06-18"): v2025.ListRootsResult, + ("sampling/createMessage", "2025-06-18"): v2025.CreateMessageResult, + # 2025-11-25 + ("elicitation/create", "2025-11-25"): v2025.ElicitResult, + ("ping", "2025-11-25"): v2025.EmptyResult, + ("roots/list", "2025-11-25"): v2025.ListRootsResult, + ("sampling/createMessage", "2025-11-25"): v2025.CreateMessageResult, + # 2026-07-28: none (no server-to-client requests at this version) + } +) +"""Results clients send, keyed by the originating server request's (method, version).""" + + +# --- Direction-specific method sets --- + +SPEC_CLIENT_METHODS: Final[frozenset[str]] = frozenset(m for m, _ in CLIENT_REQUESTS) +"""Spec request methods a client may send (any version); the server-side spec-method discriminator.""" + +SPEC_CLIENT_NOTIFICATION_METHODS: Final[frozenset[str]] = frozenset(m for m, _ in CLIENT_NOTIFICATIONS) +"""Spec notification methods a client may send (any version); the server-side spec-method discriminator.""" + + +# --- Monolith maps --- + +MONOLITH_REQUESTS: Final[Mapping[str, type[types.Request[Any, Any]]]] = MappingProxyType( + { + "completion/complete": types.CompleteRequest, + "elicitation/create": types.ElicitRequest, + "initialize": types.InitializeRequest, + "logging/setLevel": types.SetLevelRequest, + "ping": types.PingRequest, + "prompts/get": types.GetPromptRequest, + "prompts/list": types.ListPromptsRequest, + "resources/list": types.ListResourcesRequest, + "resources/read": types.ReadResourceRequest, + "resources/subscribe": types.SubscribeRequest, + "resources/templates/list": types.ListResourceTemplatesRequest, + "resources/unsubscribe": types.UnsubscribeRequest, + "roots/list": types.ListRootsRequest, + "sampling/createMessage": types.CreateMessageRequest, + "server/discover": types.DiscoverRequest, + "subscriptions/listen": types.SubscriptionsListenRequest, + "tools/call": types.CallToolRequest, + "tools/list": types.ListToolsRequest, + } +) +"""Monolith request model per method, both directions.""" + +MONOLITH_NOTIFICATIONS: Final[Mapping[str, type[types.Notification[Any, Any]]]] = MappingProxyType( + { + "notifications/cancelled": types.CancelledNotification, + "notifications/elicitation/complete": types.ElicitCompleteNotification, + "notifications/initialized": types.InitializedNotification, + "notifications/message": types.LoggingMessageNotification, + "notifications/progress": types.ProgressNotification, + "notifications/prompts/list_changed": types.PromptListChangedNotification, + "notifications/resources/list_changed": types.ResourceListChangedNotification, + "notifications/resources/updated": types.ResourceUpdatedNotification, + "notifications/roots/list_changed": types.RootsListChangedNotification, + "notifications/subscriptions/acknowledged": types.SubscriptionsAcknowledgedNotification, + "notifications/tools/list_changed": types.ToolListChangedNotification, + } +) +"""Monolith notification model per method, both directions.""" + +MONOLITH_RESULTS: Final[Mapping[str, type[types.Result] | UnionType]] = MappingProxyType( + { + "completion/complete": types.CompleteResult, + "elicitation/create": types.ElicitResult, + "initialize": types.InitializeResult, + "logging/setLevel": types.EmptyResult, + "ping": types.EmptyResult, + "prompts/get": types.GetPromptResult | types.InputRequiredResult, + "prompts/list": types.ListPromptsResult, + "resources/list": types.ListResourcesResult, + "resources/read": types.ReadResourceResult | types.InputRequiredResult, + "resources/subscribe": types.EmptyResult, + "resources/templates/list": types.ListResourceTemplatesResult, + "resources/unsubscribe": types.EmptyResult, + "roots/list": types.ListRootsResult, + # Arm order load-bearing: a single-block body satisfies both arms and + # smart-union ties resolve leftmost. Pinned by tests/types/test_methods.py. + "sampling/createMessage": types.CreateMessageResult | types.CreateMessageResultWithTools, + "server/discover": types.DiscoverResult, + "subscriptions/listen": types.SubscriptionsListenResult, + "tools/call": types.CallToolResult | types.InputRequiredResult, + "tools/list": types.ListToolsResult, + } +) +"""Monolith result model (or two-arm union) per request method.""" + + +CacheableMethod = Literal[ + "prompts/list", + "resources/list", + "resources/read", + "resources/templates/list", + "server/discover", + "tools/list", +] +"""Methods whose results carry `ttlMs`/`cacheScope`; hand-written Literal, welded to `CACHEABLE_METHODS` by tests.""" + +CACHEABLE_METHODS: Final[frozenset[str]] = frozenset( + method + for method, row in MONOLITH_RESULTS.items() + if any(issubclass(arm, types.CacheableResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,))) +) +"""Runtime mirror of `CacheableMethod`, derived from `MONOLITH_RESULTS`.""" + +INPUT_REQUIRED_METHODS: Final[frozenset[str]] = frozenset( + method + for method, row in MONOLITH_RESULTS.items() + if any( + issubclass(arm, types.InputRequiredResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,)) + ) +) +"""Methods whose results may be `InputRequiredResult`, derived from `MONOLITH_RESULTS`.""" + + +def is_input_required(result: object) -> TypeGuard[types.InputRequiredResult | dict[str, Any]]: + """True when `result` is an `input_required` interim result, typed or wire-shaped.""" + if isinstance(result, types.InputRequiredResult): + return True + return isinstance(result, Mapping) and cast("Mapping[str, Any]", result).get("resultType") == "input_required" + + +# --- Parse functions --- + +# Envelope stubs merged into bodies for surface validation (surface classes are full frames). +_REQUEST_STUB: Final[Mapping[str, Any]] = MappingProxyType({"jsonrpc": "2.0", "id": 0}) +_NOTIFICATION_STUB: Final[Mapping[str, Any]] = MappingProxyType({"jsonrpc": "2.0"}) + + +def _check_known_version(version: str) -> None: + """Raise ValueError for unknown `version` so a typo cannot silently gate every method.""" + if version not in KNOWN_PROTOCOL_VERSIONS: + raise ValueError(f"version must be a known protocol version, got {version!r}") + + +def _body(method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + """Build a JSON-RPC body, omitting `params` when None.""" + body: dict[str, Any] = {"method": method} + if params is not None: + body["params"] = params + return body + + +@cache +def _adapter(target: type[BaseModel] | UnionType) -> TypeAdapter[Any]: + return TypeAdapter(target) + + +_MonolithT = TypeVar("_MonolithT") + + +def _monolith_row(monolith: Mapping[str, _MonolithT], method: str) -> _MonolithT: + """Look up `method` in `monolith`, raising RuntimeError on miss. + + Not KeyError: the surface row already matched, so a miss is inconsistent + extension maps and must not be caught by the session's `except KeyError` gate. + """ + try: + return monolith[method] + except KeyError: + raise RuntimeError(f"inconsistent extension maps: surface defines {method!r} but monolith does not") from None + + +def validate_client_request( + method: str, + version: str, + params: Mapping[str, Any] | None, + *, + surface: Mapping[tuple[str, str], type[BaseModel]] = CLIENT_REQUESTS, +) -> None: + """Validate a client request against `surface` only. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface` (the version gate). + pydantic.ValidationError: body fails surface validation. + """ + _check_known_version(version) + surface[(method, version)].model_validate({**_REQUEST_STUB, **_body(method, params)}, by_name=False) + + +def parse_client_request( + method: str, + version: str, + params: Mapping[str, Any] | None, + *, + surface: Mapping[tuple[str, str], type[BaseModel]] = CLIENT_REQUESTS, + monolith: Mapping[str, type[types.Request[Any, Any]]] = MONOLITH_REQUESTS, +) -> types.Request[Any, Any]: + """Validate a client request against `surface`, then parse and return its `monolith` model. + + Args: + surface: `(method, version)` to wire-type map; the version-gate lookup + and (per-schema-era) shape check run against this. Pass an extended + map to admit custom methods. + monolith: `method` to version-free model map; the returned instance is + parsed from this row. Must cover every method `surface` admits. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface` (the version gate). + pydantic.ValidationError: body fails surface or monolith validation. + RuntimeError: surface matched but `method` has no monolith row. + """ + validate_client_request(method, version, params, surface=surface) + return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False) + + +def parse_server_request( + method: str, + version: str, + params: Mapping[str, Any] | None, + *, + surface: Mapping[tuple[str, str], type[BaseModel]] = SERVER_REQUESTS, + monolith: Mapping[str, type[types.Request[Any, Any]]] = MONOLITH_REQUESTS, +) -> types.Request[Any, Any]: + """Validate a server request against `surface`, then parse and return its `monolith` model. + + Args: + surface: `(method, version)` to wire-type map; the version-gate lookup + and (per-schema-era) shape check run against this. Pass an extended + map to admit custom methods. + monolith: `method` to version-free model map; the returned instance is + parsed from this row. Must cover every method `surface` admits. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface` (the version gate). + pydantic.ValidationError: body fails surface or monolith validation. + RuntimeError: surface matched but `method` has no monolith row. + """ + _check_known_version(version) + surface_type = surface[(method, version)] + surface_type.model_validate({**_REQUEST_STUB, **_body(method, params)}, by_name=False) + return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False) + + +def validate_client_notification( + method: str, + version: str, + params: Mapping[str, Any] | None, + *, + surface: Mapping[tuple[str, str], type[BaseModel]] = CLIENT_NOTIFICATIONS, +) -> None: + """Validate a client notification against `surface` only. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface`. + pydantic.ValidationError: body fails surface validation. + """ + _check_known_version(version) + surface[(method, version)].model_validate({**_NOTIFICATION_STUB, **_body(method, params)}, by_name=False) + + +def parse_client_notification( + method: str, + version: str, + params: Mapping[str, Any] | None, + *, + surface: Mapping[tuple[str, str], type[BaseModel]] = CLIENT_NOTIFICATIONS, + monolith: Mapping[str, type[types.Notification[Any, Any]]] = MONOLITH_NOTIFICATIONS, +) -> types.Notification[Any, Any]: + """Validate a client notification against `surface`, then parse and return its `monolith` model. + + Args: + surface: `(method, version)` to wire-type map; the version-gate lookup + and (per-schema-era) shape check run against this. Pass an extended + map to admit custom methods. + monolith: `method` to version-free model map; the returned instance is + parsed from this row. Must cover every method `surface` admits. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface`. + pydantic.ValidationError: body fails surface or monolith validation. + RuntimeError: surface matched but `method` has no monolith row. + """ + validate_client_notification(method, version, params, surface=surface) + return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False) + + +def parse_server_notification( + method: str, + version: str, + params: Mapping[str, Any] | None, + *, + surface: Mapping[tuple[str, str], type[BaseModel]] = SERVER_NOTIFICATIONS, + monolith: Mapping[str, type[types.Notification[Any, Any]]] = MONOLITH_NOTIFICATIONS, +) -> types.Notification[Any, Any]: + """Validate a server notification against `surface`, then parse and return its `monolith` model. + + Args: + surface: `(method, version)` to wire-type map; the version-gate lookup + and (per-schema-era) shape check run against this. Pass an extended + map to admit custom methods. + monolith: `method` to version-free model map; the returned instance is + parsed from this row. Must cover every method `surface` admits. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface`. + pydantic.ValidationError: body fails surface or monolith validation. + RuntimeError: surface matched but `method` has no monolith row. + """ + _check_known_version(version) + surface_type = surface[(method, version)] + surface_type.model_validate({**_NOTIFICATION_STUB, **_body(method, params)}, by_name=False) + return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False) + + +def serialize_server_result( + method: str, + version: str, + data: Mapping[str, Any], + *, + surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = SERVER_RESULTS, +) -> dict[str, Any]: + """Validate `data` against `surface` and return its surface-shaped dump. + + The surface model carries `extra="ignore"`, so fields not in `version`'s + schema are dropped from the returned dict. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface`. + pydantic.ValidationError: result fails surface validation. + """ + _check_known_version(version) + adapter = _adapter(surface[(method, version)]) + return adapter.dump_python( + adapter.validate_python(data, by_name=False), by_alias=True, mode="json", exclude_none=True + ) + + +def validate_server_result( + method: str, + version: str, + data: Mapping[str, Any], + *, + surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = SERVER_RESULTS, +) -> None: + """Validate a server result against `surface` only. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface`. + pydantic.ValidationError: result fails surface validation. + """ + serialize_server_result(method, version, data, surface=surface) + + +def parse_server_result( + method: str, + version: str, + data: Mapping[str, Any], + *, + surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = SERVER_RESULTS, + monolith: Mapping[str, type[types.Result] | UnionType] = MONOLITH_RESULTS, +) -> types.Result: + """Validate a server result against `surface`, then parse and return its `monolith` model. + + Args: + surface: `(method, version)` to wire-type map; the version-gate lookup + and (per-schema-era) shape check run against this. Pass an extended + map to admit custom methods. + monolith: `method` to version-free model map; the returned instance is + parsed from this row. Must cover every method `surface` admits. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface`. + pydantic.ValidationError: result fails surface or monolith validation. + RuntimeError: surface matched but `method` has no monolith row. + """ + validate_server_result(method, version, data, surface=surface) + result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False) + return result + + +def validate_client_result( + method: str, + version: str, + data: Mapping[str, Any], + *, + surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = CLIENT_RESULTS, +) -> None: + """Validate a client result against `surface` only. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface`. + pydantic.ValidationError: result fails surface validation. + """ + _check_known_version(version) + _adapter(surface[(method, version)]).validate_python(data, by_name=False) + + +def parse_client_result( + method: str, + version: str, + data: Mapping[str, Any], + *, + surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = CLIENT_RESULTS, + monolith: Mapping[str, type[types.Result] | UnionType] = MONOLITH_RESULTS, +) -> types.Result: + """Validate a client result against `surface`, then parse and return its `monolith` model. + + Args: + surface: `(method, version)` to wire-type map; the version-gate lookup + and (per-schema-era) shape check run against this. Pass an extended + map to admit custom methods. + monolith: `method` to version-free model map; the returned instance is + parsed from this row. Must cover every method `surface` admits. + + Raises: + ValueError: `version` is not a known protocol version. + KeyError: `(method, version)` is not in `surface`. + pydantic.ValidationError: result fails surface or monolith validation. + RuntimeError: surface matched but `method` has no monolith row. + """ + validate_client_result(method, version, data, surface=surface) + result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False) + return result diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/py.typed b/.mcp-venv/lib/python3.13/site-packages/mcp_types/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/mcp_types/version.py b/.mcp-venv/lib/python3.13/site-packages/mcp_types/version.py new file mode 100644 index 0000000000000000000000000000000000000000..e2f7e1ac86e453e3fa7ea04a7626d5c7225695e1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/mcp_types/version.py @@ -0,0 +1,74 @@ +"""Protocol-version registry and comparison helpers. + +Date-string protocol revisions happen to sort lexicographically, but versions +are an enumerated set, not an ordered scalar: future identifiers are not +guaranteed to be date-shaped, and unrecognized peer strings must compare +conservatively instead of accidentally (e.g. "zzz" > "2025-11-25"). All +ordering questions go through KNOWN_PROTOCOL_VERSIONS. +""" + +from typing import Final + +__all__ = [ + "KNOWN_PROTOCOL_VERSIONS", + "HANDSHAKE_PROTOCOL_VERSIONS", + "MODERN_PROTOCOL_VERSIONS", + "SUPPORTED_PROTOCOL_VERSIONS", + "LATEST_PROTOCOL_VERSION", + "LATEST_HANDSHAKE_VERSION", + "LATEST_MODERN_VERSION", + "OLDEST_SUPPORTED_VERSION", + "is_version_at_least", +] + +KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ( + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", + "2026-07-28", +) +"""Every released protocol revision, oldest to newest.""" + +HANDSHAKE_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ( + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", +) +"""Protocol revisions reachable via the initialize handshake.""" + +MODERN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ("2026-07-28",) +"""Protocol revisions that use the stateless per-request envelope.""" + +SUPPORTED_PROTOCOL_VERSIONS: tuple[str, ...] = (*HANDSHAKE_PROTOCOL_VERSIONS, *MODERN_PROTOCOL_VERSIONS) +"""Deprecated: prefer HANDSHAKE_PROTOCOL_VERSIONS or MODERN_PROTOCOL_VERSIONS. + +Kept as the union for v1.x compatibility. +""" + +LATEST_PROTOCOL_VERSION: Final[str] = KNOWN_PROTOCOL_VERSIONS[-1] +"""Newest protocol revision this SDK speaks (any era).""" + +LATEST_HANDSHAKE_VERSION: Final[str] = HANDSHAKE_PROTOCOL_VERSIONS[-1] +"""Newest revision reachable via the ``initialize`` handshake; the client's offer and server's counter-offer default.""" + +LATEST_MODERN_VERSION: Final[str] = MODERN_PROTOCOL_VERSIONS[-1] +"""Newest per-request-envelope revision; the ``server/discover`` probe default.""" + +OLDEST_SUPPORTED_VERSION: Final[str] = HANDSHAKE_PROTOCOL_VERSIONS[0] +"""Oldest revision this SDK still negotiates via the ``initialize`` handshake.""" + + +def is_version_at_least(version: str, minimum: str) -> bool: + """Return True if `version` is a known revision at least as new as `minimum`. + + Unknown `version` strings return False (treat unrecognized peers + conservatively). `minimum` must be a member of KNOWN_PROTOCOL_VERSIONS; + passing anything else is programmer error and raises ValueError. + """ + if minimum not in KNOWN_PROTOCOL_VERSIONS: + raise ValueError(f"minimum must be a known protocol version, got {minimum!r}") + if version not in KNOWN_PROTOCOL_VERSIONS: + return False + return KNOWN_PROTOCOL_VERSIONS.index(version) >= KNOWN_PROTOCOL_VERSIONS.index(minimum) diff --git a/.mcp-venv/lib/python3.13/site-packages/multipart/__init__.py b/.mcp-venv/lib/python3.13/site-packages/multipart/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..53c08d28d0ee974896a3fd4cc26e90d1e6592119 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/multipart/__init__.py @@ -0,0 +1,38 @@ +# Copyright 2012, Andrew Dunham +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This only works if using a file system, other loaders not implemented. + +import importlib.util +import sys +import warnings +from pathlib import Path + +for p in sys.path: + file_path = Path(p, "multipart.py") + try: + if file_path.is_file(): + spec = importlib.util.spec_from_file_location("multipart", file_path) + assert spec is not None, f"{file_path} found but not loadable!" + module = importlib.util.module_from_spec(spec) + sys.modules["multipart"] = module + assert spec.loader is not None, f"{file_path} must be loadable!" + spec.loader.exec_module(module) + break + except PermissionError: + pass +else: + warnings.warn("Please use `import python_multipart` instead.", PendingDeprecationWarning, stacklevel=2) + from python_multipart import * + from python_multipart import __all__, __version__ diff --git a/.mcp-venv/lib/python3.13/site-packages/multipart/decoders.py b/.mcp-venv/lib/python3.13/site-packages/multipart/decoders.py new file mode 100644 index 0000000000000000000000000000000000000000..31acdfbf1d69a0a13dcde2742fdeb6ff04e98e7d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/multipart/decoders.py @@ -0,0 +1 @@ +from python_multipart.decoders import * diff --git a/.mcp-venv/lib/python3.13/site-packages/multipart/exceptions.py b/.mcp-venv/lib/python3.13/site-packages/multipart/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..36815d1980abcf496bf63b0ac93525053111dc79 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/multipart/exceptions.py @@ -0,0 +1 @@ +from python_multipart.exceptions import * diff --git a/.mcp-venv/lib/python3.13/site-packages/multipart/multipart.py b/.mcp-venv/lib/python3.13/site-packages/multipart/multipart.py new file mode 100644 index 0000000000000000000000000000000000000000..7bf567dfc297e9e97c10073b2022efc993e39b93 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/multipart/multipart.py @@ -0,0 +1 @@ +from python_multipart.multipart import * diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1c4dfee13355d8b0850c2df5ddc2a29b5af116c6 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/__init__.py @@ -0,0 +1,47 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +""" +The OpenTelemetry logging API describes the classes used to generate logs and events. + +The :class:`.LoggerProvider` provides users access to the :class:`.Logger`. + +This module provides abstract (i.e. unimplemented) classes required for +logging, and a concrete no-op implementation :class:`.NoOpLogger` that allows applications +to use the API package alone without a supporting implementation. + +To get a logger, you need to provide the package name from which you are +calling the logging APIs to OpenTelemetry by calling `LoggerProvider.get_logger` +with the calling module name and the version of your package. + +The following code shows how to obtain a logger using the global :class:`.LoggerProvider`:: + + from opentelemetry._logs import get_logger + + logger = get_logger("example-logger") + +.. versionadded:: 1.15.0 +""" + +from opentelemetry._logs._internal import ( + Logger, + LoggerProvider, + LogRecord, + NoOpLogger, + NoOpLoggerProvider, + get_logger, + get_logger_provider, + set_logger_provider, +) +from opentelemetry._logs.severity import SeverityNumber + +__all__ = [ + "Logger", + "LoggerProvider", + "LogRecord", + "NoOpLogger", + "NoOpLoggerProvider", + "get_logger", + "get_logger_provider", + "set_logger_provider", + "SeverityNumber", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/_internal/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/_internal/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2319a461c9b439d7cee5c7f2e9d11e8e2b320238 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/_internal/__init__.py @@ -0,0 +1,447 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +""" +The OpenTelemetry logging API describes the classes used to generate logs and events. + +The :class:`.LoggerProvider` provides users access to the :class:`.Logger`. + +This module provides abstract (i.e. unimplemented) classes required for +logging, and a concrete no-op implementation :class:`.NoOpLogger` that allows applications +to use the API package alone without a supporting implementation. + +To get a logger, you need to provide the package name from which you are +calling the logging APIs to OpenTelemetry by calling `LoggerProvider.get_logger` +with the calling module name and the version of your package. + +The following code shows how to obtain a logger using the global :class:`.LoggerProvider`:: + + from opentelemetry._logs import get_logger + + logger = get_logger("example-logger") + +.. versionadded:: 1.15.0 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from logging import getLogger +from os import environ +from time import time_ns +from typing import cast, overload + +from typing_extensions import deprecated + +from opentelemetry._logs.severity import SeverityNumber +from opentelemetry.context import get_current +from opentelemetry.context.context import Context +from opentelemetry.environment_variables import _OTEL_PYTHON_LOGGER_PROVIDER +from opentelemetry.trace import get_current_span +from opentelemetry.trace.span import TraceFlags +from opentelemetry.util._once import Once +from opentelemetry.util._providers import _load_provider +from opentelemetry.util.types import AnyValue, _ExtendedAttributes + +_logger = getLogger(__name__) + + +class LogRecord(ABC): + """A LogRecord instance represents an event being logged. + + LogRecord instances are created and emitted via `Logger` + every time something is logged. They contain all the information + pertinent to the event being logged. + """ + + @overload + def __init__( + self, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context: Context | None = None, + severity_text: str | None = None, + severity_number: SeverityNumber | None = None, + body: AnyValue = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, + ) -> None: ... + + @overload + @deprecated( + "LogRecord init with `trace_id`, `span_id`, and/or `trace_flags` is deprecated since 1.35.0. Use `context` instead." + ) + def __init__( + self, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + trace_id: int | None = None, + span_id: int | None = None, + trace_flags: TraceFlags | None = None, + severity_text: str | None = None, + severity_number: SeverityNumber | None = None, + body: AnyValue = None, + attributes: _ExtendedAttributes | None = None, + ) -> None: ... + + def __init__( + self, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context: Context | None = None, + trace_id: int | None = None, + span_id: int | None = None, + trace_flags: TraceFlags | None = None, + severity_text: str | None = None, + severity_number: SeverityNumber | None = None, + body: AnyValue = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, + ) -> None: + if not context: + context = get_current() + span_context = get_current_span(context).get_span_context() + self.timestamp = timestamp + if observed_timestamp is None: + observed_timestamp = time_ns() + self.observed_timestamp = observed_timestamp + self.context = context + self.trace_id = trace_id or span_context.trace_id + self.span_id = span_id or span_context.span_id + self.trace_flags = trace_flags or span_context.trace_flags + self.severity_text = severity_text + self.severity_number = severity_number + self.body = body + self.attributes = attributes + self.event_name = event_name + self.exception = exception + + +class Logger(ABC): + """Handles emitting events and logs via `LogRecord`.""" + + def __init__( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + attributes: _ExtendedAttributes | None = None, + ) -> None: + super().__init__() + self._name = name + self._version = version + self._schema_url = schema_url + self._attributes = attributes + + @overload + def emit( + self, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + severity_text: str | None = None, + body: AnyValue | None = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, + ) -> None: ... + + @overload + def emit( + self, + record: LogRecord, + ) -> None: ... + + @abstractmethod + def emit( + self, + record: LogRecord | None = None, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + severity_text: str | None = None, + body: AnyValue | None = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, + ) -> None: + """Emits a :class:`LogRecord` representing a log to the processing pipeline.""" + + +class NoOpLogger(Logger): + """The default Logger used when no Logger implementation is available. + + All operations are no-op. + """ + + @overload + def emit( + self, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + severity_text: str | None = None, + body: AnyValue | None = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, + ) -> None: ... + + @overload + def emit( # pylint:disable=arguments-differ + self, + record: LogRecord, + ) -> None: ... + + def emit( + self, + record: LogRecord | None = None, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + severity_text: str | None = None, + body: AnyValue | None = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, + ) -> None: + pass + + +class ProxyLogger(Logger): + def __init__( # pylint: disable=super-init-not-called + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + attributes: _ExtendedAttributes | None = None, + ): + self._name = name + self._version = version + self._schema_url = schema_url + self._attributes = attributes + self._real_logger: Logger | None = None + self._noop_logger = NoOpLogger(name) + + @property + def _logger(self) -> Logger: + if self._real_logger: + return self._real_logger + + if _LOGGER_PROVIDER: + self._real_logger = _LOGGER_PROVIDER.get_logger( + self._name, + self._version, + self._schema_url, + self._attributes, + ) + return self._real_logger + return self._noop_logger + + @overload + def emit( + self, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + severity_text: str | None = None, + body: AnyValue | None = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, + ) -> None: ... + + @overload + def emit( # pylint:disable=arguments-differ + self, + record: LogRecord, + ) -> None: ... + + def emit( + self, + record: LogRecord | None = None, + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + severity_text: str | None = None, + body: AnyValue | None = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, + ) -> None: + if record: + self._logger.emit(record) + else: + self._logger.emit( + timestamp=timestamp, + observed_timestamp=observed_timestamp, + context=context, + severity_number=severity_number, + severity_text=severity_text, + body=body, + attributes=attributes, + event_name=event_name, + exception=exception, + ) + + +class LoggerProvider(ABC): + """ + LoggerProvider is the entry point of the API. It provides access to Logger instances. + """ + + @abstractmethod + def get_logger( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + attributes: _ExtendedAttributes | None = None, + ) -> Logger: + """Returns a `Logger` for use by the given instrumentation library. + + For any two calls with identical parameters, it is undefined whether the same + or different `Logger` instances are returned. + + This function may return different `Logger` types (e.g. a no-op logger + vs. a functional logger). + + Args: + name: The name of the instrumenting module, package or class. + This should *not* be the name of the module, package or class that is + instrumented but the name of the code doing the instrumentation. + E.g., instead of ``"requests"``, use + ``"opentelemetry.instrumentation.requests"``. + + For log sources which define a logger name (e.g. logging.Logger.name) + the Logger Name should be recorded as the instrumentation scope name. + + version: Optional. The version string of the + instrumenting library. Usually this should be the same as + ``importlib.metadata.version(instrumenting_library_name)``. + + schema_url: Optional. Specifies the Schema URL of the emitted telemetry. + + attributes: Optional. Specifies the instrumentation scope attributes to + associate with emitted telemetry. + """ + + +class NoOpLoggerProvider(LoggerProvider): + """The default LoggerProvider used when no LoggerProvider implementation is available.""" + + def get_logger( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + attributes: _ExtendedAttributes | None = None, + ) -> Logger: + """Returns a NoOpLogger.""" + return NoOpLogger( + name, version=version, schema_url=schema_url, attributes=attributes + ) + + +class ProxyLoggerProvider(LoggerProvider): + def get_logger( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + attributes: _ExtendedAttributes | None = None, + ) -> Logger: + if _LOGGER_PROVIDER: + return _LOGGER_PROVIDER.get_logger( + name, + version=version, + schema_url=schema_url, + attributes=attributes, + ) + return ProxyLogger( + name, + version=version, + schema_url=schema_url, + attributes=attributes, + ) + + +_LOGGER_PROVIDER_SET_ONCE = Once() +_LOGGER_PROVIDER: LoggerProvider | None = None +_PROXY_LOGGER_PROVIDER = ProxyLoggerProvider() + + +def get_logger_provider() -> LoggerProvider: + """Gets the current global :class:`~.LoggerProvider` object.""" + global _LOGGER_PROVIDER # pylint: disable=global-variable-not-assigned + if _LOGGER_PROVIDER is None: + if _OTEL_PYTHON_LOGGER_PROVIDER not in environ: + return _PROXY_LOGGER_PROVIDER + + logger_provider: LoggerProvider = _load_provider( # type: ignore + _OTEL_PYTHON_LOGGER_PROVIDER, "logger_provider" + ) + _set_logger_provider(logger_provider, log=False) + + # _LOGGER_PROVIDER will have been set by one thread + return cast("LoggerProvider", _LOGGER_PROVIDER) + + +def _set_logger_provider(logger_provider: LoggerProvider, log: bool) -> None: + def set_lp() -> None: + global _LOGGER_PROVIDER # pylint: disable=global-statement + _LOGGER_PROVIDER = logger_provider + + did_set = _LOGGER_PROVIDER_SET_ONCE.do_once(set_lp) + + if log and not did_set: + _logger.warning("Overriding of current LoggerProvider is not allowed") + + +def set_logger_provider(logger_provider: LoggerProvider) -> None: + """Sets the current global :class:`~.LoggerProvider` object. + + This can only be done once, a warning will be logged if any further attempt + is made. + """ + _set_logger_provider(logger_provider, log=True) + + +def get_logger( + instrumenting_module_name: str, + instrumenting_library_version: str = "", + logger_provider: LoggerProvider | None = None, + schema_url: str | None = None, + attributes: _ExtendedAttributes | None = None, +) -> Logger: + """Returns a `Logger` for use within a python process. + + This function is a convenience wrapper for + opentelemetry.sdk._logs.LoggerProvider.get_logger. + + If logger_provider param is omitted the current configured one is used. + """ + if logger_provider is None: + logger_provider = get_logger_provider() + return logger_provider.get_logger( + instrumenting_module_name, + instrumenting_library_version, + schema_url, + attributes, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/severity/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/severity/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..96ffcc6229e85fca60f7bb6cd38600f3952d354c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/_logs/severity/__init__.py @@ -0,0 +1,44 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import enum + + +class SeverityNumber(enum.Enum): + """Numerical value of severity. + + Smaller numerical values correspond to less severe events + (such as debug events), larger numerical values correspond + to more severe events (such as errors and critical events). + + See the `Log Data Model`_ spec for more info and how to map the + severity from source format to OTLP Model. + + .. _Log Data Model: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#field-severitynumber + """ + + UNSPECIFIED = 0 + TRACE = 1 + TRACE2 = 2 + TRACE3 = 3 + TRACE4 = 4 + DEBUG = 5 + DEBUG2 = 6 + DEBUG3 = 7 + DEBUG4 = 8 + INFO = 9 + INFO2 = 10 + INFO3 = 11 + INFO4 = 12 + WARN = 13 + WARN2 = 14 + WARN3 = 15 + WARN4 = 16 + ERROR = 17 + ERROR2 = 18 + ERROR3 = 19 + ERROR4 = 20 + FATAL = 21 + FATAL2 = 22 + FATAL3 = 23 + FATAL4 = 24 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/attributes/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/attributes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9c1b83b91e6c48305332bef52ec6c4cc45669f80 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/attributes/__init__.py @@ -0,0 +1,355 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import copy +import logging +import threading +from collections import OrderedDict +from collections.abc import Mapping, MutableMapping, Sequence + +from opentelemetry.util import types + +# bytes are accepted as a user supplied value for attributes but +# decoded to strings internally. +_VALID_ATTR_VALUE_TYPES = (bool, str, bytes, int, float) +# AnyValue possible values +_VALID_ANY_VALUE_TYPES = ( + type(None), + bool, + bytes, + int, + float, + str, + Sequence, + Mapping, +) + + +# TODO: Remove this workaround and revert to the simpler implementation +# once Python 3.9 support is dropped (planned around May 2026). +# This exists only to avoid issues caused by deprecated behavior in 3.9. +def _type_name(t): + return getattr(t, "__name__", getattr(t, "_name", repr(t))) + + +_logger = logging.getLogger(__name__) + + +# pylint: disable=too-many-return-statements +# pylint: disable=too-many-branches +def _clean_attribute( + key: str, value: types.AttributeValue, max_len: int | None +) -> types.AttributeValue | tuple[str | int | float, ...] | None: + """Checks if attribute value is valid and cleans it if required. + + The function returns the cleaned value or None if the value is not valid. + + An attribute value is valid if it is either: + - A primitive type: string, boolean, double precision floating + point (IEEE 754-1985) or integer. + - An array of primitive type values. The array MUST be homogeneous, + i.e. it MUST NOT contain values of different types. + + An attribute needs cleansing if: + - Its length is greater than the maximum allowed length. + - It needs to be encoded/decoded e.g, bytes to strings. + """ + + if not (key and isinstance(key, str)): + _logger.warning("invalid key `%s`. must be non-empty string.", key) + return None + + if isinstance(value, _VALID_ATTR_VALUE_TYPES): + if isinstance(value, bytes): + try: + value = value.decode() + except UnicodeDecodeError: + _logger.warning("Byte attribute could not be decoded.") + return None + if max_len is not None and isinstance(value, str): + value = value[:max_len] + return value + + if isinstance(value, Sequence): + sequence_first_valid_type = None + cleaned_seq = [] + + for element in value: + if isinstance(element, bytes): + try: + element = element.decode() + except UnicodeDecodeError: + _logger.warning("Byte attribute could not be decoded.") + cleaned_seq.append(None) + continue + if max_len is not None and isinstance(element, str): + element = element[:max_len] + elif element is None: + cleaned_seq.append(None) + continue + + element_type = type(element) + # Reject attribute value if sequence contains a value with an incompatible type. + if element_type not in _VALID_ATTR_VALUE_TYPES: + _logger.warning( + "Invalid type %s in attribute '%s' value sequence. Expected one of " + "%s or None", + element_type.__name__, + key, + [ + valid_type.__name__ + for valid_type in _VALID_ATTR_VALUE_TYPES + ], + ) + return None + + # The type of the sequence must be homogeneous. The first non-None + # element determines the type of the sequence + if sequence_first_valid_type is None: + sequence_first_valid_type = element_type + # use equality instead of isinstance as isinstance(True, int) evaluates to True + elif element_type != sequence_first_valid_type: + _logger.warning( + "Attribute %r mixes types %s and %s in attribute value sequence", + key, + sequence_first_valid_type.__name__, + type(element).__name__, + ) + return None + + cleaned_seq.append(element) + + # Freeze mutable sequences defensively + return tuple(cleaned_seq) + + _logger.warning( + "Invalid type %s for attribute '%s' value. Expected one of %s or a " + "sequence of those types", + type(value).__name__, + key, + [valid_type.__name__ for valid_type in _VALID_ATTR_VALUE_TYPES], + ) + return None + + +def _clean_extended_attribute_value( # pylint: disable=too-many-branches + value: types.AnyValue, max_len: int | None +) -> types.AnyValue: + # for primitive types just return the value and eventually shorten the string length + if value is None or isinstance(value, _VALID_ATTR_VALUE_TYPES): + if max_len is not None and isinstance(value, str): + value = value[:max_len] + return value + + if isinstance(value, Mapping): + cleaned_dict: dict[str, types.AnyValue] = {} + for key, element in value.items(): + # skip invalid keys + if not (key and isinstance(key, str)): + _logger.warning( + "invalid key `%s`. must be non-empty string.", key + ) + continue + + cleaned_dict[key] = _clean_extended_attribute( + key=key, value=element, max_len=max_len + ) + + return cleaned_dict + + if isinstance(value, Sequence): + sequence_first_valid_type = None + cleaned_seq: list[types.AnyValue] = [] + + for element in value: + if element is None: + cleaned_seq.append(element) + continue + + if max_len is not None and isinstance(element, str): + element = element[:max_len] + + element_type = type(element) + if element_type not in _VALID_ATTR_VALUE_TYPES: + element = _clean_extended_attribute_value( + element, max_len=max_len + ) + element_type = type(element) # type: ignore + + # The type of the sequence must be homogeneous. The first non-None + # element determines the type of the sequence + if sequence_first_valid_type is None: + sequence_first_valid_type = element_type + # use equality instead of isinstance as isinstance(True, int) evaluates to True + elif element_type != sequence_first_valid_type: + _logger.warning( + "Mixed types %s and %s in attribute value sequence", + sequence_first_valid_type.__name__, + type(element).__name__, + ) + return None + + cleaned_seq.append(element) + + # Freeze mutable sequences defensively + return tuple(cleaned_seq) + + # Some applications such as Django add values to log records whose types fall outside the + # primitive types and `_VALID_ANY_VALUE_TYPES`, i.e., they are not of type `AnyValue`. + # Rather than attempt to whitelist every possible instrumentation, we stringify those values here + # so they can still be represented as attributes, falling back to the original TypeError only if + # converting to string raises. + try: + return str(value) + except Exception: + raise TypeError( + f"Invalid type {type(value).__name__} for attribute value. " + f"Expected one of {[_type_name(valid_type) for valid_type in _VALID_ANY_VALUE_TYPES]} or a " + "sequence of those types", + ) + + +def _clean_extended_attribute( + key: str, value: types.AnyValue, max_len: int | None +) -> types.AnyValue: + """Checks if attribute value is valid and cleans it if required. + + The function returns the cleaned value or None if the value is not valid. + + An attribute value is valid if it is an AnyValue. + An attribute needs cleansing if: + - Its length is greater than the maximum allowed length. + """ + + if not (key and isinstance(key, str)): + _logger.warning("invalid key `%s`. must be non-empty string.", key) + return None + + try: + return _clean_extended_attribute_value(value, max_len=max_len) + except TypeError as exception: + _logger.warning("Attribute %s: %s", key, exception) + return None + + +class BoundedAttributes(MutableMapping): # type: ignore + """An ordered dict with a fixed max capacity. + + Oldest elements are dropped when the dict is full and a new element is + added. + """ + + def __init__( + self, + maxlen: int | None = None, + attributes: types._ExtendedAttributes | None = None, + immutable: bool = True, + max_value_len: int | None = None, + extended_attributes: bool = False, + ): + if maxlen is not None: + if not isinstance(maxlen, int) or maxlen < 0: + raise ValueError( + "maxlen must be valid int greater or equal to 0" + ) + self.maxlen = maxlen + self.dropped = 0 + self.max_value_len = max_value_len + self._extended_attributes = extended_attributes + # OrderedDict is not used until the maxlen is reached for efficiency. + + self._dict: ( + MutableMapping[str, types.AnyValue] + | OrderedDict[str, types.AnyValue] + ) = {} + self._lock = threading.Lock() + if attributes: + for key, value in attributes.items(): + self[key] = value + self._immutable = immutable + + def __repr__(self) -> str: + return f"{dict(self._dict)}" + + def __getitem__(self, key: str) -> types.AnyValue: + return self._dict[key] + + def __setitem__(self, key: str, value: types.AnyValue) -> None: + if getattr(self, "_immutable", False): # type: ignore + raise TypeError + if self.maxlen is not None and self.maxlen == 0: + with self._lock: + self.dropped += 1 + return + if self._extended_attributes: + value = _clean_extended_attribute(key, value, self.max_value_len) + else: + value = _clean_attribute(key, value, self.max_value_len) # type: ignore + if value is None: + return + with self._lock: + self._setitem_locked(key, value) + + def _set_items(self, attributes: "types._ExtendedAttributes") -> None: + if getattr(self, "_immutable", False): # type: ignore + raise TypeError + if self.maxlen is not None and self.maxlen == 0: + with self._lock: + self.dropped += len(attributes) + return + cleaned = [] + for key, value in attributes.items(): + if self._extended_attributes: + cv = _clean_extended_attribute(key, value, self.max_value_len) + else: + cv = _clean_attribute(key, value, self.max_value_len) # type: ignore + if cv is None: + continue + cleaned.append((key, cv)) + with self._lock: + for key, cv in cleaned: + self._setitem_locked(key, cv) + + def _setitem_locked(self, key: str, value: types.AnyValue) -> None: + if key in self._dict: + del self._dict[key] + elif self.maxlen is not None and len(self._dict) == self.maxlen: + if not isinstance(self._dict, OrderedDict): + self._dict = OrderedDict(self._dict) + self._dict.popitem(last=False) # type: ignore + self.dropped += 1 + + self._dict[key] = value # type: ignore + + def __delitem__(self, key: str) -> None: + if getattr(self, "_immutable", False): # type: ignore + raise TypeError + with self._lock: + del self._dict[key] + + def __iter__(self): + if self._immutable: + return iter(self._dict) + with self._lock: + return iter(list(self._dict)) + + def __len__(self) -> int: + return len(self._dict) + + def __deepcopy__(self, memo: dict) -> "BoundedAttributes": + copy_ = BoundedAttributes( + maxlen=self.maxlen, + immutable=self._immutable, + max_value_len=self.max_value_len, + extended_attributes=self._extended_attributes, + ) + memo[id(self)] = copy_ + with self._lock: + # Assign _dict directly to avoid re-cleaning already clean values + # and to bypass the immutability guard in __setitem__ + copy_._dict = copy.deepcopy(self._dict, memo) + copy_.dropped = self.dropped + return copy_ + + def copy(self): # type: ignore + return self._dict.copy() # type: ignore diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/attributes/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/attributes/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/baggage/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/baggage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b1b36a990dfded9e62521bc8bca8a00aa23e2555 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/baggage/__init__.py @@ -0,0 +1,123 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Mapping +from logging import getLogger +from re import compile +from types import MappingProxyType + +from opentelemetry.context import create_key, get_value, set_value +from opentelemetry.context.context import Context +from opentelemetry.util.re import ( + _BAGGAGE_PROPERTY_FORMAT, + _KEY_FORMAT, + _VALUE_FORMAT, +) + +_BAGGAGE_KEY = create_key("baggage") +_logger = getLogger(__name__) + +_KEY_PATTERN = compile(_KEY_FORMAT) +_VALUE_PATTERN = compile(_VALUE_FORMAT) +_PROPERT_PATTERN = compile(_BAGGAGE_PROPERTY_FORMAT) + + +def get_all( + context: Context | None = None, +) -> Mapping[str, object]: + """Returns the name/value pairs in the Baggage + + Args: + context: The Context to use. If not set, uses current Context + + Returns: + The name/value pairs in the Baggage + """ + return MappingProxyType(_get_baggage_value(context=context)) + + +def get_baggage(name: str, context: Context | None = None) -> object | None: + """Provides access to the value for a name/value pair in the + Baggage + + Args: + name: The name of the value to retrieve + context: The Context to use. If not set, uses current Context + + Returns: + The value associated with the given name, or null if the given name is + not present. + """ + return _get_baggage_value(context=context).get(name) + + +def set_baggage( + name: str, value: object, context: Context | None = None +) -> Context: + """Sets a value in the Baggage + + Args: + name: The name of the value to set + value: The value to set + context: The Context to use. If not set, uses current Context + + Returns: + A Context with the value updated + """ + baggage = _get_baggage_value(context=context).copy() + baggage[name] = value + return set_value(_BAGGAGE_KEY, baggage, context=context) + + +def remove_baggage(name: str, context: Context | None = None) -> Context: + """Removes a value from the Baggage + + Args: + name: The name of the value to remove + context: The Context to use. If not set, uses current Context + + Returns: + A Context with the name/value removed + """ + baggage = _get_baggage_value(context=context).copy() + baggage.pop(name, None) + + return set_value(_BAGGAGE_KEY, baggage, context=context) + + +def clear(context: Context | None = None) -> Context: + """Removes all values from the Baggage + + Args: + context: The Context to use. If not set, uses current Context + + Returns: + A Context with all baggage entries removed + """ + return set_value(_BAGGAGE_KEY, {}, context=context) + + +def _get_baggage_value(context: Context | None = None) -> dict[str, object]: + baggage = get_value(_BAGGAGE_KEY, context=context) + if isinstance(baggage, dict): + return baggage + return {} + + +def _is_valid_key(name: str) -> bool: + return _KEY_PATTERN.fullmatch(str(name)) is not None + + +def _is_valid_value(value: object) -> bool: + parts = str(value).split(";") + is_valid_value = _VALUE_PATTERN.fullmatch(parts[0]) is not None + if len(parts) > 1: # one or more properties metadata + for property in parts[1:]: + if _PROPERT_PATTERN.fullmatch(property) is None: + is_valid_value = False + break + return is_valid_value + + +def _is_valid_pair(key: str, value: str) -> bool: + return _is_valid_key(key) and _is_valid_value(value) diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/baggage/propagation/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/baggage/propagation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1d873c5f3cda1a6333a7eb6cafd3474c01ede243 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/baggage/propagation/__init__.py @@ -0,0 +1,185 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# +from collections.abc import Iterable, Iterator, Mapping +from logging import getLogger +from re import split +from urllib.parse import quote_plus, unquote_plus + +from opentelemetry.baggage import _is_valid_pair, get_all, set_baggage +from opentelemetry.context import get_current +from opentelemetry.context.context import Context +from opentelemetry.propagators import textmap +from opentelemetry.util.re import _DELIMITER_PATTERN + +_logger = getLogger(__name__) + + +def _filter_valid_entries( + entries: Iterable[str], + max_pair_length: int, +) -> Iterator[str]: + for entry in entries: + if not entry: + continue + if not entry.isascii(): + _logger.warning( + "Baggage entry with key `%s` contains non-ASCII characters", + entry.split("=", 1)[0], + ) + continue + if len(entry) > max_pair_length: + _logger.warning( + "Baggage entry with key `%s` exceeded the maximum number of bytes per list-member with length %d", + entry.split("=", 1)[0], + len(entry), + ) + continue + yield entry + + +def _apply_baggage_limits( + entries: Iterable[str], + max_pairs: int, + max_pair_length: int, + max_header_length: int, +) -> Iterator[str]: + """Apply W3C Baggage size limits to a sequence of baggage entries. + + Yields entries that fit within the W3C specification limits. + Logs warnings when entries are dropped. + """ + length = 0 + for index, entry in enumerate( + _filter_valid_entries(entries, max_pair_length) + ): + if index >= max_pairs: + _logger.warning( + "Baggage exceeded the maximum number of list-members" + ) + return + + length += (1 if index > 0 else 0) + len(entry) + if length > max_header_length: + _logger.warning( + "Baggage exceeded the maximum number of bytes per baggage-string" + ) + return + yield entry + + +class W3CBaggagePropagator(textmap.TextMapPropagator): + """Extracts and injects Baggage which is used to annotate telemetry.""" + + _MAX_HEADER_LENGTH = 8192 + _MAX_PAIR_LENGTH = 4096 + _MAX_PAIRS = 180 + _BAGGAGE_HEADER_NAME = "baggage" + + def extract( + self, + carrier: textmap.CarrierT, + context: Context | None = None, + getter: textmap.Getter[textmap.CarrierT] = textmap.default_getter, + ) -> Context: + """Extract Baggage from the carrier. + + See + `opentelemetry.propagators.textmap.TextMapPropagator.extract` + """ + + if context is None: + context = get_current() + + header = _extract_first_element( + getter.get(carrier, self._BAGGAGE_HEADER_NAME) + ) + + if not header: + return context + + if len(header.encode()) > self._MAX_HEADER_LENGTH: + _logger.warning( + "Baggage header `%s` exceeded the maximum number of bytes per baggage-string", + header, + ) + return context + + baggage_entries = split(_DELIMITER_PATTERN, header) + + for entry in _apply_baggage_limits( + baggage_entries, + max_pairs=self._MAX_PAIRS, + max_pair_length=self._MAX_PAIR_LENGTH, + max_header_length=self._MAX_HEADER_LENGTH, + ): + try: + name, value = entry.split("=", 1) + except Exception: # pylint: disable=broad-exception-caught + _logger.warning( + "Baggage list-member `%s` doesn't match the format", entry + ) + continue + + if not _is_valid_pair(name, value): + _logger.warning("Invalid baggage entry: `%s`", entry) + continue + + name = unquote_plus(name).strip() + value = unquote_plus(value).strip() + + context = set_baggage( + name, + value, + context=context, + ) + + return context + + def inject( + self, + carrier: textmap.CarrierT, + context: Context | None = None, + setter: textmap.Setter[textmap.CarrierT] = textmap.default_setter, + ) -> None: + """Injects Baggage into the carrier. + + See + `opentelemetry.propagators.textmap.TextMapPropagator.inject` + """ + baggage_entries = get_all(context=context) + if not baggage_entries: + return + + baggage_string = ",".join( + _apply_baggage_limits( + _encode_baggage_pairs(baggage_entries), + max_pairs=self._MAX_PAIRS, + max_pair_length=self._MAX_PAIR_LENGTH, + max_header_length=self._MAX_HEADER_LENGTH, + ) + ) + + if baggage_string: + setter.set(carrier, self._BAGGAGE_HEADER_NAME, baggage_string) + + @property + def fields(self) -> set[str]: + """Returns a set with the fields set in `inject`.""" + return {self._BAGGAGE_HEADER_NAME} + + +def _encode_baggage_pairs( + baggage_entries: Mapping[str, object], +) -> Iterator[str]: + """Yield URL-encoded 'key=value' pairs from baggage entries.""" + for key, value in baggage_entries.items(): + yield quote_plus(str(key)) + "=" + quote_plus(str(value)) + + +def _extract_first_element( + items: Iterable[textmap.CarrierT] | None, +) -> textmap.CarrierT | None: + if items is None: + return None + return next(iter(items), None) diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/baggage/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/baggage/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..80174f66b5ecf6e88cdd3f26d12ef8bf725943a2 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/__init__.py @@ -0,0 +1,157 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +import os +from contextvars import Token +from uuid import uuid4 + +# pylint: disable=wrong-import-position +from opentelemetry.context.context import Context, _RuntimeContext # noqa +from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext +from opentelemetry.environment_variables import OTEL_PYTHON_CONTEXT + +logger = logging.getLogger(__name__) + + +def _load_runtime_context() -> _RuntimeContext: + """Initialize the RuntimeContext + + Returns: + An instance of RuntimeContext. + """ + configured_context = os.environ.get(OTEL_PYTHON_CONTEXT) + if not configured_context: + return ContextVarsRuntimeContext() + + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.util._importlib_metadata import ( # noqa: PLC0415 + entry_points, + ) + + try: + return next( + iter( + entry_points( + group="opentelemetry_context", name=configured_context + ) + ) + ).load()() + except Exception: # pylint: disable=broad-exception-caught + logger.exception( + "Failed to load context: %s, falling back to contextvars_context", + configured_context, + ) + return ContextVarsRuntimeContext() + + +_RUNTIME_CONTEXT = _load_runtime_context() + + +def create_key(keyname: str) -> str: + """To allow cross-cutting concern to control access to their local state, + the RuntimeContext API provides a function which takes a keyname as input, + and returns a unique key. + Args: + keyname: The key name is for debugging purposes and is not required to be unique. + Returns: + A unique string representing the newly created key. + """ + return keyname + "-" + str(uuid4()) + + +def get_value(key: str, context: Context | None = None) -> object: + """To access the local state of a concern, the RuntimeContext API + provides a function which takes a context and a key as input, + and returns a value. + + Args: + key: The key of the value to retrieve. + context: The context from which to retrieve the value, if None, the current context is used. + + Returns: + The value associated with the key. + """ + return context.get(key) if context is not None else get_current().get(key) + + +def set_value( + key: str, value: object, context: Context | None = None +) -> Context: + """To record the local state of a cross-cutting concern, the + RuntimeContext API provides a function which takes a context, a + key, and a value as input, and returns an updated context + which contains the new value. + + Args: + key: The key of the entry to set. + value: The value of the entry to set. + context: The context to copy, if None, the current context is used. + + Returns: + A new `Context` containing the value set. + """ + if context is None: + context = get_current() + new_values = context.copy() + new_values[key] = value + return Context(new_values) + + +def get_current() -> Context: + """To access the context associated with program execution, + the Context API provides a function which takes no arguments + and returns a Context. + + Returns: + The current `Context` object. + """ + return _RUNTIME_CONTEXT.get_current() + + +def attach(context: Context) -> Token[Context]: + """Associates a Context with the caller's current execution unit. Returns + a token that can be used to restore the previous Context. + + Args: + context: The Context to set as current. + + Returns: + A token that can be used with `detach` to reset the context. + """ + return _RUNTIME_CONTEXT.attach(context) + + +def detach(token: Token[Context]) -> None: + """Resets the Context associated with the caller's current execution unit + to the value it had before attaching a specified Context. + + Args: + token: The Token that was returned by a previous call to attach a Context. + """ + try: + _RUNTIME_CONTEXT.detach(token) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to detach context") + + +# FIXME This is a temporary location for the suppress instrumentation key. +# Once the decision around how to suppress instrumentation is made in the +# spec, this key should be moved accordingly. +_ON_EMIT_RECURSION_COUNT_KEY = create_key("on_emit_recursion_count") +_SUPPRESS_INSTRUMENTATION_KEY = create_key("suppress_instrumentation") +_SUPPRESS_HTTP_INSTRUMENTATION_KEY = create_key( + "suppress_http_instrumentation" +) + +__all__ = [ + "Context", + "attach", + "create_key", + "detach", + "get_current", + "get_value", + "set_value", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/context.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/context.py new file mode 100644 index 0000000000000000000000000000000000000000..984bf424d84782fdeab501367c9b34b73392c250 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/context.py @@ -0,0 +1,65 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from abc import ABC, abstractmethod +from contextvars import Token + + +class Context(dict[str, object]): + def __setitem__(self, key: str, value: object) -> None: + raise ValueError + + def __delitem__(self, key: str) -> None: + raise ValueError + + def setdefault(self, key: str, default: object = None) -> object: + raise ValueError + + def pop(self, key: str, *args: object) -> object: + raise ValueError + + def popitem(self) -> tuple[str, object]: + raise ValueError + + def clear(self) -> None: + raise ValueError + + def update(self, *args: object, **kwargs: object) -> None: + raise ValueError + + def __ior__(self, other: object) -> Context: + raise ValueError + + +class _RuntimeContext(ABC): + """The RuntimeContext interface provides a wrapper for the different + mechanisms that are used to propagate context in Python. + Implementations can be made available via entry_points and + selected through environment variables. + """ + + @abstractmethod + def attach(self, context: Context) -> Token[Context]: + """Sets the current `Context` object. Returns a + token that can be used to reset to the previous `Context`. + + Args: + context: The Context to set. + """ + + @abstractmethod + def get_current(self) -> Context: + """Returns the current `Context` object.""" + + @abstractmethod + def detach(self, token: Token[Context]) -> None: + """Resets Context to a previous value + + Args: + token: A reference to a previous Context. + """ + + +__all__ = ["Context"] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/contextvars_context.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/contextvars_context.py new file mode 100644 index 0000000000000000000000000000000000000000..388329c7f6cf1d00e6199b38e0a629f6361b73b8 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/contextvars_context.py @@ -0,0 +1,45 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from contextvars import ContextVar, Token + +from opentelemetry.context.context import Context, _RuntimeContext + + +class ContextVarsRuntimeContext(_RuntimeContext): + """An implementation of the RuntimeContext interface which wraps ContextVar under + the hood. This is the preferred implementation for usage with Python 3.5+ + """ + + _CONTEXT_KEY = "current_context" + + def __init__(self) -> None: + self._current_context = ContextVar( + self._CONTEXT_KEY, default=Context() + ) + + def attach(self, context: Context) -> Token[Context]: + """Sets the current `Context` object. Returns a + token that can be used to reset to the previous `Context`. + + Args: + context: The Context to set. + """ + return self._current_context.set(context) + + def get_current(self) -> Context: + """Returns the current `Context` object.""" + return self._current_context.get() + + def detach(self, token: Token[Context]) -> None: + """Resets Context to a previous value + + Args: + token: A reference to a previous Context. + """ + self._current_context.reset(token) + + +__all__ = ["ContextVarsRuntimeContext"] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/context/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/environment_variables/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/environment_variables/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b335c826380e0261b77c6272f9f405e96c4aa9fd --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/environment_variables/__init__.py @@ -0,0 +1,72 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +OTEL_LOGS_EXPORTER = "OTEL_LOGS_EXPORTER" +""" +.. envvar:: OTEL_LOGS_EXPORTER + +""" + +OTEL_METRICS_EXPORTER = "OTEL_METRICS_EXPORTER" +""" +.. envvar:: OTEL_METRICS_EXPORTER + +Specifies which exporter is used for metrics. See `General SDK Configuration +`_. + +**Default value:** ``"otlp"`` + +**Example:** + +``export OTEL_METRICS_EXPORTER="prometheus"`` + +Accepted values for ``OTEL_METRICS_EXPORTER`` are: + +- ``"otlp"`` +- ``"prometheus"`` +- ``"none"``: No automatically configured exporter for metrics. + +.. note:: + + Exporter packages may add entry points for group ``opentelemetry_metrics_exporter`` which + can then be used with this environment variable by name. The entry point should point to + either a `opentelemetry.sdk.metrics.export.MetricExporter` (push exporter) or + `opentelemetry.sdk.metrics.export.MetricReader` (pull exporter) subclass; it must be + constructable without any required arguments. This mechanism is considered experimental and + may change in subsequent releases. +""" + +OTEL_PROPAGATORS = "OTEL_PROPAGATORS" +""" +.. envvar:: OTEL_PROPAGATORS +""" + +OTEL_PYTHON_CONTEXT = "OTEL_PYTHON_CONTEXT" +""" +.. envvar:: OTEL_PYTHON_CONTEXT +""" + +OTEL_PYTHON_ID_GENERATOR = "OTEL_PYTHON_ID_GENERATOR" +""" +.. envvar:: OTEL_PYTHON_ID_GENERATOR +""" + +OTEL_TRACES_EXPORTER = "OTEL_TRACES_EXPORTER" +""" +.. envvar:: OTEL_TRACES_EXPORTER +""" + +OTEL_PYTHON_TRACER_PROVIDER = "OTEL_PYTHON_TRACER_PROVIDER" +""" +.. envvar:: OTEL_PYTHON_TRACER_PROVIDER +""" + +OTEL_PYTHON_METER_PROVIDER = "OTEL_PYTHON_METER_PROVIDER" +""" +.. envvar:: OTEL_PYTHON_METER_PROVIDER +""" + +_OTEL_PYTHON_LOGGER_PROVIDER = "OTEL_PYTHON_LOGGER_PROVIDER" +""" +.. envvar:: OTEL_PYTHON_LOGGER_PROVIDER +""" diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/environment_variables/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/environment_variables/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b39a3de15ff65a8a2ee26a2dbcec22d8bc0defbc --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/__init__.py @@ -0,0 +1,121 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +The OpenTelemetry metrics API describes the classes used to generate +metrics. + +The :class:`.MeterProvider` provides users access to the :class:`.Meter` which in +turn is used to create :class:`.Instrument` objects. The :class:`.Instrument` objects are +used to record measurements. + +This module provides abstract (i.e. unimplemented) classes required for +metrics, and a concrete no-op implementation :class:`.NoOpMeter` that allows applications +to use the API package alone without a supporting implementation. + +To get a meter, you need to provide the package name from which you are +calling the meter APIs to OpenTelemetry by calling `MeterProvider.get_meter` +with the calling instrumentation name and the version of your package. + +The following code shows how to obtain a meter using the global :class:`.MeterProvider`:: + + from opentelemetry.metrics import get_meter + + meter = get_meter("example-meter") + counter = meter.create_counter("example-counter") + +.. versionadded:: 1.10.0 +.. versionchanged:: 1.12.0rc +""" + +from opentelemetry.metrics._internal import ( + Meter, + MeterProvider, + NoOpMeter, + NoOpMeterProvider, + get_meter, + get_meter_provider, + set_meter_provider, +) +from opentelemetry.metrics._internal.instrument import ( + Asynchronous, + CallbackOptions, + CallbackT, + Counter, + Histogram, + Instrument, + NoOpCounter, + NoOpHistogram, + NoOpObservableCounter, + NoOpObservableGauge, + NoOpObservableUpDownCounter, + NoOpUpDownCounter, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + Synchronous, + UpDownCounter, +) +from opentelemetry.metrics._internal.instrument import Gauge as _Gauge +from opentelemetry.metrics._internal.instrument import NoOpGauge as _NoOpGauge +from opentelemetry.metrics._internal.observation import Observation + +for obj in [ + Counter, + Synchronous, + Asynchronous, + CallbackOptions, + _Gauge, + _NoOpGauge, + get_meter_provider, + get_meter, + Histogram, + Meter, + MeterProvider, + Instrument, + NoOpCounter, + NoOpHistogram, + NoOpMeter, + NoOpMeterProvider, + NoOpObservableCounter, + NoOpObservableGauge, + NoOpObservableUpDownCounter, + NoOpUpDownCounter, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + Observation, + set_meter_provider, + UpDownCounter, +]: + obj.__module__ = __name__ + +__all__ = [ + "CallbackOptions", + "MeterProvider", + "NoOpMeterProvider", + "Meter", + "Counter", + "_Gauge", + "_NoOpGauge", + "NoOpCounter", + "UpDownCounter", + "NoOpUpDownCounter", + "Histogram", + "NoOpHistogram", + "ObservableCounter", + "NoOpObservableCounter", + "ObservableUpDownCounter", + "Instrument", + "Synchronous", + "Asynchronous", + "NoOpObservableGauge", + "ObservableGauge", + "NoOpObservableUpDownCounter", + "get_meter", + "get_meter_provider", + "set_meter_provider", + "Observation", + "CallbackT", + "NoOpMeter", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9df55291fa5c59ca44f019bfaf381a9363e6f012 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/__init__.py @@ -0,0 +1,877 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=too-many-ancestors + +""" +The OpenTelemetry metrics API describes the classes used to generate +metrics. + +The :class:`.MeterProvider` provides users access to the :class:`.Meter` which in +turn is used to create :class:`.Instrument` objects. The :class:`.Instrument` objects are +used to record measurements. + +This module provides abstract (i.e. unimplemented) classes required for +metrics, and a concrete no-op implementation :class:`.NoOpMeter` that allows applications +to use the API package alone without a supporting implementation. + +To get a meter, you need to provide the package name from which you are +calling the meter APIs to OpenTelemetry by calling `MeterProvider.get_meter` +with the calling instrumentation name and the version of your package. + +The following code shows how to obtain a meter using the global :class:`.MeterProvider`:: + + from opentelemetry.metrics import get_meter + + meter = get_meter("example-meter") + counter = meter.create_counter("example-counter") + +.. versionadded:: 1.10.0 +""" + +import warnings +from abc import ABC, abstractmethod +from collections.abc import Sequence +from dataclasses import dataclass +from logging import getLogger +from os import environ +from threading import Lock +from typing import cast + +from opentelemetry.environment_variables import OTEL_PYTHON_METER_PROVIDER +from opentelemetry.metrics._internal.instrument import ( + CallbackT, + Counter, + Gauge, + Histogram, + NoOpCounter, + NoOpGauge, + NoOpHistogram, + NoOpObservableCounter, + NoOpObservableGauge, + NoOpObservableUpDownCounter, + NoOpUpDownCounter, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, + _MetricsHistogramAdvisory, + _ProxyCounter, + _ProxyGauge, + _ProxyHistogram, + _ProxyObservableCounter, + _ProxyObservableGauge, + _ProxyObservableUpDownCounter, + _ProxyUpDownCounter, +) +from opentelemetry.util._once import Once +from opentelemetry.util._providers import _load_provider +from opentelemetry.util.types import ( + Attributes, +) + +_logger = getLogger(__name__) + + +# pylint: disable=invalid-name +_ProxyInstrumentT = ( + _ProxyCounter + | _ProxyHistogram + | _ProxyGauge + | _ProxyObservableCounter + | _ProxyObservableGauge + | _ProxyObservableUpDownCounter + | _ProxyUpDownCounter +) + + +class MeterProvider(ABC): + """ + MeterProvider is the entry point of the API. It provides access to `Meter` instances. + """ + + @abstractmethod + def get_meter( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + attributes: Attributes | None = None, + ) -> "Meter": + """Returns a `Meter` for use by the given instrumentation library. + + For any two calls it is undefined whether the same or different + `Meter` instances are returned, even for different library names. + + This function may return different `Meter` types (e.g. a no-op meter + vs. a functional meter). + + Args: + name: The name of the instrumenting module. + ``__name__`` should be avoided as this can result in + different meter names if the meters are in different files. + It is better to use a fixed string that can be imported where + needed and used consistently as the name of the meter. + + This should *not* be the name of the module that is + instrumented but the name of the module doing the instrumentation. + E.g., instead of ``"requests"``, use + ``"opentelemetry.instrumentation.requests"``. + + version: Optional. The version string of the + instrumenting library. Usually this should be the same as + ``importlib.metadata.version(instrumenting_library_name)``. + + schema_url: Optional. Specifies the Schema URL of the emitted telemetry. + attributes: Optional. Attributes that are associated with the emitted telemetry. + """ + + +class NoOpMeterProvider(MeterProvider): + """The default MeterProvider used when no MeterProvider implementation is available.""" + + def get_meter( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + attributes: Attributes | None = None, + ) -> "Meter": + """Returns a NoOpMeter.""" + return NoOpMeter(name, version=version, schema_url=schema_url) + + +class _ProxyMeterProvider(MeterProvider): + def __init__(self) -> None: + self._lock = Lock() + self._meters: list[_ProxyMeter] = [] + self._real_meter_provider: MeterProvider | None = None + + def get_meter( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + attributes: Attributes | None = None, + ) -> "Meter": + with self._lock: + if self._real_meter_provider is not None: + return self._real_meter_provider.get_meter( + name, version, schema_url + ) + + meter = _ProxyMeter(name, version=version, schema_url=schema_url) + self._meters.append(meter) + return meter + + def on_set_meter_provider(self, meter_provider: MeterProvider) -> None: + with self._lock: + self._real_meter_provider = meter_provider + for meter in self._meters: + meter.on_set_meter_provider(meter_provider) + + +@dataclass +class _InstrumentRegistrationStatus: + instrument_id: str + already_registered: bool + conflict: bool + current_advisory: _MetricsHistogramAdvisory | None + + +class Meter(ABC): + """Handles instrument creation. + + This class provides methods for creating instruments which are then + used to produce measurements. + """ + + def __init__( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + ) -> None: + super().__init__() + self._name = name + self._version = version + self._schema_url = schema_url + self._instrument_ids: dict[str, _MetricsHistogramAdvisory | None] = {} + self._instrument_ids_lock = Lock() + + @property + def name(self) -> str: + """ + The name of the instrumenting module. + """ + return self._name + + @property + def version(self) -> str | None: + """ + The version string of the instrumenting library. + """ + return self._version + + @property + def schema_url(self) -> str | None: + """ + Specifies the Schema URL of the emitted telemetry + """ + return self._schema_url + + def _register_instrument( + self, + name: str, + type_: type, + unit: str, + description: str, + advisory: _MetricsHistogramAdvisory | None = None, + ) -> _InstrumentRegistrationStatus: + """ + Register an instrument with the name, type, unit and description as + identifying keys and the advisory as value. + + Returns a tuple. The first value is the instrument id. + The second value is an `_InstrumentRegistrationStatus` where + `already_registered` is `True` if the instrument has been registered + already. + If `conflict` is set to True the `current_advisory` attribute contains + the registered instrument advisory. + """ + + instrument_id = ",".join( + [name.strip().lower(), type_.__name__, unit, description] + ) + + already_registered = False + conflict = False + current_advisory = None + + with self._instrument_ids_lock: + # we are not using get because None is a valid value + already_registered = instrument_id in self._instrument_ids + if already_registered: + current_advisory = self._instrument_ids[instrument_id] + conflict = current_advisory != advisory + else: + self._instrument_ids[instrument_id] = advisory + + return _InstrumentRegistrationStatus( + instrument_id=instrument_id, + already_registered=already_registered, + conflict=conflict, + current_advisory=current_advisory, + ) + + @staticmethod + def _log_instrument_registration_conflict( + name: str, + instrumentation_type: str, + unit: str, + description: str, + status: _InstrumentRegistrationStatus, + ) -> None: + _logger.warning( + "An instrument with name %s, type %s, unit %s and " + "description %s has been created already with a " + "different advisory value %s and will be used instead.", + name, + instrumentation_type, + unit, + description, + status.current_advisory, + ) + + @abstractmethod + def create_counter( + self, + name: str, + unit: str = "", + description: str = "", + ) -> Counter: + """Creates a `Counter` instrument + + Args: + name: The name of the instrument to be created + unit: The unit for observations this instrument reports. For + example, ``By`` for bytes. UCUM units are recommended. + description: A description for this instrument and what it measures. + """ + + @abstractmethod + def create_up_down_counter( + self, + name: str, + unit: str = "", + description: str = "", + ) -> UpDownCounter: + """Creates an `UpDownCounter` instrument + + Args: + name: The name of the instrument to be created + unit: The unit for observations this instrument reports. For + example, ``By`` for bytes. UCUM units are recommended. + description: A description for this instrument and what it measures. + """ + + @abstractmethod + def create_observable_counter( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableCounter: + """Creates an `ObservableCounter` instrument + + An observable counter observes a monotonically increasing count by calling provided + callbacks which accept a :class:`~opentelemetry.metrics.CallbackOptions` and return + multiple :class:`~opentelemetry.metrics.Observation`. + + For example, an observable counter could be used to report system CPU + time periodically. Here is a basic implementation:: + + def cpu_time_callback(options: CallbackOptions) -> Iterable[Observation]: + observations = [] + with open("/proc/stat") as procstat: + procstat.readline() # skip the first line + for line in procstat: + if not line.startswith("cpu"): break + cpu, *states = line.split() + observations.append(Observation(int(states[0]) // 100, {"cpu": cpu, "state": "user"})) + observations.append(Observation(int(states[1]) // 100, {"cpu": cpu, "state": "nice"})) + observations.append(Observation(int(states[2]) // 100, {"cpu": cpu, "state": "system"})) + # ... other states + return observations + + meter.create_observable_counter( + "system.cpu.time", + callbacks=[cpu_time_callback], + unit="s", + description="CPU time" + ) + + To reduce memory usage, you can use generator callbacks instead of + building the full list:: + + def cpu_time_callback(options: CallbackOptions) -> Iterable[Observation]: + with open("/proc/stat") as procstat: + procstat.readline() # skip the first line + for line in procstat: + if not line.startswith("cpu"): break + cpu, *states = line.split() + yield Observation(int(states[0]) // 100, {"cpu": cpu, "state": "user"}) + yield Observation(int(states[1]) // 100, {"cpu": cpu, "state": "nice"}) + # ... other states + + Alternatively, you can pass a sequence of generators directly instead of a sequence of + callbacks, which each should return iterables of :class:`~opentelemetry.metrics.Observation`:: + + def cpu_time_callback(states_to_include: set[str]) -> Iterable[Iterable[Observation]]: + # accept options sent in from OpenTelemetry + options = yield + while True: + observations = [] + with open("/proc/stat") as procstat: + procstat.readline() # skip the first line + for line in procstat: + if not line.startswith("cpu"): break + cpu, *states = line.split() + if "user" in states_to_include: + observations.append(Observation(int(states[0]) // 100, {"cpu": cpu, "state": "user"})) + if "nice" in states_to_include: + observations.append(Observation(int(states[1]) // 100, {"cpu": cpu, "state": "nice"})) + # ... other states + # yield the observations and receive the options for next iteration + options = yield observations + + meter.create_observable_counter( + "system.cpu.time", + callbacks=[cpu_time_callback({"user", "system"})], + unit="s", + description="CPU time" + ) + + The :class:`~opentelemetry.metrics.CallbackOptions` contain a timeout which the + callback should respect. For example if the callback does asynchronous work, like + making HTTP requests, it should respect the timeout:: + + def scrape_http_callback(options: CallbackOptions) -> Iterable[Observation]: + r = requests.get('http://scrapethis.com', timeout=options.timeout_millis / 10**3) + for value in r.json(): + yield Observation(value) + + Args: + name: The name of the instrument to be created + callbacks: A sequence of callbacks that return an iterable of + :class:`~opentelemetry.metrics.Observation`. Alternatively, can be a sequence of generators that each + yields iterables of :class:`~opentelemetry.metrics.Observation`. + unit: The unit for observations this instrument reports. For + example, ``By`` for bytes. UCUM units are recommended. + description: A description for this instrument and what it measures. + """ + + @abstractmethod + def create_histogram( + self, + name: str, + unit: str = "", + description: str = "", + *, + explicit_bucket_boundaries_advisory: Sequence[float] | None = None, + ) -> Histogram: + """Creates a :class:`~opentelemetry.metrics.Histogram` instrument + + Args: + name: The name of the instrument to be created + unit: The unit for observations this instrument reports. For + example, ``By`` for bytes. UCUM units are recommended. + description: A description for this instrument and what it measures. + """ + + def create_gauge( # type: ignore # pylint: disable=no-self-use + self, + name: str, + unit: str = "", + description: str = "", + ) -> Gauge: # pyright: ignore[reportReturnType] + """Creates a ``Gauge`` instrument + + Args: + name: The name of the instrument to be created + unit: The unit for observations this instrument reports. For + example, ``By`` for bytes. UCUM units are recommended. + description: A description for this instrument and what it measures. + """ + warnings.warn("create_gauge() is not implemented and will be a no-op") + + @abstractmethod + def create_observable_gauge( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableGauge: + """Creates an `ObservableGauge` instrument + + Args: + name: The name of the instrument to be created + callbacks: A sequence of callbacks that return an iterable of + :class:`~opentelemetry.metrics.Observation`. Alternatively, can be a generator that yields iterables + of :class:`~opentelemetry.metrics.Observation`. + unit: The unit for observations this instrument reports. For + example, ``By`` for bytes. UCUM units are recommended. + description: A description for this instrument and what it measures. + """ + + @abstractmethod + def create_observable_up_down_counter( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableUpDownCounter: + """Creates an `ObservableUpDownCounter` instrument + + Args: + name: The name of the instrument to be created + callbacks: A sequence of callbacks that return an iterable of + :class:`~opentelemetry.metrics.Observation`. Alternatively, can be a generator that yields iterables + of :class:`~opentelemetry.metrics.Observation`. + unit: The unit for observations this instrument reports. For + example, ``By`` for bytes. UCUM units are recommended. + description: A description for this instrument and what it measures. + """ + + +class _ProxyMeter(Meter): + def __init__( + self, + name: str, + version: str | None = None, + schema_url: str | None = None, + ) -> None: + super().__init__(name, version=version, schema_url=schema_url) + self._lock = Lock() + self._instruments: list[_ProxyInstrumentT] = [] + self._real_meter: Meter | None = None + + def on_set_meter_provider(self, meter_provider: MeterProvider) -> None: + """Called when a real meter provider is set on the creating _ProxyMeterProvider + + Creates a real backing meter for this instance and notifies all created + instruments so they can create real backing instruments. + """ + real_meter = meter_provider.get_meter( + self._name, self._version, self._schema_url + ) + + with self._lock: + self._real_meter = real_meter + # notify all proxy instruments of the new meter so they can create + # real instruments to back themselves + for instrument in self._instruments: + instrument.on_meter_set(real_meter) + + def create_counter( + self, + name: str, + unit: str = "", + description: str = "", + ) -> Counter: + with self._lock: + if self._real_meter: + return self._real_meter.create_counter(name, unit, description) + proxy = _ProxyCounter(name, unit, description) + self._instruments.append(proxy) + return proxy + + def create_up_down_counter( + self, + name: str, + unit: str = "", + description: str = "", + ) -> UpDownCounter: + with self._lock: + if self._real_meter: + return self._real_meter.create_up_down_counter( + name, unit, description + ) + proxy = _ProxyUpDownCounter(name, unit, description) + self._instruments.append(proxy) + return proxy + + def create_observable_counter( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableCounter: + with self._lock: + if self._real_meter: + return self._real_meter.create_observable_counter( + name, callbacks, unit, description + ) + proxy = _ProxyObservableCounter( + name, callbacks, unit=unit, description=description + ) + self._instruments.append(proxy) + return proxy + + def create_histogram( + self, + name: str, + unit: str = "", + description: str = "", + *, + explicit_bucket_boundaries_advisory: Sequence[float] | None = None, + ) -> Histogram: + with self._lock: + if self._real_meter: + return self._real_meter.create_histogram( + name, + unit, + description, + explicit_bucket_boundaries_advisory=explicit_bucket_boundaries_advisory, + ) + proxy = _ProxyHistogram( + name, unit, description, explicit_bucket_boundaries_advisory + ) + self._instruments.append(proxy) + return proxy + + def create_gauge( + self, + name: str, + unit: str = "", + description: str = "", + ) -> Gauge: + with self._lock: + if self._real_meter: + return self._real_meter.create_gauge(name, unit, description) + proxy = _ProxyGauge(name, unit, description) + self._instruments.append(proxy) + return proxy + + def create_observable_gauge( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableGauge: + with self._lock: + if self._real_meter: + return self._real_meter.create_observable_gauge( + name, callbacks, unit, description + ) + proxy = _ProxyObservableGauge( + name, callbacks, unit=unit, description=description + ) + self._instruments.append(proxy) + return proxy + + def create_observable_up_down_counter( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableUpDownCounter: + with self._lock: + if self._real_meter: + return self._real_meter.create_observable_up_down_counter( + name, + callbacks, + unit, + description, + ) + proxy = _ProxyObservableUpDownCounter( + name, callbacks, unit=unit, description=description + ) + self._instruments.append(proxy) + return proxy + + +class NoOpMeter(Meter): + """The default Meter used when no Meter implementation is available. + + All operations are no-op. + """ + + def create_counter( + self, + name: str, + unit: str = "", + description: str = "", + ) -> Counter: + """Returns a no-op Counter.""" + status = self._register_instrument( + name, NoOpCounter, unit, description + ) + if status.conflict: + self._log_instrument_registration_conflict( + name, + Counter.__name__, + unit, + description, + status, + ) + + return NoOpCounter(name, unit=unit, description=description) + + def create_gauge( + self, + name: str, + unit: str = "", + description: str = "", + ) -> Gauge: + """Returns a no-op Gauge.""" + status = self._register_instrument(name, NoOpGauge, unit, description) + if status.conflict: + self._log_instrument_registration_conflict( + name, + Gauge.__name__, + unit, + description, + status, + ) + return NoOpGauge(name, unit=unit, description=description) + + def create_up_down_counter( + self, + name: str, + unit: str = "", + description: str = "", + ) -> UpDownCounter: + """Returns a no-op UpDownCounter.""" + status = self._register_instrument( + name, NoOpUpDownCounter, unit, description + ) + if status.conflict: + self._log_instrument_registration_conflict( + name, + UpDownCounter.__name__, + unit, + description, + status, + ) + return NoOpUpDownCounter(name, unit=unit, description=description) + + def create_observable_counter( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableCounter: + """Returns a no-op ObservableCounter.""" + status = self._register_instrument( + name, NoOpObservableCounter, unit, description + ) + if status.conflict: + self._log_instrument_registration_conflict( + name, + ObservableCounter.__name__, + unit, + description, + status, + ) + return NoOpObservableCounter( + name, + callbacks, + unit=unit, + description=description, + ) + + def create_histogram( + self, + name: str, + unit: str = "", + description: str = "", + *, + explicit_bucket_boundaries_advisory: Sequence[float] | None = None, + ) -> Histogram: + """Returns a no-op Histogram.""" + status = self._register_instrument( + name, + NoOpHistogram, + unit, + description, + _MetricsHistogramAdvisory( + explicit_bucket_boundaries=explicit_bucket_boundaries_advisory + ), + ) + if status.conflict: + self._log_instrument_registration_conflict( + name, + Histogram.__name__, + unit, + description, + status, + ) + return NoOpHistogram( + name, + unit=unit, + description=description, + explicit_bucket_boundaries_advisory=explicit_bucket_boundaries_advisory, + ) + + def create_observable_gauge( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableGauge: + """Returns a no-op ObservableGauge.""" + status = self._register_instrument( + name, NoOpObservableGauge, unit, description + ) + if status.conflict: + self._log_instrument_registration_conflict( + name, + ObservableGauge.__name__, + unit, + description, + status, + ) + return NoOpObservableGauge( + name, + callbacks, + unit=unit, + description=description, + ) + + def create_observable_up_down_counter( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> ObservableUpDownCounter: + """Returns a no-op ObservableUpDownCounter.""" + status = self._register_instrument( + name, NoOpObservableUpDownCounter, unit, description + ) + if status.conflict: + self._log_instrument_registration_conflict( + name, + ObservableUpDownCounter.__name__, + unit, + description, + status, + ) + return NoOpObservableUpDownCounter( + name, + callbacks, + unit=unit, + description=description, + ) + + +_METER_PROVIDER_SET_ONCE = Once() +_METER_PROVIDER: MeterProvider | None = None +_PROXY_METER_PROVIDER = _ProxyMeterProvider() + + +def get_meter( + name: str, + version: str = "", + meter_provider: MeterProvider | None = None, + schema_url: str | None = None, + attributes: Attributes | None = None, +) -> "Meter": + """Returns a `Meter` for use by the given instrumentation library. + + This function is a convenience wrapper for + `opentelemetry.metrics.MeterProvider.get_meter`. + + If meter_provider is omitted the current configured one is used. + """ + if meter_provider is None: + meter_provider = get_meter_provider() + return meter_provider.get_meter(name, version, schema_url, attributes) + + +def _set_meter_provider(meter_provider: MeterProvider, log: bool) -> None: + def set_mp() -> None: + global _METER_PROVIDER # pylint: disable=global-statement + _METER_PROVIDER = meter_provider + + # gives all proxies real instruments off the newly set meter provider + _PROXY_METER_PROVIDER.on_set_meter_provider(meter_provider) + + did_set = _METER_PROVIDER_SET_ONCE.do_once(set_mp) + + if log and not did_set: + _logger.warning("Overriding of current MeterProvider is not allowed") + + +def set_meter_provider(meter_provider: MeterProvider) -> None: + """Sets the current global :class:`~.MeterProvider` object. + + This can only be done once, a warning will be logged if any further attempt + is made. + """ + _set_meter_provider(meter_provider, log=True) + + +def get_meter_provider() -> MeterProvider: + """Gets the current global :class:`~.MeterProvider` object.""" + + if _METER_PROVIDER is None: + if OTEL_PYTHON_METER_PROVIDER not in environ: + return _PROXY_METER_PROVIDER + + meter_provider: MeterProvider = _load_provider( # type: ignore + OTEL_PYTHON_METER_PROVIDER, "meter_provider" + ) + _set_meter_provider(meter_provider, log=False) + + # _METER_PROVIDER will have been set by one thread + return cast("MeterProvider", _METER_PROVIDER) diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/instrument.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/instrument.py new file mode 100644 index 0000000000000000000000000000000000000000..79bdfa4e67302d0e7cce4cb3aa44061dabe1e286 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/instrument.py @@ -0,0 +1,555 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=too-many-ancestors + + +from abc import ABC, abstractmethod +from collections.abc import Callable, Generator, Iterable, Sequence +from dataclasses import dataclass +from logging import getLogger +from re import compile as re_compile +from typing import ( + Generic, + TypeVar, +) + +# pylint: disable=unused-import; needed for typing and sphinx +from opentelemetry import metrics +from opentelemetry.context import Context +from opentelemetry.metrics._internal.observation import Observation +from opentelemetry.util.types import ( + Attributes, +) + +_logger = getLogger(__name__) + +_name_regex = re_compile(r"[a-zA-Z][-_./a-zA-Z0-9]{0,254}") +_unit_regex = re_compile(r"[\x00-\x7F]{0,63}") + + +@dataclass(frozen=True) +class _MetricsHistogramAdvisory: + explicit_bucket_boundaries: Sequence[float] | None = None + + +@dataclass(frozen=True) +class CallbackOptions: + """Options for the callback + + Args: + timeout_millis: Timeout for the callback's execution. If the callback does asynchronous + work (e.g. HTTP requests), it should respect this timeout. + """ + + timeout_millis: float = 10_000 + + +InstrumentT = TypeVar("InstrumentT", bound="Instrument") +# pylint: disable=invalid-name +CallbackT = ( + Callable[[CallbackOptions], Iterable[Observation]] + | Generator[Iterable[Observation], CallbackOptions, None] +) + + +class Instrument(ABC): + """Abstract class that serves as base for all instruments.""" + + @abstractmethod + def __init__( + self, + name: str, + unit: str = "", + description: str = "", + ) -> None: + pass + + @staticmethod + def _check_name_unit_description( + name: str, unit: str, description: str + ) -> dict[str, str | None]: + """ + Checks the following instrument name, unit and description for + compliance with the spec. + + Returns a dict with keys "name", "unit" and "description", the + corresponding values will be the checked strings or `None` if the value + is invalid. If valid, the checked strings should be used instead of the + original values. + """ + + result: dict[str, str | None] = {} + + if _name_regex.fullmatch(name) is not None: + result["name"] = name + else: + result["name"] = None + + if unit is None: + unit = "" + if _unit_regex.fullmatch(unit) is not None: + result["unit"] = unit + else: + result["unit"] = None + + if description is None: + result["description"] = "" + else: + result["description"] = description + + return result + + +class _ProxyInstrument(ABC, Generic[InstrumentT]): + def __init__( + self, + name: str, + unit: str = "", + description: str = "", + ) -> None: + self._name = name + self._unit = unit + self._description = description + self._real_instrument: InstrumentT | None = None + + def on_meter_set(self, meter: "metrics.Meter") -> None: + """Called when a real meter is set on the creating _ProxyMeter""" + + # We don't need any locking on proxy instruments because it's OK if some + # measurements get dropped while a real backing instrument is being + # created. + self._real_instrument = self._create_real_instrument(meter) + + @abstractmethod + def _create_real_instrument(self, meter: "metrics.Meter") -> InstrumentT: + """Create an instance of the real instrument. Implement this.""" + + +class _ProxyAsynchronousInstrument(_ProxyInstrument[InstrumentT]): + def __init__( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> None: + super().__init__(name, unit, description) + self._callbacks = callbacks + + +class Synchronous(Instrument): + """Base class for all synchronous instruments""" + + +class Asynchronous(Instrument): + """Base class for all asynchronous instruments""" + + @abstractmethod + def __init__( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> None: + super().__init__(name, unit=unit, description=description) + + +class Counter(Synchronous): + """A Counter is a synchronous `Instrument` which supports non-negative increments.""" + + @abstractmethod + def add( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + """Records an increment to the counter. + + Args: + amount: The amount to increment the counter by. Must be non-negative. + attributes: Optional set of attributes to associate with the measurement. + context: Optional context to associate with the measurement. If not + provided, the current context is used. + """ + + +class NoOpCounter(Counter): + """No-op implementation of `Counter`.""" + + def __init__( + self, + name: str, + unit: str = "", + description: str = "", + ) -> None: + super().__init__(name, unit=unit, description=description) + + def add( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + return super().add(amount, attributes=attributes, context=context) + + +class _ProxyCounter(_ProxyInstrument[Counter], Counter): + def add( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + if self._real_instrument: + self._real_instrument.add(amount, attributes, context) + + def _create_real_instrument(self, meter: "metrics.Meter") -> Counter: + return meter.create_counter( + self._name, + self._unit, + self._description, + ) + + +class UpDownCounter(Synchronous): + """An UpDownCounter is a synchronous `Instrument` which supports increments and decrements.""" + + @abstractmethod + def add( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + """Records an increment or decrement to the counter. + + Unlike `Counter`, the ``amount`` may be negative, allowing the + instrument to track values that go up and down (e.g. number of + active requests, queue depth). + + Args: + amount: The amount to add to the counter. May be positive or negative. + attributes: Optional set of attributes to associate with the measurement. + context: Optional context to associate with the measurement. If not + provided, the current context is used. + """ + + +class NoOpUpDownCounter(UpDownCounter): + """No-op implementation of `UpDownCounter`.""" + + def __init__( + self, + name: str, + unit: str = "", + description: str = "", + ) -> None: + super().__init__(name, unit=unit, description=description) + + def add( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + return super().add(amount, attributes=attributes, context=context) + + +class _ProxyUpDownCounter(_ProxyInstrument[UpDownCounter], UpDownCounter): + def add( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + if self._real_instrument: + self._real_instrument.add(amount, attributes, context) + + def _create_real_instrument(self, meter: "metrics.Meter") -> UpDownCounter: + return meter.create_up_down_counter( + self._name, + self._unit, + self._description, + ) + + +class ObservableCounter(Asynchronous): + """An ObservableCounter is an asynchronous `Instrument` which reports monotonically + increasing value(s) when the instrument is being observed. + """ + + +class NoOpObservableCounter(ObservableCounter): + """No-op implementation of `ObservableCounter`.""" + + def __init__( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> None: + super().__init__( + name, + callbacks, + unit=unit, + description=description, + ) + + +class _ProxyObservableCounter( + _ProxyAsynchronousInstrument[ObservableCounter], ObservableCounter +): + def _create_real_instrument( + self, meter: "metrics.Meter" + ) -> ObservableCounter: + return meter.create_observable_counter( + self._name, + self._callbacks, + self._unit, + self._description, + ) + + +class ObservableUpDownCounter(Asynchronous): + """An ObservableUpDownCounter is an asynchronous `Instrument` which reports additive value(s) (e.g. + the process heap size - it makes sense to report the heap size from multiple processes and sum them + up, so we get the total heap usage) when the instrument is being observed. + """ + + +class NoOpObservableUpDownCounter(ObservableUpDownCounter): + """No-op implementation of `ObservableUpDownCounter`.""" + + def __init__( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> None: + super().__init__( + name, + callbacks, + unit=unit, + description=description, + ) + + +class _ProxyObservableUpDownCounter( + _ProxyAsynchronousInstrument[ObservableUpDownCounter], + ObservableUpDownCounter, +): + def _create_real_instrument( + self, meter: "metrics.Meter" + ) -> ObservableUpDownCounter: + return meter.create_observable_up_down_counter( + self._name, + self._callbacks, + self._unit, + self._description, + ) + + +class Histogram(Synchronous): + """Histogram is a synchronous `Instrument` which can be used to report arbitrary values + that are likely to be statistically meaningful. It is intended for statistics such as + histograms, summaries, and percentile. + """ + + @abstractmethod + def __init__( + self, + name: str, + unit: str = "", + description: str = "", + explicit_bucket_boundaries_advisory: Sequence[float] | None = None, + ) -> None: + pass + + @abstractmethod + def record( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + """Records a measurement. + + Used to report measurements that are likely to be statistically + meaningful, such as request durations, payload sizes, or any value + for which a distribution (e.g. percentiles) is useful. + + Args: + amount: The measurement to record. Should be non-negative in most + cases; negative values are only meaningful when the histogram + is used to track signed deltas. + attributes: Optional set of attributes to associate with the measurement. + context: Optional context to associate with the measurement. If not + provided, the current context is used. + """ + + +class NoOpHistogram(Histogram): + """No-op implementation of `Histogram`.""" + + def __init__( + self, + name: str, + unit: str = "", + description: str = "", + explicit_bucket_boundaries_advisory: Sequence[float] | None = None, + ) -> None: + super().__init__( + name, + unit=unit, + description=description, + explicit_bucket_boundaries_advisory=explicit_bucket_boundaries_advisory, + ) + + def record( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + return super().record(amount, attributes=attributes, context=context) + + +class _ProxyHistogram(_ProxyInstrument[Histogram], Histogram): + def __init__( + self, + name: str, + unit: str = "", + description: str = "", + explicit_bucket_boundaries_advisory: Sequence[float] | None = None, + ) -> None: + super().__init__(name, unit=unit, description=description) + self._explicit_bucket_boundaries_advisory = ( + explicit_bucket_boundaries_advisory + ) + + def record( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + if self._real_instrument: + self._real_instrument.record(amount, attributes, context) + + def _create_real_instrument(self, meter: "metrics.Meter") -> Histogram: + return meter.create_histogram( + self._name, + self._unit, + self._description, + explicit_bucket_boundaries_advisory=self._explicit_bucket_boundaries_advisory, + ) + + +class ObservableGauge(Asynchronous): + """Asynchronous Gauge is an asynchronous `Instrument` which reports non-additive value(s) (e.g. + the room temperature - it makes no sense to report the temperature value from multiple rooms + and sum them up) when the instrument is being observed. + """ + + +class NoOpObservableGauge(ObservableGauge): + """No-op implementation of `ObservableGauge`.""" + + def __init__( + self, + name: str, + callbacks: Sequence[CallbackT] | None = None, + unit: str = "", + description: str = "", + ) -> None: + super().__init__( + name, + callbacks, + unit=unit, + description=description, + ) + + +class _ProxyObservableGauge( + _ProxyAsynchronousInstrument[ObservableGauge], + ObservableGauge, +): + def _create_real_instrument( + self, meter: "metrics.Meter" + ) -> ObservableGauge: + return meter.create_observable_gauge( + self._name, + self._callbacks, + self._unit, + self._description, + ) + + +class Gauge(Synchronous): + """A Gauge is a synchronous `Instrument` which can be used to record non-additive values as they occur.""" + + @abstractmethod + def set( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + """Records the current value of the gauge. + + The gauge reports the last recorded value when observed. It is + intended for non-additive measurements where only the current + value matters (e.g. CPU utilisation percentage, room temperature). + + Args: + amount: The current value to record. + attributes: Optional set of attributes to associate with the measurement. + context: Optional context to associate with the measurement. If not + provided, the current context is used. + """ + + +class NoOpGauge(Gauge): + """No-op implementation of ``Gauge``.""" + + def __init__( + self, + name: str, + unit: str = "", + description: str = "", + ) -> None: + super().__init__(name, unit=unit, description=description) + + def set( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + return super().set(amount, attributes=attributes, context=context) + + +class _ProxyGauge( + _ProxyInstrument[Gauge], + Gauge, +): + def set( + self, + amount: int | float, + attributes: Attributes | None = None, + context: Context | None = None, + ) -> None: + if self._real_instrument: + self._real_instrument.set(amount, attributes, context) + + def _create_real_instrument(self, meter: "metrics.Meter") -> Gauge: + return meter.create_gauge( + self._name, + self._unit, + self._description, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/observation.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/observation.py new file mode 100644 index 0000000000000000000000000000000000000000..26f2e07914b8be489e132442445c0c545a283300 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/_internal/observation.py @@ -0,0 +1,51 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + + +from opentelemetry.context import Context +from opentelemetry.util.types import Attributes + + +class Observation: + """A measurement observed in an asynchronous instrument + + Return/yield instances of this class from asynchronous instrument callbacks. + + Args: + value: The float or int measured value + attributes: The measurement's attributes + context: The measurement's context + """ + + def __init__( + self, + value: int | float, + attributes: Attributes = None, + context: Context | None = None, + ) -> None: + self._value = value + self._attributes = attributes + self._context = context + + @property + def value(self) -> float | int: + return self._value + + @property + def attributes(self) -> Attributes: + return self._attributes + + @property + def context(self) -> Context | None: + return self._context + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, Observation) + and self.value == other.value + and self.attributes == other.attributes + and self.context == other.context + ) + + def __repr__(self) -> str: + return f"Observation(value={self.value}, attributes={self.attributes}, context={self.context})" diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/metrics/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagate/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagate/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d53a1b91d30de89f0a009c254738b9469cefcee0 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagate/__init__.py @@ -0,0 +1,181 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +API for propagation of context. + +The propagators for the +``opentelemetry.propagators.composite.CompositePropagator`` can be defined +via configuration in the ``OTEL_PROPAGATORS`` environment variable. This +variable should be set to a comma-separated string of names of values for the +``opentelemetry_propagator`` entry point. For example, setting +``OTEL_PROPAGATORS`` to ``tracecontext,baggage`` (which is the default value) +would instantiate +``opentelemetry.propagators.composite.CompositePropagator`` with 2 +propagators, one of type +``opentelemetry.trace.propagation.tracecontext.TraceContextTextMapPropagator`` +and other of type ``opentelemetry.baggage.propagation.W3CBaggagePropagator``. +Notice that these propagator classes are defined as +``opentelemetry_propagator`` entry points in the ``pyproject.toml`` file of +``opentelemetry``. + +Example:: + + import flask + import requests + from opentelemetry import propagate + + + PROPAGATOR = propagate.get_global_textmap() + + + def get_header_from_flask_request(request, key): + return request.headers.get_all(key) + + def set_header_into_requests_request(request: requests.Request, + key: str, value: str): + request.headers[key] = value + + def example_route(): + context = PROPAGATOR.extract( + get_header_from_flask_request, + flask.request + ) + request_to_downstream = requests.Request( + "GET", "http://httpbin.org/get" + ) + PROPAGATOR.inject( + set_header_into_requests_request, + request_to_downstream, + context=context + ) + session = requests.Session() + session.send(request_to_downstream.prepare()) + + +.. _Propagation API Specification: + https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/context/api-propagators.md +""" + +from logging import getLogger +from os import environ + +from opentelemetry.context.context import Context +from opentelemetry.environment_variables import OTEL_PROPAGATORS +from opentelemetry.propagators import composite, textmap + +logger = getLogger(__name__) + + +def extract( + carrier: textmap.CarrierT, + context: Context | None = None, + getter: textmap.Getter[textmap.CarrierT] = textmap.default_getter, +) -> Context: + """Uses the configured propagator to extract a Context from the carrier. + + Args: + getter: an object which contains a get function that can retrieve zero + or more values from the carrier and a keys function that can get all the keys + from carrier. + carrier: and object which contains values that are + used to construct a Context. This object + must be paired with an appropriate getter + which understands how to extract a value from it. + context: an optional Context to use. Defaults to root + context if not set. + """ + return get_global_textmap().extract(carrier, context, getter=getter) + + +def inject( + carrier: textmap.CarrierT, + context: Context | None = None, + setter: textmap.Setter[textmap.CarrierT] = textmap.default_setter, +) -> None: + """Uses the configured propagator to inject a Context into the carrier. + + Args: + carrier: the medium used by Propagators to read + values from and write values to. + Should be paired with setter, which + should know how to set header values on the carrier. + context: An optional Context to use. Defaults to current + context if not set. + setter: An optional `Setter` object that can set values + on the carrier. + """ + get_global_textmap().inject(carrier, context=context, setter=setter) + + +def _load_propagators() -> textmap.TextMapPropagator: + configured = environ.get(OTEL_PROPAGATORS) + if not configured: + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.baggage.propagation import ( # noqa: PLC0415 + W3CBaggagePropagator, + ) + + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.trace.propagation.tracecontext import ( # noqa: PLC0415 + TraceContextTextMapPropagator, + ) + + return composite.CompositePropagator( + [TraceContextTextMapPropagator(), W3CBaggagePropagator()] + ) + + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.util._importlib_metadata import ( # noqa: PLC0415 + entry_points, + ) + + _propagators: list[textmap.TextMapPropagator] = [] + for _propagator in configured.split(","): + _propagator = _propagator.strip() + if _propagator.lower() == "none": + logger.debug( + "OTEL_PROPAGATORS environment variable contains none, removing all propagators" + ) + return composite.CompositePropagator([]) + try: + _propagators.append( + next( # type: ignore + iter( # type: ignore + entry_points( # type: ignore[misc] + group="opentelemetry_propagator", + name=_propagator, + ) + ) + ).load()() + ) + except StopIteration: + raise ValueError( + f"Propagator {_propagator} not found. It is either misspelled or not installed." + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to load propagator: %s", _propagator) + raise + return composite.CompositePropagator(_propagators) + + +_HTTP_TEXT_FORMAT: textmap.TextMapPropagator = _load_propagators() + +# Deprecated: propagators, environ_propagators and propagator names were never intendended to be part of the public API. +propagators: list[textmap.TextMapPropagator] = [] + +environ_propagators = environ.get(OTEL_PROPAGATORS, "tracecontext,baggage") + +for propagator in environ_propagators.split(","): # type: ignore[assignment] + propagator = propagator.strip() + + +def get_global_textmap() -> textmap.TextMapPropagator: + return _HTTP_TEXT_FORMAT + + +def set_global_textmap( + http_text_format: textmap.TextMapPropagator, +) -> None: + global _HTTP_TEXT_FORMAT # pylint:disable=global-statement + _HTTP_TEXT_FORMAT = http_text_format diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagate/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagate/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/_envcarrier.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/_envcarrier.py new file mode 100644 index 0000000000000000000000000000000000000000..7c381e36d9c6f2876f188f962d5a1187f938fb78 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/_envcarrier.py @@ -0,0 +1,98 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Environment variable carriers for text map propagators. + +Use :class:`EnvironmentGetter` with the environment mapping to extract from, +usually ``os.environ`` during application or child-process initialization. +Use :class:`EnvironmentSetter` with a mutable environment copy when preparing +the environment for a child process. +""" + +import re +from collections.abc import Mapping, MutableMapping + +from opentelemetry.propagators.textmap import Getter, Setter + + +def _normalize_key(key: str) -> str: + if not key: + return "_" + result = re.sub(r"[^A-Za-z0-9_]", "_", key).upper() + if result and result[0].isdigit(): + result = "_" + result + return result + + +def _is_normalized_key(key: str) -> bool: + if not key: + return False + if "0" <= key[0] <= "9": + return False + return all( + "A" <= char <= "Z" or "0" <= char <= "9" or char == "_" for char in key + ) + + +class EnvironmentGetter(Getter[Mapping[str, str]]): + """Getter implementation for extracting context and baggage from environment variables. + + EnvironmentGetter reads from the mapping provided as the carrier, normalizes + requested keys, and provides simple data access without validation. + + Example usage: + getter = EnvironmentGetter() + traceparent = getter.get(os.environ, "traceparent") + """ + + def get(self, carrier: Mapping[str, str], key: str) -> list[str] | None: + """Get a value from the environment carrier for the given key. + + Args: + carrier: Mapping to read environment variables from + key: The key to look up (will be normalized) + + Returns: + A list with a single string value if the key exists, None otherwise. + """ + val = carrier.get(_normalize_key(key)) + if val is None: + return None + return [val] + + def keys(self, carrier: Mapping[str, str]) -> list[str]: + """Get all keys from the environment carrier. + + Args: + carrier: Mapping to read environment variable keys from + + Returns: + List of all already-normalized environment variable keys. + """ + return [key for key in carrier.keys() if _is_normalized_key(key)] + + +class EnvironmentSetter(Setter[MutableMapping[str, str]]): + """Setter implementation for building environment variable dictionaries. + + EnvironmentSetter builds a dictionary of environment variables that + can be passed to utilities like subprocess.run() + + Example usage: + setter = EnvironmentSetter() + env_vars = {} + setter.set(env_vars, "traceparent", "00-trace-id-span-id-01") + subprocess.run(myCommand, env=env_vars) + """ + + def set( + self, carrier: MutableMapping[str, str], key: str, value: str + ) -> None: + """Set a value in the carrier dictionary for the given key. + + Args: + carrier: Dictionary to store environment variables + key: The key to set (normalized) + value: The value to set + """ + carrier[_normalize_key(key)] = value diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/composite.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/composite.py new file mode 100644 index 0000000000000000000000000000000000000000..1d3c3912ee1035b9a3fe7fbaea38f7b36b7495ba --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/composite.py @@ -0,0 +1,82 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +import collections.abc +import logging + +from typing_extensions import deprecated + +from opentelemetry.context.context import Context +from opentelemetry.propagators import textmap + +logger = logging.getLogger(__name__) + + +class CompositePropagator(textmap.TextMapPropagator): + """CompositePropagator provides a mechanism for combining multiple + propagators into a single one. + + Args: + propagators: the list of propagators to use + """ + + def __init__( + self, propagators: collections.abc.Sequence[textmap.TextMapPropagator] + ) -> None: + self._propagators = propagators + + def extract( + self, + carrier: textmap.CarrierT, + context: Context | None = None, + getter: textmap.Getter[textmap.CarrierT] = textmap.default_getter, + ) -> Context: + """Run each of the configured propagators with the given context and carrier. + Propagators are run in the order they are configured, if multiple + propagators write the same context key, the propagator later in the list + will override previous propagators. + + See `opentelemetry.propagators.textmap.TextMapPropagator.extract` + """ + for propagator in self._propagators: + context = propagator.extract(carrier, context, getter=getter) + return context # type: ignore + + def inject( + self, + carrier: textmap.CarrierT, + context: Context | None = None, + setter: textmap.Setter[textmap.CarrierT] = textmap.default_setter, + ) -> None: + """Run each of the configured propagators with the given context and carrier. + Propagators are run in the order they are configured, if multiple + propagators write the same carrier key, the propagator later in the list + will override previous propagators. + + See `opentelemetry.propagators.textmap.TextMapPropagator.inject` + """ + for propagator in self._propagators: + propagator.inject(carrier, context, setter=setter) + + @property + def fields(self) -> set[str]: + """Returns a set with the fields set in `inject`. + + See + `opentelemetry.propagators.textmap.TextMapPropagator.fields` + """ + composite_fields = set() + + for propagator in self._propagators: + for field in propagator.fields: + composite_fields.add(field) + + return composite_fields + + +@deprecated( + "You should use CompositePropagator. Deprecated since version 1.2.0." +) +class CompositeHTTPPropagator(CompositePropagator): + """CompositeHTTPPropagator provides a mechanism for combining multiple + propagators into a single one. + """ diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/textmap.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/textmap.py new file mode 100644 index 0000000000000000000000000000000000000000..04f5fe868320902b0c4955f68238d289cff80c17 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/propagators/textmap.py @@ -0,0 +1,183 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import abc +import typing +from collections.abc import Iterable, Mapping, MutableMapping + +from opentelemetry.context.context import Context + +CarrierT = typing.TypeVar("CarrierT") +# pylint: disable=invalid-name +CarrierValT = list[str] | str + + +class Getter(abc.ABC, typing.Generic[CarrierT]): + """This class implements a Getter that enables extracting propagated + fields from a carrier. + """ + + @abc.abstractmethod + def get(self, carrier: CarrierT, key: str) -> list[str] | None: + """Function that can retrieve zero + or more values from the carrier. In the case that + the value does not exist, returns None. + + Args: + carrier: An object which contains values that are used to + construct a Context. + key: key of a field in carrier. + Returns: first value of the propagation key or None if the key doesn't + exist. + """ + + @abc.abstractmethod + def keys(self, carrier: CarrierT) -> list[str]: + """Function that can retrieve all the keys in a carrier object. + + Args: + carrier: An object which contains values that are + used to construct a Context. + Returns: + list of keys from the carrier. + """ + + +class Setter(abc.ABC, typing.Generic[CarrierT]): + """This class implements a Setter that enables injecting propagated + fields into a carrier. + """ + + @abc.abstractmethod + def set(self, carrier: CarrierT, key: str, value: str) -> None: + """Function that can set a value into a carrier"" + + Args: + carrier: An object which contains values that are used to + construct a Context. + key: key of a field in carrier. + value: value for a field in carrier. + """ + + +class DefaultGetter(Getter[Mapping[str, CarrierValT]]): + def get( + self, carrier: Mapping[str, CarrierValT], key: str + ) -> list[str] | None: + """Getter implementation to retrieve a value from a dictionary. + + Args: + carrier: dictionary in which to get value + key: the key used to get the value + Returns: + A list with a single string with the value if it exists, else None. + """ + val = carrier.get(key, None) + if val is None: + return None + if isinstance(val, Iterable) and not isinstance(val, str): + return list(val) + return [val] + + def keys(self, carrier: Mapping[str, CarrierValT]) -> list[str]: + """Keys implementation that returns all keys from a dictionary.""" + return list(carrier.keys()) + + +default_getter: Getter[CarrierT] = DefaultGetter() # type: ignore + + +class DefaultSetter(Setter[MutableMapping[str, CarrierValT]]): + def set( + self, + carrier: MutableMapping[str, CarrierValT], + key: str, + value: CarrierValT, + ) -> None: + """Setter implementation to set a value into a dictionary. + + Args: + carrier: dictionary in which to set value + key: the key used to set the value + value: the value to set + """ + carrier[key] = value + + +default_setter: Setter[CarrierT] = DefaultSetter() # type: ignore + + +class TextMapPropagator(abc.ABC): + """This class provides an interface that enables extracting and injecting + context into headers of HTTP requests. HTTP frameworks and clients + can integrate with TextMapPropagator by providing the object containing the + headers, and a getter and setter function for the extraction and + injection of values, respectively. + + """ + + @abc.abstractmethod + def extract( + self, + carrier: CarrierT, + context: Context | None = None, + getter: Getter[CarrierT] = default_getter, + ) -> Context: + """Create a Context from values in the carrier. + + The extract function should retrieve values from the carrier + object using getter, and use values to populate a + Context value and return it. + + Args: + getter: a function that can retrieve zero + or more values from the carrier. In the case that + the value does not exist, return an empty list. + carrier: and object which contains values that are + used to construct a Context. This object + must be paired with an appropriate getter + which understands how to extract a value from it. + context: an optional Context to use. Defaults to root + context if not set. + Returns: + A Context with configuration found in the carrier. + + """ + + @abc.abstractmethod + def inject( + self, + carrier: CarrierT, + context: Context | None = None, + setter: Setter[CarrierT] = default_setter, + ) -> None: + """Inject values from a Context into a carrier. + + inject enables the propagation of values into HTTP clients or + other objects which perform an HTTP request. Implementations + should use the `Setter` 's set method to set values on the + carrier. + + Args: + carrier: An object that a place to define HTTP headers. + Should be paired with setter, which should + know how to set header values on the carrier. + context: an optional Context to use. Defaults to current + context if not set. + setter: An optional `Setter` object that can set values + on the carrier. + + """ + + @property + @abc.abstractmethod + def fields(self) -> set[str]: + """ + Gets the fields set in the carrier by the `inject` method. + + If the carrier is reused, its fields that correspond with the ones + present in this attribute should be deleted before calling `inject`. + + Returns: + A set with the fields set in `inject`. + """ diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..996576c3ee0b4a0dbde85da03504880c232b23a8 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/__init__.py @@ -0,0 +1,667 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +The OpenTelemetry tracing API describes the classes used to generate +distributed traces. + +The :class:`.Tracer` class controls access to the execution context, and +manages span creation. Each operation in a trace is represented by a +:class:`.Span`, which records the start, end time, and metadata associated with +the operation. + +This module provides abstract (i.e. unimplemented) classes required for +tracing, and a concrete no-op :class:`.NonRecordingSpan` that allows applications +to use the API package alone without a supporting implementation. + +To get a tracer, you need to provide the package name from which you are +calling the tracer APIs to OpenTelemetry by calling `TracerProvider.get_tracer` +with the calling module name and the version of your package. + +The tracer supports creating spans that are "attached" or "detached" from the +context. New spans are "attached" to the context in that they are +created as children of the currently active span, and the newly-created span +can optionally become the new active span:: + + from opentelemetry import trace + + tracer = trace.get_tracer(__name__) + + # Create a new root span, set it as the current span in context + with tracer.start_as_current_span("parent"): + # Attach a new child and update the current span + with tracer.start_as_current_span("child"): + do_work(): + # Close child span, set parent as current + # Close parent span, set default span as current + +When creating a span that's "detached" from the context the active span doesn't +change, and the caller is responsible for managing the span's lifetime:: + + # Explicit parent span assignment is done via the Context + from opentelemetry.trace import set_span_in_context + + context = set_span_in_context(parent) + child = tracer.start_span("child", context=context) + + try: + do_work(span=child) + finally: + child.end() + +Applications should generally use a single global TracerProvider, and use +either implicit or explicit context propagation consistently throughout. + +.. versionadded:: 0.1.0 +.. versionchanged:: 0.3.0 + `TracerProvider` was introduced and the global ``tracer`` getter was + replaced by ``tracer_provider``. +.. versionchanged:: 0.5.0 + ``tracer_provider`` was replaced by `get_tracer_provider`, + ``set_preferred_tracer_provider_implementation`` was replaced by + `set_tracer_provider`. +""" + +import os +from abc import ABC, abstractmethod +from collections.abc import Iterator, Sequence +from enum import Enum +from logging import getLogger +from typing import cast + +from typing_extensions import deprecated + +from opentelemetry import context as context_api +from opentelemetry.attributes import BoundedAttributes +from opentelemetry.context.context import Context +from opentelemetry.environment_variables import OTEL_PYTHON_TRACER_PROVIDER +from opentelemetry.trace.propagation import ( + _SPAN_KEY, + get_current_span, + set_span_in_context, +) +from opentelemetry.trace.span import ( + DEFAULT_TRACE_OPTIONS, + DEFAULT_TRACE_STATE, + INVALID_SPAN, + INVALID_SPAN_CONTEXT, + INVALID_SPAN_ID, + INVALID_TRACE_ID, + NonRecordingSpan, + Span, + SpanContext, + TraceFlags, + TraceState, + format_span_id, + format_trace_id, +) +from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.util import types +from opentelemetry.util._decorator import _agnosticcontextmanager +from opentelemetry.util._once import Once +from opentelemetry.util._providers import _load_provider + +logger = getLogger(__name__) + + +class _LinkBase(ABC): + def __init__(self, context: "SpanContext") -> None: + self._context = context + + @property + def context(self) -> "SpanContext": + return self._context + + @property + @abstractmethod + def attributes(self) -> types.Attributes: + pass + + +class Link(_LinkBase): + """A link to a `Span`. The attributes of a Link are immutable. + + Args: + context: `SpanContext` of the `Span` to link to. + attributes: Link's attributes. + """ + + def __init__( + self, + context: "SpanContext", + attributes: types.Attributes = None, + ) -> None: + super().__init__(context) + self._attributes = attributes + + @property + def attributes(self) -> types.Attributes: + return self._attributes + + @property + def dropped_attributes(self) -> int: + if isinstance(self._attributes, BoundedAttributes): + return self._attributes.dropped + return 0 + + +_Links = Sequence[Link] | None + + +class SpanKind(Enum): + """Specifies additional details on how this span relates to its parent span. + + Note that this enumeration is experimental and likely to change. See + https://github.com/open-telemetry/opentelemetry-specification/pull/226. + """ + + #: Default value. Indicates that the span is used internally in the + # application. + INTERNAL = 0 + + #: Indicates that the span describes an operation that handles a remote + # request. + SERVER = 1 + + #: Indicates that the span describes a request to some remote service. + CLIENT = 2 + + #: Indicates that the span describes a producer sending a message to a + #: broker. Unlike client and server, there is usually no direct critical + #: path latency relationship between producer and consumer spans. + PRODUCER = 3 + + #: Indicates that the span describes a consumer receiving a message from a + #: broker. Unlike client and server, there is usually no direct critical + #: path latency relationship between producer and consumer spans. + CONSUMER = 4 + + +class TracerProvider(ABC): + @abstractmethod + def get_tracer( + self, + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + schema_url: str | None = None, + attributes: types.Attributes | None = None, + ) -> "Tracer": + """Returns a `Tracer` for use by the given instrumentation library. + + For any two calls it is undefined whether the same or different + `Tracer` instances are returned, even for different library names. + + This function may return different `Tracer` types (e.g. a no-op tracer + vs. a functional tracer). + + Args: + instrumenting_module_name: The uniquely identifiable name for instrumentation + scope, such as instrumentation library, package, module or class name. + ``__name__`` should be avoided as this can result in + different tracer names if the tracers are in different files. + It is better to use a fixed string that can be imported where + needed and used consistently as the name of the tracer. + + This should *not* be the name of the module that is + instrumented but the name of the module doing the instrumentation. + E.g., instead of ``"requests"``, use + ``"opentelemetry.instrumentation.requests"``. + + instrumenting_library_version: Optional. The version string of the + instrumenting library. Usually this should be the same as + ``importlib.metadata.version(instrumenting_library_name)``. + + schema_url: Optional. Specifies the Schema URL of the emitted telemetry. + attributes: Optional. Specifies the attributes of the emitted telemetry. + """ + + +class NoOpTracerProvider(TracerProvider): + """The default TracerProvider, used when no implementation is available. + + All operations are no-op. + """ + + def get_tracer( + self, + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + schema_url: str | None = None, + attributes: types.Attributes | None = None, + ) -> "Tracer": + # pylint:disable=no-self-use,unused-argument + return NoOpTracer() + + +@deprecated( + "You should use NoOpTracerProvider. Deprecated since version 1.9.0." +) +class _DefaultTracerProvider(NoOpTracerProvider): + """The default TracerProvider, used when no implementation is available. + + All operations are no-op. + """ + + +class ProxyTracerProvider(TracerProvider): + def get_tracer( + self, + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + schema_url: str | None = None, + attributes: types.Attributes | None = None, + ) -> "Tracer": + if _TRACER_PROVIDER: + return _TRACER_PROVIDER.get_tracer( + instrumenting_module_name, + instrumenting_library_version, + schema_url, + attributes, + ) + return ProxyTracer( + instrumenting_module_name, + instrumenting_library_version, + schema_url, + attributes, + ) + + +class Tracer(ABC): + """Handles span creation and in-process context propagation. + + This class provides methods for manipulating the context, creating spans, + and controlling spans' lifecycles. + """ + + @abstractmethod + def start_span( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + ) -> "Span": + """Starts a span. + + Create a new span. Start the span without setting it as the current + span in the context. To start the span and use the context in a single + method, see :meth:`start_as_current_span`. + + By default the current span in the context will be used as parent, but an + explicit context can also be specified, by passing in a `Context` containing + a current `Span`. If there is no current span in the global `Context` or in + the specified context, the created span will be a root span. + + The span can be used as a context manager. On exiting the context manager, + the span's end() method will be called. + + Example:: + + # trace.get_current_span() will be used as the implicit parent. + # If none is found, the created span will be a root instance. + with tracer.start_span("one") as child: + child.add_event("child's event") + + Args: + name: The name of the span to be created. + context: An optional Context containing the span's parent. Defaults to the + global context. + kind: The span's kind (relationship to parent). Note that is + meaningful even if there is no parent. + attributes: The span's attributes. + links: Links span to other spans + start_time: Sets the start time of a span + record_exception: Whether to record any exceptions raised within the + context as error event on the span. + set_status_on_exception: Only relevant if the returned span is used + in a with/context manager. Defines whether the span status will + be automatically set to ERROR when an uncaught exception is + raised in the span with block. The span status won't be set by + this mechanism if it was previously set manually. + + Returns: + The newly-created span. + """ + + @_agnosticcontextmanager + @abstractmethod + def start_as_current_span( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + end_on_exit: bool = True, + ) -> Iterator["Span"]: + """Context manager for creating a new span and set it + as the current span in this tracer's context. + + Exiting the context manager will call the span's end method, + as well as return the current span to its previous value by + returning to the previous context. + + Example:: + + with tracer.start_as_current_span("one") as parent: + parent.add_event("parent's event") + with tracer.start_as_current_span("two") as child: + child.add_event("child's event") + trace.get_current_span() # returns child + trace.get_current_span() # returns parent + trace.get_current_span() # returns previously active span + + This is a convenience method for creating spans attached to the + tracer's context. Applications that need more control over the span + lifetime should use :meth:`start_span` instead. For example:: + + with tracer.start_as_current_span(name) as span: + do_work() + + is equivalent to:: + + span = tracer.start_span(name) + with opentelemetry.trace.use_span(span, end_on_exit=True): + do_work() + + This can also be used as a decorator:: + + @tracer.start_as_current_span("name") + def function(): + ... + + function() + + Args: + name: The name of the span to be created. + context: An optional Context containing the span's parent. Defaults to the + global context. + kind: The span's kind (relationship to parent). Note that is + meaningful even if there is no parent. + attributes: The span's attributes. + links: Links span to other spans + start_time: Sets the start time of a span + record_exception: Whether to record any exceptions raised within the + context as error event on the span. + set_status_on_exception: Only relevant if the returned span is used + in a with/context manager. Defines whether the span status will + be automatically set to ERROR when an uncaught exception is + raised in the span with block. The span status won't be set by + this mechanism if it was previously set manually. + end_on_exit: Whether to end the span automatically when leaving the + context manager. + + Yields: + The newly-created span. + """ + + +class ProxyTracer(Tracer): + # pylint: disable=W0222,signature-differs + def __init__( + self, + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + schema_url: str | None = None, + attributes: types.Attributes | None = None, + ): + self._instrumenting_module_name = instrumenting_module_name + self._instrumenting_library_version = instrumenting_library_version + self._schema_url = schema_url + self._attributes = attributes + self._real_tracer: Tracer | None = None + self._noop_tracer = NoOpTracer() + + @property + def _tracer(self) -> Tracer: + if self._real_tracer: + return self._real_tracer + + if _TRACER_PROVIDER: + self._real_tracer = _TRACER_PROVIDER.get_tracer( + self._instrumenting_module_name, + self._instrumenting_library_version, + self._schema_url, + self._attributes, + ) + return self._real_tracer + return self._noop_tracer + + def start_span(self, *args, **kwargs) -> Span: # type: ignore + return self._tracer.start_span(*args, **kwargs) # type: ignore + + @_agnosticcontextmanager # type: ignore + def start_as_current_span(self, *args, **kwargs) -> Iterator[Span]: + with self._tracer.start_as_current_span(*args, **kwargs) as span: # type: ignore + yield span + + +class NoOpTracer(Tracer): + """The default Tracer, used when no Tracer implementation is available. + + All operations are no-op. + """ + + def start_span( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + ) -> "Span": + current_span = get_current_span(context) + if isinstance(current_span, NonRecordingSpan): + return current_span + parent_span_context = current_span.get_span_context() + if parent_span_context is not None and not isinstance( + parent_span_context, SpanContext + ): + logger.warning( + "Invalid span context for %s: %s", + current_span, + parent_span_context, + ) + return INVALID_SPAN + + return NonRecordingSpan(context=parent_span_context) + + @_agnosticcontextmanager + def start_as_current_span( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + end_on_exit: bool = True, + ) -> Iterator["Span"]: + span = self.start_span( + name=name, + context=context, + kind=kind, + attributes=attributes, + links=links, + start_time=start_time, + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) + with use_span( + span, + end_on_exit=end_on_exit, + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) as span: + yield span + + +@deprecated("You should use NoOpTracer. Deprecated since version 1.9.0.") +class _DefaultTracer(NoOpTracer): + """The default Tracer, used when no Tracer implementation is available. + + All operations are no-op. + """ + + +_TRACER_PROVIDER_SET_ONCE = Once() +_TRACER_PROVIDER: TracerProvider | None = None +_PROXY_TRACER_PROVIDER = ProxyTracerProvider() + + +def get_tracer( + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + tracer_provider: TracerProvider | None = None, + schema_url: str | None = None, + attributes: types.Attributes | None = None, +) -> "Tracer": + """Returns a `Tracer` for use by the given instrumentation library. + + This function is a convenience wrapper for + opentelemetry.trace.TracerProvider.get_tracer. + + If tracer_provider is omitted the current configured one is used. + """ + if tracer_provider is None: + tracer_provider = get_tracer_provider() + return tracer_provider.get_tracer( + instrumenting_module_name, + instrumenting_library_version, + schema_url, + attributes, + ) + + +def _set_tracer_provider(tracer_provider: TracerProvider, log: bool) -> None: + def set_tp() -> None: + global _TRACER_PROVIDER # pylint: disable=global-statement + _TRACER_PROVIDER = tracer_provider + + did_set = _TRACER_PROVIDER_SET_ONCE.do_once(set_tp) + + if log and not did_set: + logger.warning("Overriding of current TracerProvider is not allowed") + + +def set_tracer_provider(tracer_provider: TracerProvider) -> None: + """Sets the current global :class:`~.TracerProvider` object. + + This can only be done once, a warning will be logged if any further attempt + is made. + """ + _set_tracer_provider(tracer_provider, log=True) + + +def get_tracer_provider() -> TracerProvider: + """Gets the current global :class:`~.TracerProvider` object.""" + if _TRACER_PROVIDER is None: + # if a global tracer provider has not been set either via code or env + # vars, return a proxy tracer provider + if OTEL_PYTHON_TRACER_PROVIDER not in os.environ: + return _PROXY_TRACER_PROVIDER + + tracer_provider: TracerProvider = _load_provider( + OTEL_PYTHON_TRACER_PROVIDER, "tracer_provider" + ) + _set_tracer_provider(tracer_provider, log=False) + # _TRACER_PROVIDER will have been set by one thread + return cast("TracerProvider", _TRACER_PROVIDER) + + +@_agnosticcontextmanager +def use_span( + span: Span, + end_on_exit: bool = False, + record_exception: bool = True, + set_status_on_exception: bool = True, +) -> Iterator[Span]: + """Takes a non-active span and activates it in the current context. + + Args: + span: The span that should be activated in the current context. + end_on_exit: Whether to end the span automatically when leaving the + context manager scope. + record_exception: Whether to record any exceptions raised within the + context as error event on the span. + set_status_on_exception: Only relevant if the returned span is used + in a with/context manager. Defines whether the span status will + be automatically set to ERROR when an uncaught exception is + raised in the span with block. The span status won't be set by + this mechanism if it was previously set manually. + """ + try: + token = context_api.attach(context_api.set_value(_SPAN_KEY, span)) + try: + yield span + finally: + context_api.detach(token) + + # Record only exceptions that inherit Exception class but not BaseException, because + # classes that directly inherit BaseException are not technically errors, e.g. GeneratorExit. + # See https://github.com/open-telemetry/opentelemetry-python/issues/4484 + except Exception as exc: # pylint: disable=broad-exception-caught + if isinstance(span, Span) and span.is_recording(): + # Record the exception as an event + if record_exception: + span.record_exception(exc) + + # Set status in case exception was raised + if set_status_on_exception: + span.set_status( + Status( + status_code=StatusCode.ERROR, + description=f"{type(exc).__name__}: {exc}", + ) + ) + + # This causes parent spans to set their status to ERROR and to record + # an exception as an event if a child span raises an exception even if + # such child span was started with both record_exception and + # set_status_on_exception attributes set to False. + raise + + finally: + if end_on_exit: + span.end() + + +__all__ = [ + "DEFAULT_TRACE_OPTIONS", + "DEFAULT_TRACE_STATE", + "INVALID_SPAN", + "INVALID_SPAN_CONTEXT", + "INVALID_SPAN_ID", + "INVALID_TRACE_ID", + "NonRecordingSpan", + "Link", + "Span", + "SpanContext", + "SpanKind", + "TraceFlags", + "TraceState", + "TracerProvider", + "Tracer", + "format_span_id", + "format_trace_id", + "get_current_span", + "get_tracer", + "get_tracer_provider", + "set_tracer_provider", + "set_span_in_context", + "use_span", + "Status", + "StatusCode", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/propagation/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/propagation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..93a09160a81710a3b4540b980ec2d7a296f5718d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/propagation/__init__.py @@ -0,0 +1,37 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from opentelemetry.context import create_key, get_value, set_value +from opentelemetry.context.context import Context +from opentelemetry.trace.span import INVALID_SPAN, Span + +SPAN_KEY = "current-span" +_SPAN_KEY = create_key("current-span") + + +def set_span_in_context(span: Span, context: Context | None = None) -> Context: + """Set the span in the given context. + + Args: + span: The Span to set. + context: a Context object. if one is not passed, the + default current context is used instead. + """ + ctx = set_value(_SPAN_KEY, span, context=context) + return ctx + + +def get_current_span(context: Context | None = None) -> Span: + """Retrieve the current span. + + Args: + context: A Context object. If one is not passed, the + default current context is used instead. + + Returns: + The Span set in the context if it exists. INVALID_SPAN otherwise. + """ + span = get_value(_SPAN_KEY, context=context) + if span is None or not isinstance(span, Span): + return INVALID_SPAN + return span diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/propagation/tracecontext.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/propagation/tracecontext.py new file mode 100644 index 0000000000000000000000000000000000000000..97c64d93737969befe0430e56fd8339cb1da1889 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/propagation/tracecontext.py @@ -0,0 +1,106 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# +import re + +from opentelemetry import trace +from opentelemetry.context.context import Context +from opentelemetry.propagators import textmap +from opentelemetry.trace import format_span_id, format_trace_id +from opentelemetry.trace.span import TraceState + + +class TraceContextTextMapPropagator(textmap.TextMapPropagator): + """Extracts and injects using w3c TraceContext's headers.""" + + _TRACEPARENT_HEADER_NAME = "traceparent" + _TRACESTATE_HEADER_NAME = "tracestate" + _TRACEPARENT_HEADER_FORMAT = ( + "^[ \t]*([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})" + + "(-.*)?[ \t]*$" + ) + _TRACEPARENT_HEADER_FORMAT_RE = re.compile(_TRACEPARENT_HEADER_FORMAT) + + def extract( + self, + carrier: textmap.CarrierT, + context: Context | None = None, + getter: textmap.Getter[textmap.CarrierT] = textmap.default_getter, + ) -> Context: + """Extracts SpanContext from the carrier. + + See `opentelemetry.propagators.textmap.TextMapPropagator.extract` + """ + if context is None: + context = Context() + + header = getter.get(carrier, self._TRACEPARENT_HEADER_NAME) + + if not header: + return context + + match = re.search(self._TRACEPARENT_HEADER_FORMAT_RE, header[0]) + if not match: + return context + + version: str = match.group(1) + trace_id: str = match.group(2) + span_id: str = match.group(3) + trace_flags: str = match.group(4) + + if trace_id == "0" * 32 or span_id == "0" * 16: + return context + + if version == "00": + if match.group(5): # type: ignore + return context + if version == "ff": + return context + + tracestate_headers = getter.get(carrier, self._TRACESTATE_HEADER_NAME) + if tracestate_headers is None: + tracestate = None + else: + tracestate = TraceState.from_header(tracestate_headers) + + span_context = trace.SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + is_remote=True, + trace_flags=trace.TraceFlags(int(trace_flags, 16)), + trace_state=tracestate, + ) + return trace.set_span_in_context( + trace.NonRecordingSpan(span_context), context + ) + + def inject( + self, + carrier: textmap.CarrierT, + context: Context | None = None, + setter: textmap.Setter[textmap.CarrierT] = textmap.default_setter, + ) -> None: + """Injects SpanContext into the carrier. + + See `opentelemetry.propagators.textmap.TextMapPropagator.inject` + """ + span = trace.get_current_span(context) + span_context = span.get_span_context() + if span_context == trace.INVALID_SPAN_CONTEXT: + return + traceparent_string = f"00-{format_trace_id(span_context.trace_id)}-{format_span_id(span_context.span_id)}-{span_context.trace_flags:02x}" + setter.set(carrier, self._TRACEPARENT_HEADER_NAME, traceparent_string) + if span_context.trace_state: + tracestate_string = span_context.trace_state.to_header() + setter.set( + carrier, self._TRACESTATE_HEADER_NAME, tracestate_string + ) + + @property + def fields(self) -> set[str]: + """Returns a set with the fields set in `inject`. + + See + `opentelemetry.propagators.textmap.TextMapPropagator.fields` + """ + return {self._TRACEPARENT_HEADER_NAME, self._TRACESTATE_HEADER_NAME} diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/span.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/span.py new file mode 100644 index 0000000000000000000000000000000000000000..23a24f16782d34216229e7cabaea5cd4bdb47e7d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/span.py @@ -0,0 +1,618 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import abc +import logging +import re +import types as python_types +import typing +import warnings +from collections.abc import Iterator, Mapping, Sequence + +from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.util import types + +# The key MUST begin with a lowercase letter or a digit, +# and can only contain lowercase letters (a-z), digits (0-9), +# underscores (_), dashes (-), asterisks (*), and forward slashes (/). +# For multi-tenant vendor scenarios, an at sign (@) can be used to +# prefix the vendor name. Vendors SHOULD set the tenant ID +# at the beginning of the key. + +# key = ( lcalpha ) 0*255( lcalpha / DIGIT / "_" / "-"/ "*" / "/" ) +# key = ( lcalpha / DIGIT ) 0*240( lcalpha / DIGIT / "_" / "-"/ "*" / "/" ) "@" lcalpha 0*13( lcalpha / DIGIT / "_" / "-"/ "*" / "/" ) +# lcalpha = %x61-7A ; a-z + +_KEY_FORMAT = ( + r"[a-z][_0-9a-z\-\*\/]{0,255}|" + r"[a-z0-9][_0-9a-z\-\*\/]{0,240}@[a-z][_0-9a-z\-\*\/]{0,13}" +) +_KEY_PATTERN = re.compile(_KEY_FORMAT) + +# The value is an opaque string containing up to 256 printable +# ASCII [RFC0020] characters (i.e., the range 0x20 to 0x7E) +# except comma (,) and (=). +# value = 0*255(chr) nblk-chr +# nblk-chr = %x21-2B / %x2D-3C / %x3E-7E +# chr = %x20 / nblk-chr + +_VALUE_FORMAT = ( + r"[\x20-\x2b\x2d-\x3c\x3e-\x7e]{0,255}[\x21-\x2b\x2d-\x3c\x3e-\x7e]" +) +_VALUE_PATTERN = re.compile(_VALUE_FORMAT) + + +_TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS = 32 +_delimiter_pattern = re.compile(r"[ \t]*,[ \t]*") +_member_pattern = re.compile(f"({_KEY_FORMAT})(=)({_VALUE_FORMAT})[ \t]*") +_logger = logging.getLogger(__name__) + + +def _is_valid_pair(key: str, value: str) -> bool: + return ( + isinstance(key, str) + and _KEY_PATTERN.fullmatch(key) is not None + and isinstance(value, str) + and _VALUE_PATTERN.fullmatch(value) is not None + ) + + +class Span(abc.ABC): + """A span represents a single operation within a trace.""" + + @abc.abstractmethod + def end(self, end_time: int | None = None) -> None: + """Sets the current time as the span's end time. + + The span's end time is the wall time at which the operation finished. + + Only the first call to `end` should modify the span, and + implementations are free to ignore or raise on further calls. + """ + + @abc.abstractmethod + def get_span_context(self) -> SpanContext: + """Gets the span's SpanContext. + + Get an immutable, serializable identifier for this span that can be + used to create new child spans. + + Returns: + A :class:`opentelemetry.trace.SpanContext` with a copy of this span's immutable state. + """ + + @abc.abstractmethod + def set_attributes( + self, attributes: Mapping[str, types.AttributeValue] + ) -> None: + """Sets Attributes. + + Sets Attributes with the key and value passed as arguments dict. + + Note: The behavior of `None` value attributes is undefined, and hence + strongly discouraged. It is also preferred to set attributes at span + creation, instead of calling this method later since samplers can only + consider information already present during span creation. + """ + + @abc.abstractmethod + def set_attribute(self, key: str, value: types.AttributeValue) -> None: + """Sets an Attribute. + + Sets a single Attribute with the key and value passed as arguments. + + Note: The behavior of `None` value attributes is undefined, and hence + strongly discouraged. It is also preferred to set attributes at span + creation, instead of calling this method later since samplers can only + consider information already present during span creation. + """ + + @abc.abstractmethod + def add_event( + self, + name: str, + attributes: types.Attributes = None, + timestamp: int | None = None, + ) -> None: + """Adds an `Event`. + + Adds a single `Event` with the name and, optionally, a timestamp and + attributes passed as arguments. Implementations should generate a + timestamp if the `timestamp` argument is omitted. + """ + + def add_link( # pylint: disable=no-self-use + self, + context: SpanContext, + attributes: types.Attributes = None, + ) -> None: + """Adds a `Link`. + + Adds a single `Link` with the `SpanContext` of the span to link to and, + optionally, attributes passed as arguments. Implementations may ignore + calls with an invalid span context if both attributes and TraceState + are empty. + + Note: It is preferred to add links at span creation, instead of calling + this method later since samplers can only consider information already + present during span creation. + """ + warnings.warn( + "Span.add_link() not implemented and will be a no-op. " + "Use opentelemetry-sdk >= 1.23 to add links after span creation" + ) + + @abc.abstractmethod + def update_name(self, name: str) -> None: + """Updates the `Span` name. + + This will override the name provided via :func:`opentelemetry.trace.Tracer.start_span`. + + Upon this update, any sampling behavior based on Span name will depend + on the implementation. + """ + + @abc.abstractmethod + def is_recording(self) -> bool: + """Returns whether this span will be recorded. + + Returns true if this Span is active and recording information like + events with the add_event operation and attributes using set_attribute. + """ + + @abc.abstractmethod + def set_status( + self, + status: Status | StatusCode, + description: str | None = None, + ) -> None: + """Sets the Status of the Span. If used, this will override the default + Span status. + """ + + @abc.abstractmethod + def record_exception( + self, + exception: BaseException, + attributes: types.Attributes = None, + timestamp: int | None = None, + escaped: bool = False, + ) -> None: + """Records an exception as a span event.""" + + def __enter__(self) -> Span: + """Invoked when `Span` is used as a context manager. + + Returns the `Span` itself. + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: python_types.TracebackType | None, + ) -> None: + """Ends context manager and calls `end` on the `Span`.""" + + self.end() + + +class TraceFlags(int): + """A bitmask that represents options specific to the trace. + + Supported flags: + - "sampled" (``0x01``): Indicates the trace may have been sampled upstream. + - "random-trace-id" (``0x02``): Indicates the trace ID was generated + randomly, with at least the 7 rightmost bytes (56 bits) selected with + uniform distribution. + + See the `W3C Trace Context - Traceparent`_ spec for details. + + .. _W3C Trace Context - Traceparent: + https://www.w3.org/TR/trace-context-2/#trace-flags + """ + + DEFAULT = 0x00 + SAMPLED = 0x01 + RANDOM_TRACE_ID = 0x02 + + @classmethod + def get_default(cls) -> TraceFlags: + return cls(cls.DEFAULT) + + @property + def sampled(self) -> bool: + return bool(self & TraceFlags.SAMPLED) + + @property + def random_trace_id(self) -> bool: + return bool(self & TraceFlags.RANDOM_TRACE_ID) + + +DEFAULT_TRACE_OPTIONS = TraceFlags.get_default() + + +class TraceState(Mapping[str, str]): + """A list of key-value pairs representing vendor-specific trace info. + + Keys and values are strings of up to 256 printable US-ASCII characters. + Implementations should conform to the `W3C Trace Context - Tracestate`_ + spec, which describes additional restrictions on valid field values. + + .. _W3C Trace Context - Tracestate: + https://www.w3.org/TR/trace-context/#tracestate-field + """ + + def __init__( + self, + entries: Sequence[tuple[str, str]] | None = None, + ) -> None: + self._dict = {} # type: dict[str, str] + if entries is None: + return + if len(entries) > _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS: + _logger.warning( + "There can't be more than %s key/value pairs.", + _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS, + ) + return + + for key, value in entries: + if _is_valid_pair(key, value): + if key in self._dict: + _logger.warning("Duplicate key: %s found.", key) + continue + self._dict[key] = value + else: + _logger.warning( + "Invalid key/value pair (%s, %s) found.", key, value + ) + + def __contains__(self, item: object) -> bool: + return item in self._dict + + def __getitem__(self, key: str) -> str: + return self._dict[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._dict) + + def __len__(self) -> int: + return len(self._dict) + + def __repr__(self) -> str: + pairs = [ + f"{{key={key}, value={value}}}" + for key, value in self._dict.items() + ] + return str(pairs) + + def add(self, key: str, value: str) -> TraceState: + """Adds a key-value pair to tracestate. The provided pair should + adhere to w3c tracestate identifiers format. + + Args: + key: A valid tracestate key to add + value: A valid tracestate value to add + + Returns: + A new TraceState with the modifications applied. + + If the provided key-value pair is invalid or results in tracestate + that violates tracecontext specification, they are discarded and + same tracestate will be returned. + """ + if not _is_valid_pair(key, value): + _logger.warning( + "Invalid key/value pair (%s, %s) found.", key, value + ) + return self + # There can be a maximum of 32 pairs + if len(self) >= _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS: + _logger.warning("There can't be more 32 key/value pairs.") + return self + # Duplicate entries are not allowed + if key in self._dict: + _logger.warning("The provided key %s already exists.", key) + return self + new_state = [(key, value)] + list(self._dict.items()) + return TraceState(new_state) + + def update(self, key: str, value: str) -> TraceState: + """Updates a key-value pair in tracestate. The provided pair should + adhere to w3c tracestate identifiers format. + + Args: + key: A valid tracestate key to update + value: A valid tracestate value to update for key + + Returns: + A new TraceState with the modifications applied. + + If the provided key-value pair is invalid or results in tracestate + that violates tracecontext specification, they are discarded and + same tracestate will be returned. + """ + if not _is_valid_pair(key, value): + _logger.warning( + "Invalid key/value pair (%s, %s) found.", key, value + ) + return self + prev_state = self._dict.copy() + prev_state.pop(key, None) + new_state = [(key, value), *prev_state.items()] + return TraceState(new_state) + + def delete(self, key: str) -> TraceState: + """Deletes a key-value from tracestate. + + Args: + key: A valid tracestate key to remove key-value pair from tracestate + + Returns: + A new TraceState with the modifications applied. + + If the provided key-value pair is invalid or results in tracestate + that violates tracecontext specification, they are discarded and + same tracestate will be returned. + """ + if key not in self._dict: + _logger.warning("The provided key %s doesn't exist.", key) + return self + prev_state = self._dict.copy() + prev_state.pop(key) + new_state = list(prev_state.items()) + return TraceState(new_state) + + def to_header(self) -> str: + """Creates a w3c tracestate header from a TraceState. + + Returns: + A string that adheres to the w3c tracestate + header format. + """ + return ",".join(key + "=" + value for key, value in self._dict.items()) + + @classmethod + def from_header(cls, header_list: list[str]) -> TraceState: + """Parses one or more w3c tracestate header into a TraceState. + + Args: + header_list: one or more w3c tracestate headers. + + Returns: + A valid TraceState that contains values extracted from + the tracestate header. + + If the format of one headers is illegal, all values will + be discarded and an empty tracestate will be returned. + + If the number of keys is beyond the maximum, all values + will be discarded and an empty tracestate will be returned. + """ + pairs = {} # type: dict[str, str] + for header in header_list: + members: list[str] = re.split(_delimiter_pattern, header) + for member in members: + # empty members are valid, but no need to process further. + if not member: + continue + match = _member_pattern.fullmatch(member) + if not match: + _logger.warning( + "Member doesn't match the w3c identifiers format %s", + member, + ) + return cls() + groups: tuple[str, ...] = match.groups() + key, _eq, value = groups + # duplicate keys are not legal in header + if key in pairs: + return cls() + pairs[key] = value + return cls(list(pairs.items())) + + @classmethod + def get_default(cls) -> TraceState: + return cls() + + def keys(self) -> typing.KeysView[str]: + return self._dict.keys() + + def items(self) -> typing.ItemsView[str, str]: + return self._dict.items() + + def values(self) -> typing.ValuesView[str]: + return self._dict.values() + + +DEFAULT_TRACE_STATE = TraceState.get_default() +_TRACE_ID_MAX_VALUE = 2**128 - 1 +_SPAN_ID_MAX_VALUE = 2**64 - 1 + + +class SpanContext(tuple[int, int, bool, "TraceFlags", "TraceState", bool]): + """The state of a Span to propagate between processes. + + This class includes the immutable attributes of a :class:`.Span` that must + be propagated to a span's children and across process boundaries. + + Args: + trace_id: The ID of the trace that this span belongs to. + span_id: This span's ID. + is_remote: True if propagated from a remote parent. + trace_flags: Trace options to propagate. + trace_state: Tracing-system-specific info to propagate. + """ + + def __new__( + cls, + trace_id: int, + span_id: int, + is_remote: bool, + trace_flags: TraceFlags | None = DEFAULT_TRACE_OPTIONS, + trace_state: TraceState | None = DEFAULT_TRACE_STATE, + ) -> SpanContext: + if trace_flags is None: + trace_flags = DEFAULT_TRACE_OPTIONS + if trace_state is None: + trace_state = DEFAULT_TRACE_STATE + + is_valid = ( + INVALID_TRACE_ID < trace_id <= _TRACE_ID_MAX_VALUE + and INVALID_SPAN_ID < span_id <= _SPAN_ID_MAX_VALUE + ) + + return tuple.__new__( + cls, + (trace_id, span_id, is_remote, trace_flags, trace_state, is_valid), + ) + + def __getnewargs__( + self, + ) -> tuple[int, int, bool, TraceFlags, TraceState]: + return ( + self.trace_id, + self.span_id, + self.is_remote, + self.trace_flags, + self.trace_state, + ) + + @property + def trace_id(self) -> int: + return self[0] # pylint: disable=unsubscriptable-object + + @property + def span_id(self) -> int: + return self[1] # pylint: disable=unsubscriptable-object + + @property + def is_remote(self) -> bool: + return self[2] # pylint: disable=unsubscriptable-object + + @property + def trace_flags(self) -> TraceFlags: + return self[3] # pylint: disable=unsubscriptable-object + + @property + def trace_state(self) -> TraceState: + return self[4] # pylint: disable=unsubscriptable-object + + @property + def is_valid(self) -> bool: + return self[5] # pylint: disable=unsubscriptable-object + + def __setattr__(self, *args: str) -> None: + _logger.debug( + "Immutable type, ignoring call to set attribute", stack_info=True + ) + + def __delattr__(self, *args: str) -> None: + _logger.debug( + "Immutable type, ignoring call to set attribute", stack_info=True + ) + + def __repr__(self) -> str: + return f"{type(self).__name__}(trace_id=0x{format_trace_id(self.trace_id)}, span_id=0x{format_span_id(self.span_id)}, trace_flags=0x{self.trace_flags:02x}, trace_state={self.trace_state!r}, is_remote={self.is_remote})" + + +class NonRecordingSpan(Span): + """The Span that is used when no Span implementation is available. + + All operations are no-op except context propagation. + """ + + def __init__(self, context: SpanContext) -> None: + self._context = context + + def get_span_context(self) -> SpanContext: + return self._context + + def is_recording(self) -> bool: + return False + + def end(self, end_time: int | None = None) -> None: + pass + + def set_attributes( + self, attributes: Mapping[str, types.AttributeValue] + ) -> None: + pass + + def set_attribute(self, key: str, value: types.AttributeValue) -> None: + pass + + def add_event( + self, + name: str, + attributes: types.Attributes = None, + timestamp: int | None = None, + ) -> None: + pass + + def add_link( + self, + context: SpanContext, + attributes: types.Attributes = None, + ) -> None: + pass + + def update_name(self, name: str) -> None: + pass + + def set_status( + self, + status: Status | StatusCode, + description: str | None = None, + ) -> None: + pass + + def record_exception( + self, + exception: BaseException, + attributes: types.Attributes = None, + timestamp: int | None = None, + escaped: bool = False, + ) -> None: + pass + + def __repr__(self) -> str: + return f"NonRecordingSpan({self._context!r})" + + +INVALID_SPAN_ID = 0x0000000000000000 +INVALID_TRACE_ID = 0x00000000000000000000000000000000 +INVALID_SPAN_CONTEXT = SpanContext( + trace_id=INVALID_TRACE_ID, + span_id=INVALID_SPAN_ID, + is_remote=False, + trace_flags=DEFAULT_TRACE_OPTIONS, + trace_state=DEFAULT_TRACE_STATE, +) +INVALID_SPAN = NonRecordingSpan(INVALID_SPAN_CONTEXT) + + +def format_trace_id(trace_id: int) -> str: + """Convenience trace ID formatting method + Args: + trace_id: Trace ID int + + Returns: + The trace ID (16 bytes) cast to a 32-character hexadecimal string + """ + return format(trace_id, "032x") + + +def format_span_id(span_id: int) -> str: + """Convenience span ID formatting method + Args: + span_id: Span ID int + + Returns: + The span ID (8 bytes) cast to a 16-character hexadecimal string + """ + return format(span_id, "016x") diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/status.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/status.py new file mode 100644 index 0000000000000000000000000000000000000000..1a814265361bf7b3320742f4d92606aca294ce6d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/trace/status.py @@ -0,0 +1,70 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import enum +import logging + +logger = logging.getLogger(__name__) + + +class StatusCode(enum.Enum): + """Represents the canonical set of status codes of a finished Span.""" + + UNSET = 0 + """The default status.""" + + OK = 1 + """The operation has been validated by an Application developer or Operator to have completed successfully.""" + + ERROR = 2 + """The operation contains an error.""" + + +class Status: + """Represents the status of a finished Span. + + Args: + status_code: The canonical status code that describes the result + status of the operation. + description: An optional description of the status. + """ + + def __init__( + self, + status_code: StatusCode = StatusCode.UNSET, + description: str | None = None, + ): + self._status_code = status_code + self._description = None + + if description: + if not isinstance(description, str): + logger.warning("Invalid status description type, expected str") + return + if status_code is not StatusCode.ERROR: + logger.warning( + "description should only be set when status_code is set to StatusCode.ERROR" + ) + return + + self._description = description + + @property + def status_code(self) -> StatusCode: + """Represents the canonical status code of a finished Span.""" + return self._status_code + + @property + def description(self) -> str | None: + """Status description""" + return self._description + + @property + def is_ok(self) -> bool: + """Returns false if this represents an error, true otherwise.""" + return self.is_unset or self._status_code is StatusCode.OK + + @property + def is_unset(self) -> bool: + """Returns true if unset, false otherwise.""" + return self._status_code is StatusCode.UNSET diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_decorator.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_decorator.py new file mode 100644 index 0000000000000000000000000000000000000000..b724b9f93fabcc143a6d6b4475a7565ed253b2ad --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_decorator.py @@ -0,0 +1,75 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import contextlib +import functools +import inspect +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Generic, TypeVar + +V = TypeVar("V") +R = TypeVar("R") # Return type +Pargs = TypeVar("Pargs") # Generic type for arguments +Pkwargs = TypeVar("Pkwargs") # Generic type for arguments + +# We don't actually depend on typing_extensions but we can use it in CI with this conditional +# import. ParamSpec can be imported directly from typing after python 3.9 is dropped +# https://peps.python.org/pep-0612/. +if TYPE_CHECKING: + from typing_extensions import ParamSpec + + P = ParamSpec("P") # Generic type for all arguments + + +class _AgnosticContextManager( + contextlib._GeneratorContextManager[R], + Generic[R], +): # pylint: disable=protected-access + """Context manager that can decorate both async and sync functions. + + This is an overridden version of the contextlib._GeneratorContextManager + class that will decorate async functions with an async context manager + to end the span AFTER the entire async function coroutine finishes. + + Else it will report near zero spans durations for async functions. + + We are overriding the contextlib._GeneratorContextManager class as + reimplementing it is a lot of code to maintain and this class (even if it's + marked as protected) doesn't seems like to be evolving a lot. + + For more information, see: + https://github.com/open-telemetry/opentelemetry-python/pull/3633 + """ + + def __enter__(self) -> R: + """Reimplementing __enter__ to avoid the type error. + + The original __enter__ method returns Any type, but we want to return R. + """ + del self.args, self.kwds, self.func # type: ignore + try: + return next(self.gen) # type: ignore + except StopIteration: + raise RuntimeError("generator didn't yield") from None + + def __call__(self, func: V) -> V: # pyright: ignore [reportIncompatibleMethodOverride] + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) # type: ignore + async def async_wrapper(*args: Pargs, **kwargs: Pkwargs) -> R: # pyright: ignore [reportInvalidTypeVarUse] + with self._recreate_cm(): # type: ignore + return await func(*args, **kwargs) # type: ignore + + return async_wrapper # type: ignore + return super().__call__(func) # type: ignore + + +def _agnosticcontextmanager( + func: "Callable[P, Iterator[R]]", +) -> "Callable[P, _AgnosticContextManager[R]]": + @functools.wraps(func) + def helper(*args: Pargs, **kwargs: Pkwargs) -> _AgnosticContextManager[R]: # pyright: ignore [reportInvalidTypeVarUse] + return _AgnosticContextManager(func, args, kwargs) # pyright: ignore [reportArgumentType] + + # Ignoring the type to keep the original signature of the function + return helper # type: ignore[return-value] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_importlib_metadata.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_importlib_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..7e685f595d730a939b9e7c5698511eacd56b7a91 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_importlib_metadata.py @@ -0,0 +1,63 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +Caching and compatibility wrapper for standard library ``importlib.metadata``. + +This module caches ``entry_points()`` to avoid reloading entry points from disk on every call. +It also normalizes minor differences across python versions 3.10+. References to issues: + +- https://github.com/open-telemetry/opentelemetry-python/pull/4735 +- https://github.com/open-telemetry/opentelemetry-python/pull/5203 +""" + +import itertools +from functools import cache +from importlib.metadata import ( + Distribution, + EntryPoint, + EntryPoints, + PackageNotFoundError, + distributions, + requires, + version, +) +from importlib.metadata import entry_points as original_entry_points +from typing import Any + + +def _as_entry_points(eps: Any) -> EntryPoints: + # Python versions greater than 3.11 return EntryPoints. + if isinstance(eps, EntryPoints): + return eps + # In Python 3.10 and 3.11 entry_points() returns + # a dict-like SelectableGroups object. + # Use dict.values() instead of eps.values() to avoid the DeprecationWarning + # that SelectableGroups raises when calling .values(). + if isinstance(eps, dict): + return EntryPoints(itertools.chain.from_iterable(dict.values(eps))) + # This case should be unreachable, but is included as a fallback. + return EntryPoints( + ep for group in eps.groups for ep in eps.select(group=group) + ) + + +@cache +def _original_entry_points_cached() -> EntryPoints: + return _as_entry_points(original_entry_points()) + + +def entry_points(**params) -> EntryPoints: + return _original_entry_points_cached().select(**params) + + +__all__ = [ + "entry_points", + "version", + "EntryPoint", + "EntryPoints", + "requires", + "Distribution", + "distributions", + "PackageNotFoundError", +] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_once.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_once.py new file mode 100644 index 0000000000000000000000000000000000000000..59f562385e64fa355a057850cb94572a0ba6005c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_once.py @@ -0,0 +1,36 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Callable +from threading import Lock + + +class Once: + """Execute a function exactly once and block all callers until the function returns + + Same as golang's `sync.Once `_ + """ + + def __init__(self) -> None: + self._lock = Lock() + self._done = False + + def do_once(self, func: Callable[[], None]) -> bool: + """Execute ``func`` if it hasn't been executed or return. + + Will block until ``func`` has been called by one thread. + + Returns: + Whether or not ``func`` was executed in this call + """ + + # fast path, try to avoid locking + if self._done: + return False + + with self._lock: + if not self._done: + func() + self._done = True + return True + return False diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_providers.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_providers.py new file mode 100644 index 0000000000000000000000000000000000000000..37cc823e395c679bf002234e18618ae39052f34a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/_providers.py @@ -0,0 +1,44 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from logging import getLogger +from os import environ +from typing import TYPE_CHECKING, TypeVar, cast + +if TYPE_CHECKING: + from opentelemetry.metrics import MeterProvider + from opentelemetry.trace import TracerProvider + +Provider = TypeVar("Provider", "TracerProvider", "MeterProvider") + +logger = getLogger(__name__) + + +def _load_provider( + provider_environment_variable: str, provider: str +) -> Provider: # type: ignore[type-var] + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.util._importlib_metadata import ( # noqa: PLC0415 + entry_points, + ) + + try: + provider_name = cast( + str, + environ.get(provider_environment_variable, f"default_{provider}"), + ) + + return cast( + Provider, + next( # type: ignore + iter( # type: ignore + entry_points( # type: ignore + group=f"opentelemetry_{provider}", + name=provider_name, + ) + ) + ).load()(), + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to load configured provider %s", provider) + raise diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/re.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/re.py new file mode 100644 index 0000000000000000000000000000000000000000..5ac7813341148fd2b02a495191fc363b116245f8 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/re.py @@ -0,0 +1,105 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Mapping +from logging import getLogger +from re import compile, split +from urllib.parse import unquote + +from typing_extensions import deprecated + +_logger = getLogger(__name__) + +# The following regexes reference this spec: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md#specifying-headers-via-environment-variables + +# Optional whitespace +_OWS = r"[ \t]*" +# A key contains printable US-ASCII characters except: SP and "(),/:;<=>?@[\]{} +_KEY_FORMAT = ( + r"[\x21\x23-\x27\x2a\x2b\x2d\x2e\x30-\x39\x41-\x5a\x5e-\x7a\x7c\x7e]+" +) +# A value contains a URL-encoded UTF-8 string. The encoded form can contain any +# printable US-ASCII characters (0x20-0x7f) other than SP, DEL, and ",;/ +_VALUE_FORMAT = r"[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*" +# Like above with SP included +_LIBERAL_VALUE_FORMAT = r"[\x20\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*" +# A key-value is key=value, with optional whitespace surrounding key and value +_KEY_VALUE_FORMAT = rf"{_OWS}{_KEY_FORMAT}{_OWS}={_OWS}{_VALUE_FORMAT}{_OWS}" + +_HEADER_PATTERN = compile(_KEY_VALUE_FORMAT) +_LIBERAL_HEADER_PATTERN = compile( + rf"{_OWS}{_KEY_FORMAT}{_OWS}={_OWS}{_LIBERAL_VALUE_FORMAT}{_OWS}" +) +_DELIMITER_PATTERN = compile(r"[ \t]*,[ \t]*") + +_BAGGAGE_PROPERTY_FORMAT = rf"{_KEY_VALUE_FORMAT}|{_OWS}{_KEY_FORMAT}{_OWS}" + +_INVALID_HEADER_ERROR_MESSAGE_STRICT_TEMPLATE = ( + "Header format invalid! Header values in environment variables must be " + "URL encoded per the OpenTelemetry Protocol Exporter specification: %s" +) + +_INVALID_HEADER_ERROR_MESSAGE_LIBERAL_TEMPLATE = ( + "Header format invalid! Header values in environment variables must be " + "URL encoded per the OpenTelemetry Protocol Exporter specification or " + "a comma separated list of name=value occurrences: %s" +) + +# pylint: disable=invalid-name + + +@deprecated( + "You should use parse_env_headers. Deprecated since version 1.15.0." +) +def parse_headers(s: str) -> Mapping[str, str]: + return parse_env_headers(s) + + +def parse_env_headers(s: str, liberal: bool = False) -> Mapping[str, str]: + """ + Parse ``s``, which is a ``str`` instance containing HTTP headers encoded + for use in ENV variables per the W3C Baggage HTTP header format at + https://www.w3.org/TR/baggage/#baggage-http-header-format, except that + additional semi-colon delimited metadata is not supported. + If ``liberal`` is True we try to parse ``s`` anyway to be more compatible + with other languages SDKs that accept non URL-encoded headers by default. + """ + headers: dict[str, str] = {} + headers_list: list[str] = split(_DELIMITER_PATTERN, s) + for header in headers_list: + if not header: # empty string + continue + header_match = _HEADER_PATTERN.fullmatch(header.strip()) + if not header_match and not liberal: + _logger.warning( + _INVALID_HEADER_ERROR_MESSAGE_STRICT_TEMPLATE, header + ) + continue + + if header_match: + match_string: str = header_match.string + # value may contain any number of `=` + name, value = match_string.split("=", 1) + name = unquote(name).strip().lower() + value = unquote(value).strip() + headers[name] = value + else: + # this is not url-encoded and does not match the spec but we decided to be + # liberal in what we accept to match other languages SDKs behaviour + liberal_header_match = _LIBERAL_HEADER_PATTERN.fullmatch( + header.strip() + ) + if not liberal_header_match: + _logger.warning( + _INVALID_HEADER_ERROR_MESSAGE_LIBERAL_TEMPLATE, header + ) + continue + + liberal_match_string: str = liberal_header_match.string + # value may contain any number of `=` + name, value = liberal_match_string.split("=", 1) + name = name.strip().lower() + value = value.strip() + headers[name] = value + + return headers diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/types.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/types.py new file mode 100644 index 0000000000000000000000000000000000000000..b8a753871bc0af0d02955711f89023d40d652aa1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/util/types.py @@ -0,0 +1,46 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Mapping, Sequence + +# This is the implementation of the "Any" type as specified by the specifications of OpenTelemetry data model for logs. +# For more details, refer to the OTel specification: +# https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#type-any +AnyValue = ( + str + | bool + | int + | float + | bytes + | Sequence["AnyValue"] + | Mapping[str, "AnyValue"] + | None +) + +AttributeValue = ( + str + | bool + | int + | float + | Sequence[str] + | Sequence[bool] + | Sequence[int] + | Sequence[float] +) +Attributes = Mapping[str, AttributeValue] | None +AttributesAsKey = tuple[ + tuple[ + str, + str + | bool + | int + | float + | tuple[str | None, ...] + | tuple[bool | None, ...] + | tuple[int | None, ...] + | tuple[float | None, ...], + ], + ..., +] + +_ExtendedAttributes = Mapping[str, "AnyValue"] diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/version/__init__.py b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/version/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7a0ff724d8a5d0fc9858c059847e6f6b9b8c3c4a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/version/__init__.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "1.44.0" diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry/version/py.typed b/.mcp-venv/lib/python3.13/site-packages/opentelemetry/version/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/INSTALLER b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/METADATA b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..a6215520b9045e7a580cfeabfea8cc3fbafc593c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/METADATA @@ -0,0 +1,43 @@ +Metadata-Version: 2.4 +Name: opentelemetry-api +Version: 1.44.0 +Summary: OpenTelemetry Python API +Project-URL: Homepage, https://github.com/open-telemetry/opentelemetry-python/tree/main/opentelemetry-api +Project-URL: Repository, https://github.com/open-telemetry/opentelemetry-python +Author-email: OpenTelemetry Authors +License-Expression: Apache-2.0 +License-File: LICENSE +Classifier: Development Status :: 5 - Production/Stable +Classifier: Framework :: OpenTelemetry +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Classifier: Typing :: Typed +Requires-Python: >=3.10 +Requires-Dist: typing-extensions>=4.5.0 +Description-Content-Type: text/x-rst + +OpenTelemetry Python API +============================================================================ + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-api.svg + :target: https://pypi.org/project/opentelemetry-api/ + +Installation +------------ + +:: + + pip install opentelemetry-api + +References +---------- + +* `OpenTelemetry Project `_ diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/RECORD b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..6a863e80161bad59cd96e3e10c92d2c85c40d927 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/RECORD @@ -0,0 +1,78 @@ +opentelemetry/_logs/__init__.py,sha256=dWfKh1xbSdfgrG9osU9UfddLVWIQKiXj1SWhjwZn6gQ,1366 +opentelemetry/_logs/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/_logs/_internal/__init__.py,sha256=e5Llz-hHL5UAu1fOf0JGSYNPpeXn4soi_jyzGRnOdQE,14607 +opentelemetry/_logs/_internal/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/_logs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/_logs/severity/__init__.py,sha256=GbGwrjYDcuJJzxXDAA4mVCDL6q1hAdGEPkzii-QpL5s,1007 +opentelemetry/_logs/severity/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/attributes/__init__.py,sha256=hlbjLpWSOY81uLzUC5tvAZs2HMxLMNURGHSDi5ah-Ik,12884 +opentelemetry/attributes/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/attributes/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/baggage/__init__.py,sha256=B55rc8MckSv2XU1oMqp05nNkKRpQK-t-iv0CTP4KXkc,3485 +opentelemetry/baggage/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/baggage/propagation/__init__.py,sha256=k-vOZBGGmMk3SnP2Ji0ZzwYDc0GRk7Spj41JX9OZj4A,5600 +opentelemetry/baggage/propagation/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/baggage/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/context/__init__.py,sha256=_EF_b5O1iUhFj08MFHj27prvJ6YRghzDWRAY7kCSGUg,4820 +opentelemetry/context/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/context/__pycache__/context.cpython-313.pyc,, +opentelemetry/context/__pycache__/contextvars_context.cpython-313.pyc,, +opentelemetry/context/context.py,sha256=-UbauXKz4i__60j67rBHqTMbKwxp30t1rL1Jt-3bk7s,1722 +opentelemetry/context/contextvars_context.py,sha256=jEHc7ChNFD3oDf4s0EXxttJLl2-4rSwY-H8JAGVaFRI,1321 +opentelemetry/context/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/environment_variables/__init__.py,sha256=wjBIKwPYfnI8XNCQ3J804I6BCV6tTxMvly6gLF_CMt8,1859 +opentelemetry/environment_variables/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/environment_variables/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/metrics/__init__.py,sha256=RGa_HSX6dAG0d3u44po6PLAoBoF7aLI3wAus7w_MvcU,3068 +opentelemetry/metrics/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/metrics/_internal/__init__.py,sha256=HIzLNCQ54pxPKNgBrNbdw64qMuFSSv0tPBIfOCDcEWE,30085 +opentelemetry/metrics/_internal/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/metrics/_internal/__pycache__/instrument.cpython-313.pyc,, +opentelemetry/metrics/_internal/__pycache__/observation.cpython-313.pyc,, +opentelemetry/metrics/_internal/instrument.py,sha256=nr6Kr2lh1DBGFRwK7SqmuHFC891i6lpaVrPFZHTZO_8,16235 +opentelemetry/metrics/_internal/observation.py,sha256=_cHt_EW0tUM0qobXh_2TyVxFMwP3Mzby2fCmC1BCWVE,1384 +opentelemetry/metrics/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/propagate/__init__.py,sha256=y92JMFtYzonlbOj7p_e6pAc4saWM9oHkOqbFOOg-NRE,6482 +opentelemetry/propagate/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/propagate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/propagators/__pycache__/_envcarrier.cpython-313.pyc,, +opentelemetry/propagators/__pycache__/composite.cpython-313.pyc,, +opentelemetry/propagators/__pycache__/textmap.cpython-313.pyc,, +opentelemetry/propagators/_envcarrier.py,sha256=mRCfUU15P54kts6U93xypQe5Bhrwtw7FUDPF48eXQek,3145 +opentelemetry/propagators/composite.py,sha256=1tZTM9jdwEaKr11znYkeMfIJ0ZfZ-gjijUPDG928Zng,2744 +opentelemetry/propagators/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/propagators/textmap.py,sha256=9J3xPj7NuGlg9QVt6u-0y-d-HtY4ONFxh90U8Bayq14,6031 +opentelemetry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/trace/__init__.py,sha256=yj2djNHsScD6Kpi0nbzL7yBaM3EMxQ-kC5bZ1IDnUxc,23300 +opentelemetry/trace/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/trace/__pycache__/span.cpython-313.pyc,, +opentelemetry/trace/__pycache__/status.cpython-313.pyc,, +opentelemetry/trace/propagation/__init__.py,sha256=fwZOzspAfxpCEROkp_grc77HKpXKbxpwE5QNsLuySV0,1136 +opentelemetry/trace/propagation/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/trace/propagation/__pycache__/tracecontext.cpython-313.pyc,, +opentelemetry/trace/propagation/tracecontext.py,sha256=lP70xQLipW5qTrAlmKgho4fkDxfFSndxvxuWUsq_j6o,3629 +opentelemetry/trace/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/trace/span.py,sha256=N7-a8LkvJCEo5Rx2xLicrRbeKFira7e1OxvSmnrndGQ,19734 +opentelemetry/trace/status.py,sha256=s8HFTjHiibaSuUX_rVNhX7QWfENWADwyzBTAHRxzWOw,1997 +opentelemetry/util/__pycache__/_decorator.cpython-313.pyc,, +opentelemetry/util/__pycache__/_importlib_metadata.cpython-313.pyc,, +opentelemetry/util/__pycache__/_once.cpython-313.pyc,, +opentelemetry/util/__pycache__/_providers.cpython-313.pyc,, +opentelemetry/util/__pycache__/re.cpython-313.pyc,, +opentelemetry/util/__pycache__/types.cpython-313.pyc,, +opentelemetry/util/_decorator.py,sha256=toFquN41ueWBmdlltyjh0Rno2gbhGWfzbQ5bNRzM98Q,2991 +opentelemetry/util/_importlib_metadata.py,sha256=--1cNvlmci16FMuyziuf8VVG9jrUa28YHJn2d97LChA,1847 +opentelemetry/util/_once.py,sha256=ESEc6pw8eAIZ2EMV2ZKEpbyfzF44UmGhkW42qniua6g,941 +opentelemetry/util/_providers.py,sha256=4yjxQjwPIDr3UEZzbeK4l48n5KBoMXi58KsERTTn1UE,1323 +opentelemetry/util/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry/util/re.py,sha256=hp5AgDaxKaxKCDAj9qR8sb2ppwu6qUebeAJ62P06U8g,4186 +opentelemetry/util/types.py,sha256=8zxsogmra1Zcc-Ef8zExpj8taNyT8W0ifbFn-2Npf44,1053 +opentelemetry/version/__init__.py,sha256=AWMXvon5aCiTvYYbsp3dMtMreb79M8Z7hn7JidVYflA,100 +opentelemetry/version/__pycache__/__init__.cpython-313.pyc,, +opentelemetry/version/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +opentelemetry_api-1.44.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +opentelemetry_api-1.44.0.dist-info/METADATA,sha256=Qqq7us0mZie1w4OaUVFQypDBj4DJ2Fym9voQ1ECn7qo,1411 +opentelemetry_api-1.44.0.dist-info/RECORD,, +opentelemetry_api-1.44.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87 +opentelemetry_api-1.44.0.dist-info/entry_points.txt,sha256=dxPq0YRbQDSwl8QkR-I9A38rbbfKQG5h2uNFjpvU6V4,573 +opentelemetry_api-1.44.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357 diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/WHEEL b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..7401812e19af977cc5088f3b8fb1ef6bc0441c0a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.31.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/entry_points.txt b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/entry_points.txt new file mode 100644 index 0000000000000000000000000000000000000000..627961628b2a3b93ebf1469e89de97ccfffb7d94 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/entry_points.txt @@ -0,0 +1,15 @@ +[opentelemetry_context] +contextvars_context = opentelemetry.context.contextvars_context:ContextVarsRuntimeContext + +[opentelemetry_environment_variables] +api = opentelemetry.environment_variables + +[opentelemetry_meter_provider] +default_meter_provider = opentelemetry.metrics:NoOpMeterProvider + +[opentelemetry_propagator] +baggage = opentelemetry.baggage.propagation:W3CBaggagePropagator +tracecontext = opentelemetry.trace.propagation.tracecontext:TraceContextTextMapPropagator + +[opentelemetry_tracer_provider] +default_tracer_provider = opentelemetry.trace:NoOpTracerProvider diff --git a/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/licenses/LICENSE b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/opentelemetry_api-1.44.0.dist-info/licenses/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d4b19f02dea098d04ae8a05abde487ed89a7e866 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/__init__.py @@ -0,0 +1,13 @@ +from typing import List, Optional + +__version__ = "25.1.1" + + +def main(args: Optional[List[str]] = None) -> int: + """This is an internal API only meant for use by pip's own console scripts. + + For additional details, see https://github.com/pypa/pip/issues/7498. + """ + from pip._internal.utils.entrypoints import _wrapper + + return _wrapper(args) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/__main__.py b/.mcp-venv/lib/python3.13/site-packages/pip/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..5991326115fe5026470165b387ba2bc78bceb006 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/__main__.py @@ -0,0 +1,24 @@ +import os +import sys + +# Remove '' and current working directory from the first entry +# of sys.path, if present to avoid using current directory +# in pip commands check, freeze, install, list and show, +# when invoked as python -m pip +if sys.path[0] in ("", os.getcwd()): + sys.path.pop(0) + +# If we are running from a wheel, add the wheel to sys.path +# This allows the usage python pip-*.whl/pip install pip-*.whl +if __package__ == "": + # __file__ is pip-*.whl/pip/__main__.py + # first dirname call strips of '/__main__.py', second strips off '/pip' + # Resulting path is the name of the wheel itself + # Add that to sys.path so we can import pip + path = os.path.dirname(os.path.dirname(__file__)) + sys.path.insert(0, path) + +if __name__ == "__main__": + from pip._internal.cli.main import main as _main + + sys.exit(_main()) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/__pip-runner__.py b/.mcp-venv/lib/python3.13/site-packages/pip/__pip-runner__.py new file mode 100644 index 0000000000000000000000000000000000000000..d6be157831a67d8d45d75ce1c6f14aad0ad47b8f --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/__pip-runner__.py @@ -0,0 +1,50 @@ +"""Execute exactly this copy of pip, within a different environment. + +This file is named as it is, to ensure that this module can't be imported via +an import statement. +""" + +# /!\ This version compatibility check section must be Python 2 compatible. /!\ + +import sys + +# Copied from pyproject.toml +PYTHON_REQUIRES = (3, 9) + + +def version_str(version): # type: ignore + return ".".join(str(v) for v in version) + + +if sys.version_info[:2] < PYTHON_REQUIRES: + raise SystemExit( + "This version of pip does not support python {} (requires >={}).".format( + version_str(sys.version_info[:2]), version_str(PYTHON_REQUIRES) + ) + ) + +# From here on, we can use Python 3 features, but the syntax must remain +# Python 2 compatible. + +import runpy # noqa: E402 +from importlib.machinery import PathFinder # noqa: E402 +from os.path import dirname # noqa: E402 + +PIP_SOURCES_ROOT = dirname(dirname(__file__)) + + +class PipImportRedirectingFinder: + @classmethod + def find_spec(self, fullname, path=None, target=None): # type: ignore + if fullname != "pip": + return None + + spec = PathFinder.find_spec(fullname, [PIP_SOURCES_ROOT], target) + assert spec, (PIP_SOURCES_ROOT, fullname) + return spec + + +sys.meta_path.insert(0, PipImportRedirectingFinder()) + +assert __name__ == "__main__", "Cannot run __pip-runner__.py as a non-main module" +runpy.run_module("pip", run_name="__main__", alter_sys=True) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1a5b7f87f973b36af0ee6fbfb76ce38420f5f9d7 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/__init__.py @@ -0,0 +1,18 @@ +from typing import List, Optional + +from pip._internal.utils import _log + +# init_logging() must be called before any call to logging.getLogger() +# which happens at import of most modules. +_log.init_logging() + + +def main(args: Optional[List[str]] = None) -> int: + """This is preserved for old console scripts that may still be referencing + it. + + For additional details, see https://github.com/pypa/pip/issues/7498. + """ + from pip._internal.utils.entrypoints import _wrapper + + return _wrapper(args) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/build_env.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/build_env.py new file mode 100644 index 0000000000000000000000000000000000000000..22c7476702b4ad9ca37c01f0c095c5eb2f35ae3f --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/build_env.py @@ -0,0 +1,325 @@ +"""Build Environment used for isolation during sdist building""" + +import logging +import os +import pathlib +import site +import sys +import textwrap +from collections import OrderedDict +from types import TracebackType +from typing import TYPE_CHECKING, Iterable, List, Optional, Set, Tuple, Type, Union + +from pip._vendor.packaging.version import Version + +from pip import __file__ as pip_location +from pip._internal.cli.spinners import open_spinner +from pip._internal.locations import get_platlib, get_purelib, get_scheme +from pip._internal.metadata import get_default_environment, get_environment +from pip._internal.utils.logging import VERBOSE +from pip._internal.utils.packaging import get_requirement +from pip._internal.utils.subprocess import call_subprocess +from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds + +if TYPE_CHECKING: + from pip._internal.index.package_finder import PackageFinder + +logger = logging.getLogger(__name__) + + +def _dedup(a: str, b: str) -> Union[Tuple[str], Tuple[str, str]]: + return (a, b) if a != b else (a,) + + +class _Prefix: + def __init__(self, path: str) -> None: + self.path = path + self.setup = False + scheme = get_scheme("", prefix=path) + self.bin_dir = scheme.scripts + self.lib_dirs = _dedup(scheme.purelib, scheme.platlib) + + +def get_runnable_pip() -> str: + """Get a file to pass to a Python executable, to run the currently-running pip. + + This is used to run a pip subprocess, for installing requirements into the build + environment. + """ + source = pathlib.Path(pip_location).resolve().parent + + if not source.is_dir(): + # This would happen if someone is using pip from inside a zip file. In that + # case, we can use that directly. + return str(source) + + return os.fsdecode(source / "__pip-runner__.py") + + +def _get_system_sitepackages() -> Set[str]: + """Get system site packages + + Usually from site.getsitepackages, + but fallback on `get_purelib()/get_platlib()` if unavailable + (e.g. in a virtualenv created by virtualenv<20) + + Returns normalized set of strings. + """ + if hasattr(site, "getsitepackages"): + system_sites = site.getsitepackages() + else: + # virtualenv < 20 overwrites site.py without getsitepackages + # fallback on get_purelib/get_platlib. + # this is known to miss things, but shouldn't in the cases + # where getsitepackages() has been removed (inside a virtualenv) + system_sites = [get_purelib(), get_platlib()] + return {os.path.normcase(path) for path in system_sites} + + +class BuildEnvironment: + """Creates and manages an isolated environment to install build deps""" + + def __init__(self) -> None: + temp_dir = TempDirectory(kind=tempdir_kinds.BUILD_ENV, globally_managed=True) + + self._prefixes = OrderedDict( + (name, _Prefix(os.path.join(temp_dir.path, name))) + for name in ("normal", "overlay") + ) + + self._bin_dirs: List[str] = [] + self._lib_dirs: List[str] = [] + for prefix in reversed(list(self._prefixes.values())): + self._bin_dirs.append(prefix.bin_dir) + self._lib_dirs.extend(prefix.lib_dirs) + + # Customize site to: + # - ensure .pth files are honored + # - prevent access to system site packages + system_sites = _get_system_sitepackages() + + self._site_dir = os.path.join(temp_dir.path, "site") + if not os.path.exists(self._site_dir): + os.mkdir(self._site_dir) + with open( + os.path.join(self._site_dir, "sitecustomize.py"), "w", encoding="utf-8" + ) as fp: + fp.write( + textwrap.dedent( + """ + import os, site, sys + + # First, drop system-sites related paths. + original_sys_path = sys.path[:] + known_paths = set() + for path in {system_sites!r}: + site.addsitedir(path, known_paths=known_paths) + system_paths = set( + os.path.normcase(path) + for path in sys.path[len(original_sys_path):] + ) + original_sys_path = [ + path for path in original_sys_path + if os.path.normcase(path) not in system_paths + ] + sys.path = original_sys_path + + # Second, add lib directories. + # ensuring .pth file are processed. + for path in {lib_dirs!r}: + assert not path in sys.path + site.addsitedir(path) + """ + ).format(system_sites=system_sites, lib_dirs=self._lib_dirs) + ) + + def __enter__(self) -> None: + self._save_env = { + name: os.environ.get(name, None) + for name in ("PATH", "PYTHONNOUSERSITE", "PYTHONPATH") + } + + path = self._bin_dirs[:] + old_path = self._save_env["PATH"] + if old_path: + path.extend(old_path.split(os.pathsep)) + + pythonpath = [self._site_dir] + + os.environ.update( + { + "PATH": os.pathsep.join(path), + "PYTHONNOUSERSITE": "1", + "PYTHONPATH": os.pathsep.join(pythonpath), + } + ) + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + for varname, old_value in self._save_env.items(): + if old_value is None: + os.environ.pop(varname, None) + else: + os.environ[varname] = old_value + + def check_requirements( + self, reqs: Iterable[str] + ) -> Tuple[Set[Tuple[str, str]], Set[str]]: + """Return 2 sets: + - conflicting requirements: set of (installed, wanted) reqs tuples + - missing requirements: set of reqs + """ + missing = set() + conflicting = set() + if reqs: + env = ( + get_environment(self._lib_dirs) + if hasattr(self, "_lib_dirs") + else get_default_environment() + ) + for req_str in reqs: + req = get_requirement(req_str) + # We're explicitly evaluating with an empty extra value, since build + # environments are not provided any mechanism to select specific extras. + if req.marker is not None and not req.marker.evaluate({"extra": ""}): + continue + dist = env.get_distribution(req.name) + if not dist: + missing.add(req_str) + continue + if isinstance(dist.version, Version): + installed_req_str = f"{req.name}=={dist.version}" + else: + installed_req_str = f"{req.name}==={dist.version}" + if not req.specifier.contains(dist.version, prereleases=True): + conflicting.add((installed_req_str, req_str)) + # FIXME: Consider direct URL? + return conflicting, missing + + def install_requirements( + self, + finder: "PackageFinder", + requirements: Iterable[str], + prefix_as_string: str, + *, + kind: str, + ) -> None: + prefix = self._prefixes[prefix_as_string] + assert not prefix.setup + prefix.setup = True + if not requirements: + return + self._install_requirements( + get_runnable_pip(), + finder, + requirements, + prefix, + kind=kind, + ) + + @staticmethod + def _install_requirements( + pip_runnable: str, + finder: "PackageFinder", + requirements: Iterable[str], + prefix: _Prefix, + *, + kind: str, + ) -> None: + args: List[str] = [ + sys.executable, + pip_runnable, + "install", + "--ignore-installed", + "--no-user", + "--prefix", + prefix.path, + "--no-warn-script-location", + "--disable-pip-version-check", + # As the build environment is ephemeral, it's wasteful to + # pre-compile everything, especially as not every Python + # module will be used/compiled in most cases. + "--no-compile", + # The prefix specified two lines above, thus + # target from config file or env var should be ignored + "--target", + "", + ] + if logger.getEffectiveLevel() <= logging.DEBUG: + args.append("-vv") + elif logger.getEffectiveLevel() <= VERBOSE: + args.append("-v") + for format_control in ("no_binary", "only_binary"): + formats = getattr(finder.format_control, format_control) + args.extend( + ( + "--" + format_control.replace("_", "-"), + ",".join(sorted(formats or {":none:"})), + ) + ) + + index_urls = finder.index_urls + if index_urls: + args.extend(["-i", index_urls[0]]) + for extra_index in index_urls[1:]: + args.extend(["--extra-index-url", extra_index]) + else: + args.append("--no-index") + for link in finder.find_links: + args.extend(["--find-links", link]) + + if finder.proxy: + args.extend(["--proxy", finder.proxy]) + for host in finder.trusted_hosts: + args.extend(["--trusted-host", host]) + if finder.custom_cert: + args.extend(["--cert", finder.custom_cert]) + if finder.client_cert: + args.extend(["--client-cert", finder.client_cert]) + if finder.allow_all_prereleases: + args.append("--pre") + if finder.prefer_binary: + args.append("--prefer-binary") + args.append("--") + args.extend(requirements) + with open_spinner(f"Installing {kind}") as spinner: + call_subprocess( + args, + command_desc=f"pip subprocess to install {kind}", + spinner=spinner, + ) + + +class NoOpBuildEnvironment(BuildEnvironment): + """A no-op drop-in replacement for BuildEnvironment""" + + def __init__(self) -> None: + pass + + def __enter__(self) -> None: + pass + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + pass + + def cleanup(self) -> None: + pass + + def install_requirements( + self, + finder: "PackageFinder", + requirements: Iterable[str], + prefix_as_string: str, + *, + kind: str, + ) -> None: + raise NotImplementedError() diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cache.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..97d917193d3bca819fe3c4b3c8190f2127c3c2ca --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cache.py @@ -0,0 +1,289 @@ +"""Cache Management""" + +import hashlib +import json +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from pip._vendor.packaging.tags import Tag, interpreter_name, interpreter_version +from pip._vendor.packaging.utils import canonicalize_name + +from pip._internal.exceptions import InvalidWheelFilename +from pip._internal.models.direct_url import DirectUrl +from pip._internal.models.link import Link +from pip._internal.models.wheel import Wheel +from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds +from pip._internal.utils.urls import path_to_url + +logger = logging.getLogger(__name__) + +ORIGIN_JSON_NAME = "origin.json" + + +def _hash_dict(d: Dict[str, str]) -> str: + """Return a stable sha224 of a dictionary.""" + s = json.dumps(d, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha224(s.encode("ascii")).hexdigest() + + +class Cache: + """An abstract class - provides cache directories for data from links + + :param cache_dir: The root of the cache. + """ + + def __init__(self, cache_dir: str) -> None: + super().__init__() + assert not cache_dir or os.path.isabs(cache_dir) + self.cache_dir = cache_dir or None + + def _get_cache_path_parts(self, link: Link) -> List[str]: + """Get parts of part that must be os.path.joined with cache_dir""" + + # We want to generate an url to use as our cache key, we don't want to + # just reuse the URL because it might have other items in the fragment + # and we don't care about those. + key_parts = {"url": link.url_without_fragment} + if link.hash_name is not None and link.hash is not None: + key_parts[link.hash_name] = link.hash + if link.subdirectory_fragment: + key_parts["subdirectory"] = link.subdirectory_fragment + + # Include interpreter name, major and minor version in cache key + # to cope with ill-behaved sdists that build a different wheel + # depending on the python version their setup.py is being run on, + # and don't encode the difference in compatibility tags. + # https://github.com/pypa/pip/issues/7296 + key_parts["interpreter_name"] = interpreter_name() + key_parts["interpreter_version"] = interpreter_version() + + # Encode our key url with sha224, we'll use this because it has similar + # security properties to sha256, but with a shorter total output (and + # thus less secure). However the differences don't make a lot of + # difference for our use case here. + hashed = _hash_dict(key_parts) + + # We want to nest the directories some to prevent having a ton of top + # level directories where we might run out of sub directories on some + # FS. + parts = [hashed[:2], hashed[2:4], hashed[4:6], hashed[6:]] + + return parts + + def _get_candidates(self, link: Link, canonical_package_name: str) -> List[Any]: + can_not_cache = not self.cache_dir or not canonical_package_name or not link + if can_not_cache: + return [] + + path = self.get_path_for_link(link) + if os.path.isdir(path): + return [(candidate, path) for candidate in os.listdir(path)] + return [] + + def get_path_for_link(self, link: Link) -> str: + """Return a directory to store cached items in for link.""" + raise NotImplementedError() + + def get( + self, + link: Link, + package_name: Optional[str], + supported_tags: List[Tag], + ) -> Link: + """Returns a link to a cached item if it exists, otherwise returns the + passed link. + """ + raise NotImplementedError() + + +class SimpleWheelCache(Cache): + """A cache of wheels for future installs.""" + + def __init__(self, cache_dir: str) -> None: + super().__init__(cache_dir) + + def get_path_for_link(self, link: Link) -> str: + """Return a directory to store cached wheels for link + + Because there are M wheels for any one sdist, we provide a directory + to cache them in, and then consult that directory when looking up + cache hits. + + We only insert things into the cache if they have plausible version + numbers, so that we don't contaminate the cache with things that were + not unique. E.g. ./package might have dozens of installs done for it + and build a version of 0.0...and if we built and cached a wheel, we'd + end up using the same wheel even if the source has been edited. + + :param link: The link of the sdist for which this will cache wheels. + """ + parts = self._get_cache_path_parts(link) + assert self.cache_dir + # Store wheels within the root cache_dir + return os.path.join(self.cache_dir, "wheels", *parts) + + def get( + self, + link: Link, + package_name: Optional[str], + supported_tags: List[Tag], + ) -> Link: + candidates = [] + + if not package_name: + return link + + canonical_package_name = canonicalize_name(package_name) + for wheel_name, wheel_dir in self._get_candidates(link, canonical_package_name): + try: + wheel = Wheel(wheel_name) + except InvalidWheelFilename: + continue + if canonicalize_name(wheel.name) != canonical_package_name: + logger.debug( + "Ignoring cached wheel %s for %s as it " + "does not match the expected distribution name %s.", + wheel_name, + link, + package_name, + ) + continue + if not wheel.supported(supported_tags): + # Built for a different python/arch/etc + continue + candidates.append( + ( + wheel.support_index_min(supported_tags), + wheel_name, + wheel_dir, + ) + ) + + if not candidates: + return link + + _, wheel_name, wheel_dir = min(candidates) + return Link(path_to_url(os.path.join(wheel_dir, wheel_name))) + + +class EphemWheelCache(SimpleWheelCache): + """A SimpleWheelCache that creates it's own temporary cache directory""" + + def __init__(self) -> None: + self._temp_dir = TempDirectory( + kind=tempdir_kinds.EPHEM_WHEEL_CACHE, + globally_managed=True, + ) + + super().__init__(self._temp_dir.path) + + +class CacheEntry: + def __init__( + self, + link: Link, + persistent: bool, + ): + self.link = link + self.persistent = persistent + self.origin: Optional[DirectUrl] = None + origin_direct_url_path = Path(self.link.file_path).parent / ORIGIN_JSON_NAME + if origin_direct_url_path.exists(): + try: + self.origin = DirectUrl.from_json( + origin_direct_url_path.read_text(encoding="utf-8") + ) + except Exception as e: + logger.warning( + "Ignoring invalid cache entry origin file %s for %s (%s)", + origin_direct_url_path, + link.filename, + e, + ) + + +class WheelCache(Cache): + """Wraps EphemWheelCache and SimpleWheelCache into a single Cache + + This Cache allows for gracefully degradation, using the ephem wheel cache + when a certain link is not found in the simple wheel cache first. + """ + + def __init__(self, cache_dir: str) -> None: + super().__init__(cache_dir) + self._wheel_cache = SimpleWheelCache(cache_dir) + self._ephem_cache = EphemWheelCache() + + def get_path_for_link(self, link: Link) -> str: + return self._wheel_cache.get_path_for_link(link) + + def get_ephem_path_for_link(self, link: Link) -> str: + return self._ephem_cache.get_path_for_link(link) + + def get( + self, + link: Link, + package_name: Optional[str], + supported_tags: List[Tag], + ) -> Link: + cache_entry = self.get_cache_entry(link, package_name, supported_tags) + if cache_entry is None: + return link + return cache_entry.link + + def get_cache_entry( + self, + link: Link, + package_name: Optional[str], + supported_tags: List[Tag], + ) -> Optional[CacheEntry]: + """Returns a CacheEntry with a link to a cached item if it exists or + None. The cache entry indicates if the item was found in the persistent + or ephemeral cache. + """ + retval = self._wheel_cache.get( + link=link, + package_name=package_name, + supported_tags=supported_tags, + ) + if retval is not link: + return CacheEntry(retval, persistent=True) + + retval = self._ephem_cache.get( + link=link, + package_name=package_name, + supported_tags=supported_tags, + ) + if retval is not link: + return CacheEntry(retval, persistent=False) + + return None + + @staticmethod + def record_download_origin(cache_dir: str, download_info: DirectUrl) -> None: + origin_path = Path(cache_dir) / ORIGIN_JSON_NAME + if origin_path.exists(): + try: + origin = DirectUrl.from_json(origin_path.read_text(encoding="utf-8")) + except Exception as e: + logger.warning( + "Could not read origin file %s in cache entry (%s). " + "Will attempt to overwrite it.", + origin_path, + e, + ) + else: + # TODO: use DirectUrl.equivalent when + # https://github.com/pypa/pip/pull/10564 is merged. + if origin.url != download_info.url: + logger.warning( + "Origin URL %s in cache entry %s does not match download URL " + "%s. This is likely a pip bug or a cache corruption issue. " + "Will overwrite it with the new value.", + origin.url, + cache_dir, + download_info.url, + ) + origin_path.write_text(download_info.to_json(), encoding="utf-8") diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5fcddf5d81ed444b20b22344fe8bdecd1ebe055c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/__init__.py @@ -0,0 +1,3 @@ +"""Subpackage containing all of pip's command line interface related code""" + +# This file intentionally does not import submodules diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/autocompletion.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/autocompletion.py new file mode 100644 index 0000000000000000000000000000000000000000..4fa461293c71f1c336458487b54a725a9c2822ab --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/autocompletion.py @@ -0,0 +1,175 @@ +"""Logic that powers autocompletion installed by ``pip completion``.""" + +import optparse +import os +import sys +from itertools import chain +from typing import Any, Iterable, List, Optional + +from pip._internal.cli.main_parser import create_main_parser +from pip._internal.commands import commands_dict, create_command +from pip._internal.metadata import get_default_environment + + +def autocomplete() -> None: + """Entry Point for completion of main and subcommand options.""" + # Don't complete if user hasn't sourced bash_completion file. + if "PIP_AUTO_COMPLETE" not in os.environ: + return + # Don't complete if autocompletion environment variables + # are not present + if not os.environ.get("COMP_WORDS") or not os.environ.get("COMP_CWORD"): + return + cwords = os.environ["COMP_WORDS"].split()[1:] + cword = int(os.environ["COMP_CWORD"]) + try: + current = cwords[cword - 1] + except IndexError: + current = "" + + parser = create_main_parser() + subcommands = list(commands_dict) + options = [] + + # subcommand + subcommand_name: Optional[str] = None + for word in cwords: + if word in subcommands: + subcommand_name = word + break + # subcommand options + if subcommand_name is not None: + # special case: 'help' subcommand has no options + if subcommand_name == "help": + sys.exit(1) + # special case: list locally installed dists for show and uninstall + should_list_installed = not current.startswith("-") and subcommand_name in [ + "show", + "uninstall", + ] + if should_list_installed: + env = get_default_environment() + lc = current.lower() + installed = [ + dist.canonical_name + for dist in env.iter_installed_distributions(local_only=True) + if dist.canonical_name.startswith(lc) + and dist.canonical_name not in cwords[1:] + ] + # if there are no dists installed, fall back to option completion + if installed: + for dist in installed: + print(dist) + sys.exit(1) + + should_list_installables = ( + not current.startswith("-") and subcommand_name == "install" + ) + if should_list_installables: + for path in auto_complete_paths(current, "path"): + print(path) + sys.exit(1) + + subcommand = create_command(subcommand_name) + + for opt in subcommand.parser.option_list_all: + if opt.help != optparse.SUPPRESS_HELP: + options += [ + (opt_str, opt.nargs) for opt_str in opt._long_opts + opt._short_opts + ] + + # filter out previously specified options from available options + prev_opts = [x.split("=")[0] for x in cwords[1 : cword - 1]] + options = [(x, v) for (x, v) in options if x not in prev_opts] + # filter options by current input + options = [(k, v) for k, v in options if k.startswith(current)] + # get completion type given cwords and available subcommand options + completion_type = get_path_completion_type( + cwords, + cword, + subcommand.parser.option_list_all, + ) + # get completion files and directories if ``completion_type`` is + # ````, ```` or ```` + if completion_type: + paths = auto_complete_paths(current, completion_type) + options = [(path, 0) for path in paths] + for option in options: + opt_label = option[0] + # append '=' to options which require args + if option[1] and option[0][:2] == "--": + opt_label += "=" + print(opt_label) + else: + # show main parser options only when necessary + + opts = [i.option_list for i in parser.option_groups] + opts.append(parser.option_list) + flattened_opts = chain.from_iterable(opts) + if current.startswith("-"): + for opt in flattened_opts: + if opt.help != optparse.SUPPRESS_HELP: + subcommands += opt._long_opts + opt._short_opts + else: + # get completion type given cwords and all available options + completion_type = get_path_completion_type(cwords, cword, flattened_opts) + if completion_type: + subcommands = list(auto_complete_paths(current, completion_type)) + + print(" ".join([x for x in subcommands if x.startswith(current)])) + sys.exit(1) + + +def get_path_completion_type( + cwords: List[str], cword: int, opts: Iterable[Any] +) -> Optional[str]: + """Get the type of path completion (``file``, ``dir``, ``path`` or None) + + :param cwords: same as the environmental variable ``COMP_WORDS`` + :param cword: same as the environmental variable ``COMP_CWORD`` + :param opts: The available options to check + :return: path completion type (``file``, ``dir``, ``path`` or None) + """ + if cword < 2 or not cwords[cword - 2].startswith("-"): + return None + for opt in opts: + if opt.help == optparse.SUPPRESS_HELP: + continue + for o in str(opt).split("/"): + if cwords[cword - 2].split("=")[0] == o: + if not opt.metavar or any( + x in ("path", "file", "dir") for x in opt.metavar.split("/") + ): + return opt.metavar + return None + + +def auto_complete_paths(current: str, completion_type: str) -> Iterable[str]: + """If ``completion_type`` is ``file`` or ``path``, list all regular files + and directories starting with ``current``; otherwise only list directories + starting with ``current``. + + :param current: The word to be completed + :param completion_type: path completion type(``file``, ``path`` or ``dir``) + :return: A generator of regular files and/or directories + """ + directory, filename = os.path.split(current) + current_path = os.path.abspath(directory) + # Don't complete paths if they can't be accessed + if not os.access(current_path, os.R_OK): + return + filename = os.path.normcase(filename) + # list all files that start with ``filename`` + file_list = ( + x for x in os.listdir(current_path) if os.path.normcase(x).startswith(filename) + ) + for f in file_list: + opt = os.path.join(current_path, f) + comp_file = os.path.normcase(os.path.join(directory, f)) + # complete regular files when there is not ```` after option + # complete directories when there is ````, ```` or + # ````after option + if completion_type != "dir" and os.path.isfile(opt): + yield comp_file + elif os.path.isdir(opt): + yield os.path.join(comp_file, "") diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/base_command.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/base_command.py new file mode 100644 index 0000000000000000000000000000000000000000..1d71d67e7a4ed3850fbde91f3066cacfc3760f3e --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/base_command.py @@ -0,0 +1,233 @@ +"""Base Command class, and related routines""" + +import logging +import logging.config +import optparse +import os +import sys +import traceback +from optparse import Values +from typing import List, Optional, Tuple + +from pip._vendor.rich import reconfigure +from pip._vendor.rich import traceback as rich_traceback + +from pip._internal.cli import cmdoptions +from pip._internal.cli.command_context import CommandContextMixIn +from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter +from pip._internal.cli.status_codes import ( + ERROR, + PREVIOUS_BUILD_DIR_ERROR, + UNKNOWN_ERROR, + VIRTUALENV_NOT_FOUND, +) +from pip._internal.exceptions import ( + BadCommand, + CommandError, + DiagnosticPipError, + InstallationError, + NetworkConnectionError, + PreviousBuildDirError, +) +from pip._internal.utils.filesystem import check_path_owner +from pip._internal.utils.logging import BrokenStdoutLoggingError, setup_logging +from pip._internal.utils.misc import get_prog, normalize_path +from pip._internal.utils.temp_dir import TempDirectoryTypeRegistry as TempDirRegistry +from pip._internal.utils.temp_dir import global_tempdir_manager, tempdir_registry +from pip._internal.utils.virtualenv import running_under_virtualenv + +__all__ = ["Command"] + +logger = logging.getLogger(__name__) + + +class Command(CommandContextMixIn): + usage: str = "" + ignore_require_venv: bool = False + + def __init__(self, name: str, summary: str, isolated: bool = False) -> None: + super().__init__() + + self.name = name + self.summary = summary + self.parser = ConfigOptionParser( + usage=self.usage, + prog=f"{get_prog()} {name}", + formatter=UpdatingDefaultsHelpFormatter(), + add_help_option=False, + name=name, + description=self.__doc__, + isolated=isolated, + ) + + self.tempdir_registry: Optional[TempDirRegistry] = None + + # Commands should add options to this option group + optgroup_name = f"{self.name.capitalize()} Options" + self.cmd_opts = optparse.OptionGroup(self.parser, optgroup_name) + + # Add the general options + gen_opts = cmdoptions.make_option_group( + cmdoptions.general_group, + self.parser, + ) + self.parser.add_option_group(gen_opts) + + self.add_options() + + def add_options(self) -> None: + pass + + def handle_pip_version_check(self, options: Values) -> None: + """ + This is a no-op so that commands by default do not do the pip version + check. + """ + # Make sure we do the pip version check if the index_group options + # are present. + assert not hasattr(options, "no_index") + + def run(self, options: Values, args: List[str]) -> int: + raise NotImplementedError + + def _run_wrapper(self, level_number: int, options: Values, args: List[str]) -> int: + def _inner_run() -> int: + try: + return self.run(options, args) + finally: + self.handle_pip_version_check(options) + + if options.debug_mode: + rich_traceback.install(show_locals=True) + return _inner_run() + + try: + status = _inner_run() + assert isinstance(status, int) + return status + except DiagnosticPipError as exc: + logger.error("%s", exc, extra={"rich": True}) + logger.debug("Exception information:", exc_info=True) + + return ERROR + except PreviousBuildDirError as exc: + logger.critical(str(exc)) + logger.debug("Exception information:", exc_info=True) + + return PREVIOUS_BUILD_DIR_ERROR + except ( + InstallationError, + BadCommand, + NetworkConnectionError, + ) as exc: + logger.critical(str(exc)) + logger.debug("Exception information:", exc_info=True) + + return ERROR + except CommandError as exc: + logger.critical("%s", exc) + logger.debug("Exception information:", exc_info=True) + + return ERROR + except BrokenStdoutLoggingError: + # Bypass our logger and write any remaining messages to + # stderr because stdout no longer works. + print("ERROR: Pipe to stdout was broken", file=sys.stderr) + if level_number <= logging.DEBUG: + traceback.print_exc(file=sys.stderr) + + return ERROR + except KeyboardInterrupt: + logger.critical("Operation cancelled by user") + logger.debug("Exception information:", exc_info=True) + + return ERROR + except BaseException: + logger.critical("Exception:", exc_info=True) + + return UNKNOWN_ERROR + + def parse_args(self, args: List[str]) -> Tuple[Values, List[str]]: + # factored out for testability + return self.parser.parse_args(args) + + def main(self, args: List[str]) -> int: + try: + with self.main_context(): + return self._main(args) + finally: + logging.shutdown() + + def _main(self, args: List[str]) -> int: + # We must initialize this before the tempdir manager, otherwise the + # configuration would not be accessible by the time we clean up the + # tempdir manager. + self.tempdir_registry = self.enter_context(tempdir_registry()) + # Intentionally set as early as possible so globally-managed temporary + # directories are available to the rest of the code. + self.enter_context(global_tempdir_manager()) + + options, args = self.parse_args(args) + + # Set verbosity so that it can be used elsewhere. + self.verbosity = options.verbose - options.quiet + if options.debug_mode: + self.verbosity = 2 + + reconfigure(no_color=options.no_color) + level_number = setup_logging( + verbosity=self.verbosity, + no_color=options.no_color, + user_log_file=options.log, + ) + + always_enabled_features = set(options.features_enabled) & set( + cmdoptions.ALWAYS_ENABLED_FEATURES + ) + if always_enabled_features: + logger.warning( + "The following features are always enabled: %s. ", + ", ".join(sorted(always_enabled_features)), + ) + + # Make sure that the --python argument isn't specified after the + # subcommand. We can tell, because if --python was specified, + # we should only reach this point if we're running in the created + # subprocess, which has the _PIP_RUNNING_IN_SUBPROCESS environment + # variable set. + if options.python and "_PIP_RUNNING_IN_SUBPROCESS" not in os.environ: + logger.critical( + "The --python option must be placed before the pip subcommand name" + ) + sys.exit(ERROR) + + # TODO: Try to get these passing down from the command? + # without resorting to os.environ to hold these. + # This also affects isolated builds and it should. + + if options.no_input: + os.environ["PIP_NO_INPUT"] = "1" + + if options.exists_action: + os.environ["PIP_EXISTS_ACTION"] = " ".join(options.exists_action) + + if options.require_venv and not self.ignore_require_venv: + # If a venv is required check if it can really be found + if not running_under_virtualenv(): + logger.critical("Could not find an activated virtualenv (required).") + sys.exit(VIRTUALENV_NOT_FOUND) + + if options.cache_dir: + options.cache_dir = normalize_path(options.cache_dir) + if not check_path_owner(options.cache_dir): + logger.warning( + "The directory '%s' or its parent directory is not owned " + "or is not writable by the current user. The cache " + "has been disabled. Check the permissions and owner of " + "that directory. If executing pip with sudo, you should " + "use sudo's -H flag.", + options.cache_dir, + ) + options.cache_dir = None + + return self._run_wrapper(level_number, options, args) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/cmdoptions.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/cmdoptions.py new file mode 100644 index 0000000000000000000000000000000000000000..dd3b921676c6e93ccb8458940dc05101be4c65fc --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/cmdoptions.py @@ -0,0 +1,1133 @@ +""" +shared options and groups + +The principle here is to define options once, but *not* instantiate them +globally. One reason being that options with action='append' can carry state +between parses. pip parses general options twice internally, and shouldn't +pass on state. To be consistent, all options will follow this design. +""" + +# The following comment should be removed at some point in the future. +# mypy: strict-optional=False + +import importlib.util +import logging +import os +import pathlib +import textwrap +from functools import partial +from optparse import SUPPRESS_HELP, Option, OptionGroup, OptionParser, Values +from textwrap import dedent +from typing import Any, Callable, Dict, Optional, Tuple + +from pip._vendor.packaging.utils import canonicalize_name + +from pip._internal.cli.parser import ConfigOptionParser +from pip._internal.exceptions import CommandError +from pip._internal.locations import USER_CACHE_DIR, get_src_prefix +from pip._internal.models.format_control import FormatControl +from pip._internal.models.index import PyPI +from pip._internal.models.target_python import TargetPython +from pip._internal.utils.hashes import STRONG_HASHES +from pip._internal.utils.misc import strtobool + +logger = logging.getLogger(__name__) + + +def raise_option_error(parser: OptionParser, option: Option, msg: str) -> None: + """ + Raise an option parsing error using parser.error(). + + Args: + parser: an OptionParser instance. + option: an Option instance. + msg: the error text. + """ + msg = f"{option} error: {msg}" + msg = textwrap.fill(" ".join(msg.split())) + parser.error(msg) + + +def make_option_group(group: Dict[str, Any], parser: ConfigOptionParser) -> OptionGroup: + """ + Return an OptionGroup object + group -- assumed to be dict with 'name' and 'options' keys + parser -- an optparse Parser + """ + option_group = OptionGroup(parser, group["name"]) + for option in group["options"]: + option_group.add_option(option()) + return option_group + + +def check_dist_restriction(options: Values, check_target: bool = False) -> None: + """Function for determining if custom platform options are allowed. + + :param options: The OptionParser options. + :param check_target: Whether or not to check if --target is being used. + """ + dist_restriction_set = any( + [ + options.python_version, + options.platforms, + options.abis, + options.implementation, + ] + ) + + binary_only = FormatControl(set(), {":all:"}) + sdist_dependencies_allowed = ( + options.format_control != binary_only and not options.ignore_dependencies + ) + + # Installations or downloads using dist restrictions must not combine + # source distributions and dist-specific wheels, as they are not + # guaranteed to be locally compatible. + if dist_restriction_set and sdist_dependencies_allowed: + raise CommandError( + "When restricting platform and interpreter constraints using " + "--python-version, --platform, --abi, or --implementation, " + "either --no-deps must be set, or --only-binary=:all: must be " + "set and --no-binary must not be set (or must be set to " + ":none:)." + ) + + if check_target: + if not options.dry_run and dist_restriction_set and not options.target_dir: + raise CommandError( + "Can not use any platform or abi specific options unless " + "installing via '--target' or using '--dry-run'" + ) + + +def _path_option_check(option: Option, opt: str, value: str) -> str: + return os.path.expanduser(value) + + +def _package_name_option_check(option: Option, opt: str, value: str) -> str: + return canonicalize_name(value) + + +class PipOption(Option): + TYPES = Option.TYPES + ("path", "package_name") + TYPE_CHECKER = Option.TYPE_CHECKER.copy() + TYPE_CHECKER["package_name"] = _package_name_option_check + TYPE_CHECKER["path"] = _path_option_check + + +########### +# options # +########### + +help_: Callable[..., Option] = partial( + Option, + "-h", + "--help", + dest="help", + action="help", + help="Show help.", +) + +debug_mode: Callable[..., Option] = partial( + Option, + "--debug", + dest="debug_mode", + action="store_true", + default=False, + help=( + "Let unhandled exceptions propagate outside the main subroutine, " + "instead of logging them to stderr." + ), +) + +isolated_mode: Callable[..., Option] = partial( + Option, + "--isolated", + dest="isolated_mode", + action="store_true", + default=False, + help=( + "Run pip in an isolated mode, ignoring environment variables and user " + "configuration." + ), +) + +require_virtualenv: Callable[..., Option] = partial( + Option, + "--require-virtualenv", + "--require-venv", + dest="require_venv", + action="store_true", + default=False, + help=( + "Allow pip to only run in a virtual environment; " + "exit with an error otherwise." + ), +) + +override_externally_managed: Callable[..., Option] = partial( + Option, + "--break-system-packages", + dest="override_externally_managed", + action="store_true", + help="Allow pip to modify an EXTERNALLY-MANAGED Python installation", +) + +python: Callable[..., Option] = partial( + Option, + "--python", + dest="python", + help="Run pip with the specified Python interpreter.", +) + +verbose: Callable[..., Option] = partial( + Option, + "-v", + "--verbose", + dest="verbose", + action="count", + default=0, + help="Give more output. Option is additive, and can be used up to 3 times.", +) + +no_color: Callable[..., Option] = partial( + Option, + "--no-color", + dest="no_color", + action="store_true", + default=False, + help="Suppress colored output.", +) + +version: Callable[..., Option] = partial( + Option, + "-V", + "--version", + dest="version", + action="store_true", + help="Show version and exit.", +) + +quiet: Callable[..., Option] = partial( + Option, + "-q", + "--quiet", + dest="quiet", + action="count", + default=0, + help=( + "Give less output. Option is additive, and can be used up to 3" + " times (corresponding to WARNING, ERROR, and CRITICAL logging" + " levels)." + ), +) + +progress_bar: Callable[..., Option] = partial( + Option, + "--progress-bar", + dest="progress_bar", + type="choice", + choices=["on", "off", "raw"], + default="on", + help="Specify whether the progress bar should be used [on, off, raw] (default: on)", +) + +log: Callable[..., Option] = partial( + PipOption, + "--log", + "--log-file", + "--local-log", + dest="log", + metavar="path", + type="path", + help="Path to a verbose appending log.", +) + +no_input: Callable[..., Option] = partial( + Option, + # Don't ask for input + "--no-input", + dest="no_input", + action="store_true", + default=False, + help="Disable prompting for input.", +) + +keyring_provider: Callable[..., Option] = partial( + Option, + "--keyring-provider", + dest="keyring_provider", + choices=["auto", "disabled", "import", "subprocess"], + default="auto", + help=( + "Enable the credential lookup via the keyring library if user input is allowed." + " Specify which mechanism to use [auto, disabled, import, subprocess]." + " (default: %default)" + ), +) + +proxy: Callable[..., Option] = partial( + Option, + "--proxy", + dest="proxy", + type="str", + default="", + help="Specify a proxy in the form scheme://[user:passwd@]proxy.server:port.", +) + +retries: Callable[..., Option] = partial( + Option, + "--retries", + dest="retries", + type="int", + default=5, + help="Maximum attempts to establish a new HTTP connection. (default: %default)", +) + +resume_retries: Callable[..., Option] = partial( + Option, + "--resume-retries", + dest="resume_retries", + type="int", + default=0, + help="Maximum attempts to resume or restart an incomplete download. " + "(default: %default)", +) + +timeout: Callable[..., Option] = partial( + Option, + "--timeout", + "--default-timeout", + metavar="sec", + dest="timeout", + type="float", + default=15, + help="Set the socket timeout (default %default seconds).", +) + + +def exists_action() -> Option: + return Option( + # Option when path already exist + "--exists-action", + dest="exists_action", + type="choice", + choices=["s", "i", "w", "b", "a"], + default=[], + action="append", + metavar="action", + help="Default action when a path already exists: " + "(s)witch, (i)gnore, (w)ipe, (b)ackup, (a)bort.", + ) + + +cert: Callable[..., Option] = partial( + PipOption, + "--cert", + dest="cert", + type="path", + metavar="path", + help=( + "Path to PEM-encoded CA certificate bundle. " + "If provided, overrides the default. " + "See 'SSL Certificate Verification' in pip documentation " + "for more information." + ), +) + +client_cert: Callable[..., Option] = partial( + PipOption, + "--client-cert", + dest="client_cert", + type="path", + default=None, + metavar="path", + help="Path to SSL client certificate, a single file containing the " + "private key and the certificate in PEM format.", +) + +index_url: Callable[..., Option] = partial( + Option, + "-i", + "--index-url", + "--pypi-url", + dest="index_url", + metavar="URL", + default=PyPI.simple_url, + help="Base URL of the Python Package Index (default %default). " + "This should point to a repository compliant with PEP 503 " + "(the simple repository API) or a local directory laid out " + "in the same format.", +) + + +def extra_index_url() -> Option: + return Option( + "--extra-index-url", + dest="extra_index_urls", + metavar="URL", + action="append", + default=[], + help="Extra URLs of package indexes to use in addition to " + "--index-url. Should follow the same rules as " + "--index-url.", + ) + + +no_index: Callable[..., Option] = partial( + Option, + "--no-index", + dest="no_index", + action="store_true", + default=False, + help="Ignore package index (only looking at --find-links URLs instead).", +) + + +def find_links() -> Option: + return Option( + "-f", + "--find-links", + dest="find_links", + action="append", + default=[], + metavar="url", + help="If a URL or path to an html file, then parse for links to " + "archives such as sdist (.tar.gz) or wheel (.whl) files. " + "If a local path or file:// URL that's a directory, " + "then look for archives in the directory listing. " + "Links to VCS project URLs are not supported.", + ) + + +def trusted_host() -> Option: + return Option( + "--trusted-host", + dest="trusted_hosts", + action="append", + metavar="HOSTNAME", + default=[], + help="Mark this host or host:port pair as trusted, even though it " + "does not have valid or any HTTPS.", + ) + + +def constraints() -> Option: + return Option( + "-c", + "--constraint", + dest="constraints", + action="append", + default=[], + metavar="file", + help="Constrain versions using the given constraints file. " + "This option can be used multiple times.", + ) + + +def requirements() -> Option: + return Option( + "-r", + "--requirement", + dest="requirements", + action="append", + default=[], + metavar="file", + help="Install from the given requirements file. " + "This option can be used multiple times.", + ) + + +def editable() -> Option: + return Option( + "-e", + "--editable", + dest="editables", + action="append", + default=[], + metavar="path/url", + help=( + "Install a project in editable mode (i.e. setuptools " + '"develop mode") from a local project path or a VCS url.' + ), + ) + + +def _handle_src(option: Option, opt_str: str, value: str, parser: OptionParser) -> None: + value = os.path.abspath(value) + setattr(parser.values, option.dest, value) + + +src: Callable[..., Option] = partial( + PipOption, + "--src", + "--source", + "--source-dir", + "--source-directory", + dest="src_dir", + type="path", + metavar="dir", + default=get_src_prefix(), + action="callback", + callback=_handle_src, + help="Directory to check out editable projects into. " + 'The default in a virtualenv is "/src". ' + 'The default for global installs is "/src".', +) + + +def _get_format_control(values: Values, option: Option) -> Any: + """Get a format_control object.""" + return getattr(values, option.dest) + + +def _handle_no_binary( + option: Option, opt_str: str, value: str, parser: OptionParser +) -> None: + existing = _get_format_control(parser.values, option) + FormatControl.handle_mutual_excludes( + value, + existing.no_binary, + existing.only_binary, + ) + + +def _handle_only_binary( + option: Option, opt_str: str, value: str, parser: OptionParser +) -> None: + existing = _get_format_control(parser.values, option) + FormatControl.handle_mutual_excludes( + value, + existing.only_binary, + existing.no_binary, + ) + + +def no_binary() -> Option: + format_control = FormatControl(set(), set()) + return Option( + "--no-binary", + dest="format_control", + action="callback", + callback=_handle_no_binary, + type="str", + default=format_control, + help="Do not use binary packages. Can be supplied multiple times, and " + 'each time adds to the existing value. Accepts either ":all:" to ' + 'disable all binary packages, ":none:" to empty the set (notice ' + "the colons), or one or more package names with commas between " + "them (no colons). Note that some packages are tricky to compile " + "and may fail to install when this option is used on them.", + ) + + +def only_binary() -> Option: + format_control = FormatControl(set(), set()) + return Option( + "--only-binary", + dest="format_control", + action="callback", + callback=_handle_only_binary, + type="str", + default=format_control, + help="Do not use source packages. Can be supplied multiple times, and " + 'each time adds to the existing value. Accepts either ":all:" to ' + 'disable all source packages, ":none:" to empty the set, or one ' + "or more package names with commas between them. Packages " + "without binary distributions will fail to install when this " + "option is used on them.", + ) + + +platforms: Callable[..., Option] = partial( + Option, + "--platform", + dest="platforms", + metavar="platform", + action="append", + default=None, + help=( + "Only use wheels compatible with . Defaults to the " + "platform of the running system. Use this option multiple times to " + "specify multiple platforms supported by the target interpreter." + ), +) + + +# This was made a separate function for unit-testing purposes. +def _convert_python_version(value: str) -> Tuple[Tuple[int, ...], Optional[str]]: + """ + Convert a version string like "3", "37", or "3.7.3" into a tuple of ints. + + :return: A 2-tuple (version_info, error_msg), where `error_msg` is + non-None if and only if there was a parsing error. + """ + if not value: + # The empty string is the same as not providing a value. + return (None, None) + + parts = value.split(".") + if len(parts) > 3: + return ((), "at most three version parts are allowed") + + if len(parts) == 1: + # Then we are in the case of "3" or "37". + value = parts[0] + if len(value) > 1: + parts = [value[0], value[1:]] + + try: + version_info = tuple(int(part) for part in parts) + except ValueError: + return ((), "each version part must be an integer") + + return (version_info, None) + + +def _handle_python_version( + option: Option, opt_str: str, value: str, parser: OptionParser +) -> None: + """ + Handle a provided --python-version value. + """ + version_info, error_msg = _convert_python_version(value) + if error_msg is not None: + msg = f"invalid --python-version value: {value!r}: {error_msg}" + raise_option_error(parser, option=option, msg=msg) + + parser.values.python_version = version_info + + +python_version: Callable[..., Option] = partial( + Option, + "--python-version", + dest="python_version", + metavar="python_version", + action="callback", + callback=_handle_python_version, + type="str", + default=None, + help=dedent( + """\ + The Python interpreter version to use for wheel and "Requires-Python" + compatibility checks. Defaults to a version derived from the running + interpreter. The version can be specified using up to three dot-separated + integers (e.g. "3" for 3.0.0, "3.7" for 3.7.0, or "3.7.3"). A major-minor + version can also be given as a string without dots (e.g. "37" for 3.7.0). + """ + ), +) + + +implementation: Callable[..., Option] = partial( + Option, + "--implementation", + dest="implementation", + metavar="implementation", + default=None, + help=( + "Only use wheels compatible with Python " + "implementation , e.g. 'pp', 'jy', 'cp', " + " or 'ip'. If not specified, then the current " + "interpreter implementation is used. Use 'py' to force " + "implementation-agnostic wheels." + ), +) + + +abis: Callable[..., Option] = partial( + Option, + "--abi", + dest="abis", + metavar="abi", + action="append", + default=None, + help=( + "Only use wheels compatible with Python abi , e.g. 'pypy_41'. " + "If not specified, then the current interpreter abi tag is used. " + "Use this option multiple times to specify multiple abis supported " + "by the target interpreter. Generally you will need to specify " + "--implementation, --platform, and --python-version when using this " + "option." + ), +) + + +def add_target_python_options(cmd_opts: OptionGroup) -> None: + cmd_opts.add_option(platforms()) + cmd_opts.add_option(python_version()) + cmd_opts.add_option(implementation()) + cmd_opts.add_option(abis()) + + +def make_target_python(options: Values) -> TargetPython: + target_python = TargetPython( + platforms=options.platforms, + py_version_info=options.python_version, + abis=options.abis, + implementation=options.implementation, + ) + + return target_python + + +def prefer_binary() -> Option: + return Option( + "--prefer-binary", + dest="prefer_binary", + action="store_true", + default=False, + help=( + "Prefer binary packages over source packages, even if the " + "source packages are newer." + ), + ) + + +cache_dir: Callable[..., Option] = partial( + PipOption, + "--cache-dir", + dest="cache_dir", + default=USER_CACHE_DIR, + metavar="dir", + type="path", + help="Store the cache data in .", +) + + +def _handle_no_cache_dir( + option: Option, opt: str, value: str, parser: OptionParser +) -> None: + """ + Process a value provided for the --no-cache-dir option. + + This is an optparse.Option callback for the --no-cache-dir option. + """ + # The value argument will be None if --no-cache-dir is passed via the + # command-line, since the option doesn't accept arguments. However, + # the value can be non-None if the option is triggered e.g. by an + # environment variable, like PIP_NO_CACHE_DIR=true. + if value is not None: + # Then parse the string value to get argument error-checking. + try: + strtobool(value) + except ValueError as exc: + raise_option_error(parser, option=option, msg=str(exc)) + + # Originally, setting PIP_NO_CACHE_DIR to a value that strtobool() + # converted to 0 (like "false" or "no") caused cache_dir to be disabled + # rather than enabled (logic would say the latter). Thus, we disable + # the cache directory not just on values that parse to True, but (for + # backwards compatibility reasons) also on values that parse to False. + # In other words, always set it to False if the option is provided in + # some (valid) form. + parser.values.cache_dir = False + + +no_cache: Callable[..., Option] = partial( + Option, + "--no-cache-dir", + dest="cache_dir", + action="callback", + callback=_handle_no_cache_dir, + help="Disable the cache.", +) + +no_deps: Callable[..., Option] = partial( + Option, + "--no-deps", + "--no-dependencies", + dest="ignore_dependencies", + action="store_true", + default=False, + help="Don't install package dependencies.", +) + + +def _handle_dependency_group( + option: Option, opt: str, value: str, parser: OptionParser +) -> None: + """ + Process a value provided for the --group option. + + Splits on the rightmost ":", and validates that the path (if present) ends + in `pyproject.toml`. Defaults the path to `pyproject.toml` when one is not given. + + `:` cannot appear in dependency group names, so this is a safe and simple parse. + + This is an optparse.Option callback for the dependency_groups option. + """ + path, sep, groupname = value.rpartition(":") + if not sep: + path = "pyproject.toml" + else: + # check for 'pyproject.toml' filenames using pathlib + if pathlib.PurePath(path).name != "pyproject.toml": + msg = "group paths use 'pyproject.toml' filenames" + raise_option_error(parser, option=option, msg=msg) + + parser.values.dependency_groups.append((path, groupname)) + + +dependency_groups: Callable[..., Option] = partial( + Option, + "--group", + dest="dependency_groups", + default=[], + type=str, + action="callback", + callback=_handle_dependency_group, + metavar="[path:]group", + help='Install a named dependency-group from a "pyproject.toml" file. ' + 'If a path is given, the name of the file must be "pyproject.toml". ' + 'Defaults to using "pyproject.toml" in the current directory.', +) + +ignore_requires_python: Callable[..., Option] = partial( + Option, + "--ignore-requires-python", + dest="ignore_requires_python", + action="store_true", + help="Ignore the Requires-Python information.", +) + +no_build_isolation: Callable[..., Option] = partial( + Option, + "--no-build-isolation", + dest="build_isolation", + action="store_false", + default=True, + help="Disable isolation when building a modern source distribution. " + "Build dependencies specified by PEP 518 must be already installed " + "if this option is used.", +) + +check_build_deps: Callable[..., Option] = partial( + Option, + "--check-build-dependencies", + dest="check_build_deps", + action="store_true", + default=False, + help="Check the build dependencies when PEP517 is used.", +) + + +def _handle_no_use_pep517( + option: Option, opt: str, value: str, parser: OptionParser +) -> None: + """ + Process a value provided for the --no-use-pep517 option. + + This is an optparse.Option callback for the no_use_pep517 option. + """ + # Since --no-use-pep517 doesn't accept arguments, the value argument + # will be None if --no-use-pep517 is passed via the command-line. + # However, the value can be non-None if the option is triggered e.g. + # by an environment variable, for example "PIP_NO_USE_PEP517=true". + if value is not None: + msg = """A value was passed for --no-use-pep517, + probably using either the PIP_NO_USE_PEP517 environment variable + or the "no-use-pep517" config file option. Use an appropriate value + of the PIP_USE_PEP517 environment variable or the "use-pep517" + config file option instead. + """ + raise_option_error(parser, option=option, msg=msg) + + # If user doesn't wish to use pep517, we check if setuptools is installed + # and raise error if it is not. + packages = ("setuptools",) + if not all(importlib.util.find_spec(package) for package in packages): + msg = ( + f"It is not possible to use --no-use-pep517 " + f"without {' and '.join(packages)} installed." + ) + raise_option_error(parser, option=option, msg=msg) + + # Otherwise, --no-use-pep517 was passed via the command-line. + parser.values.use_pep517 = False + + +use_pep517: Any = partial( + Option, + "--use-pep517", + dest="use_pep517", + action="store_true", + default=None, + help="Use PEP 517 for building source distributions " + "(use --no-use-pep517 to force legacy behaviour).", +) + +no_use_pep517: Any = partial( + Option, + "--no-use-pep517", + dest="use_pep517", + action="callback", + callback=_handle_no_use_pep517, + default=None, + help=SUPPRESS_HELP, +) + + +def _handle_config_settings( + option: Option, opt_str: str, value: str, parser: OptionParser +) -> None: + key, sep, val = value.partition("=") + if sep != "=": + parser.error(f"Arguments to {opt_str} must be of the form KEY=VAL") + dest = getattr(parser.values, option.dest) + if dest is None: + dest = {} + setattr(parser.values, option.dest, dest) + if key in dest: + if isinstance(dest[key], list): + dest[key].append(val) + else: + dest[key] = [dest[key], val] + else: + dest[key] = val + + +config_settings: Callable[..., Option] = partial( + Option, + "-C", + "--config-settings", + dest="config_settings", + type=str, + action="callback", + callback=_handle_config_settings, + metavar="settings", + help="Configuration settings to be passed to the PEP 517 build backend. " + "Settings take the form KEY=VALUE. Use multiple --config-settings options " + "to pass multiple keys to the backend.", +) + +build_options: Callable[..., Option] = partial( + Option, + "--build-option", + dest="build_options", + metavar="options", + action="append", + help="Extra arguments to be supplied to 'setup.py bdist_wheel'.", +) + +global_options: Callable[..., Option] = partial( + Option, + "--global-option", + dest="global_options", + action="append", + metavar="options", + help="Extra global options to be supplied to the setup.py " + "call before the install or bdist_wheel command.", +) + +no_clean: Callable[..., Option] = partial( + Option, + "--no-clean", + action="store_true", + default=False, + help="Don't clean up build directories.", +) + +pre: Callable[..., Option] = partial( + Option, + "--pre", + action="store_true", + default=False, + help="Include pre-release and development versions. By default, " + "pip only finds stable versions.", +) + +json: Callable[..., Option] = partial( + Option, + "--json", + action="store_true", + default=False, + help="Output data in a machine-readable JSON format.", +) + +disable_pip_version_check: Callable[..., Option] = partial( + Option, + "--disable-pip-version-check", + dest="disable_pip_version_check", + action="store_true", + default=True, + help="Don't periodically check PyPI to determine whether a new version " + "of pip is available for download. Implied with --no-index.", +) + +root_user_action: Callable[..., Option] = partial( + Option, + "--root-user-action", + dest="root_user_action", + default="warn", + choices=["warn", "ignore"], + help="Action if pip is run as a root user [warn, ignore] (default: warn)", +) + + +def _handle_merge_hash( + option: Option, opt_str: str, value: str, parser: OptionParser +) -> None: + """Given a value spelled "algo:digest", append the digest to a list + pointed to in a dict by the algo name.""" + if not parser.values.hashes: + parser.values.hashes = {} + try: + algo, digest = value.split(":", 1) + except ValueError: + parser.error( + f"Arguments to {opt_str} must be a hash name " + "followed by a value, like --hash=sha256:" + "abcde..." + ) + if algo not in STRONG_HASHES: + parser.error( + "Allowed hash algorithms for {} are {}.".format( + opt_str, ", ".join(STRONG_HASHES) + ) + ) + parser.values.hashes.setdefault(algo, []).append(digest) + + +hash: Callable[..., Option] = partial( + Option, + "--hash", + # Hash values eventually end up in InstallRequirement.hashes due to + # __dict__ copying in process_line(). + dest="hashes", + action="callback", + callback=_handle_merge_hash, + type="string", + help="Verify that the package's archive matches this " + "hash before installing. Example: --hash=sha256:abcdef...", +) + + +require_hashes: Callable[..., Option] = partial( + Option, + "--require-hashes", + dest="require_hashes", + action="store_true", + default=False, + help="Require a hash to check each requirement against, for " + "repeatable installs. This option is implied when any package in a " + "requirements file has a --hash option.", +) + + +list_path: Callable[..., Option] = partial( + PipOption, + "--path", + dest="path", + type="path", + action="append", + help="Restrict to the specified installation path for listing " + "packages (can be used multiple times).", +) + + +def check_list_path_option(options: Values) -> None: + if options.path and (options.user or options.local): + raise CommandError("Cannot combine '--path' with '--user' or '--local'") + + +list_exclude: Callable[..., Option] = partial( + PipOption, + "--exclude", + dest="excludes", + action="append", + metavar="package", + type="package_name", + help="Exclude specified package from the output", +) + + +no_python_version_warning: Callable[..., Option] = partial( + Option, + "--no-python-version-warning", + dest="no_python_version_warning", + action="store_true", + default=False, + help=SUPPRESS_HELP, # No-op, a hold-over from the Python 2->3 transition. +) + + +# Features that are now always on. A warning is printed if they are used. +ALWAYS_ENABLED_FEATURES = [ + "truststore", # always on since 24.2 + "no-binary-enable-wheel-cache", # always on since 23.1 +] + +use_new_feature: Callable[..., Option] = partial( + Option, + "--use-feature", + dest="features_enabled", + metavar="feature", + action="append", + default=[], + choices=[ + "fast-deps", + ] + + ALWAYS_ENABLED_FEATURES, + help="Enable new functionality, that may be backward incompatible.", +) + +use_deprecated_feature: Callable[..., Option] = partial( + Option, + "--use-deprecated", + dest="deprecated_features_enabled", + metavar="feature", + action="append", + default=[], + choices=[ + "legacy-resolver", + "legacy-certs", + ], + help=("Enable deprecated functionality, that will be removed in the future."), +) + +########## +# groups # +########## + +general_group: Dict[str, Any] = { + "name": "General Options", + "options": [ + help_, + debug_mode, + isolated_mode, + require_virtualenv, + python, + verbose, + version, + quiet, + log, + no_input, + keyring_provider, + proxy, + retries, + timeout, + exists_action, + trusted_host, + cert, + client_cert, + cache_dir, + no_cache, + disable_pip_version_check, + no_color, + no_python_version_warning, + use_new_feature, + use_deprecated_feature, + resume_retries, + ], +} + +index_group: Dict[str, Any] = { + "name": "Package Index Options", + "options": [ + index_url, + extra_index_url, + no_index, + find_links, + ], +} diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/command_context.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/command_context.py new file mode 100644 index 0000000000000000000000000000000000000000..139995ac3f109a82664e4913f7ebc32ecf7617e1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/command_context.py @@ -0,0 +1,27 @@ +from contextlib import ExitStack, contextmanager +from typing import ContextManager, Generator, TypeVar + +_T = TypeVar("_T", covariant=True) + + +class CommandContextMixIn: + def __init__(self) -> None: + super().__init__() + self._in_main_context = False + self._main_context = ExitStack() + + @contextmanager + def main_context(self) -> Generator[None, None, None]: + assert not self._in_main_context + + self._in_main_context = True + try: + with self._main_context: + yield + finally: + self._in_main_context = False + + def enter_context(self, context_provider: ContextManager[_T]) -> _T: + assert self._in_main_context + + return self._main_context.enter_context(context_provider) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/index_command.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/index_command.py new file mode 100644 index 0000000000000000000000000000000000000000..87a1e088564fc6977d03f94388c3b171dbd36a3d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/index_command.py @@ -0,0 +1,173 @@ +""" +Contains command classes which may interact with an index / the network. + +Unlike its sister module, req_command, this module still uses lazy imports +so commands which don't always hit the network (e.g. list w/o --outdated or +--uptodate) don't need waste time importing PipSession and friends. +""" + +import logging +import os +import sys +from functools import lru_cache +from optparse import Values +from typing import TYPE_CHECKING, List, Optional + +from pip._vendor import certifi + +from pip._internal.cli.base_command import Command +from pip._internal.cli.command_context import CommandContextMixIn + +if TYPE_CHECKING: + from ssl import SSLContext + + from pip._internal.network.session import PipSession + +logger = logging.getLogger(__name__) + + +@lru_cache +def _create_truststore_ssl_context() -> Optional["SSLContext"]: + if sys.version_info < (3, 10): + logger.debug("Disabling truststore because Python version isn't 3.10+") + return None + + try: + import ssl + except ImportError: + logger.warning("Disabling truststore since ssl support is missing") + return None + + try: + from pip._vendor import truststore + except ImportError: + logger.warning("Disabling truststore because platform isn't supported") + return None + + ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_verify_locations(certifi.where()) + return ctx + + +class SessionCommandMixin(CommandContextMixIn): + """ + A class mixin for command classes needing _build_session(). + """ + + def __init__(self) -> None: + super().__init__() + self._session: Optional[PipSession] = None + + @classmethod + def _get_index_urls(cls, options: Values) -> Optional[List[str]]: + """Return a list of index urls from user-provided options.""" + index_urls = [] + if not getattr(options, "no_index", False): + url = getattr(options, "index_url", None) + if url: + index_urls.append(url) + urls = getattr(options, "extra_index_urls", None) + if urls: + index_urls.extend(urls) + # Return None rather than an empty list + return index_urls or None + + def get_default_session(self, options: Values) -> "PipSession": + """Get a default-managed session.""" + if self._session is None: + self._session = self.enter_context(self._build_session(options)) + # there's no type annotation on requests.Session, so it's + # automatically ContextManager[Any] and self._session becomes Any, + # then https://github.com/python/mypy/issues/7696 kicks in + assert self._session is not None + return self._session + + def _build_session( + self, + options: Values, + retries: Optional[int] = None, + timeout: Optional[int] = None, + ) -> "PipSession": + from pip._internal.network.session import PipSession + + cache_dir = options.cache_dir + assert not cache_dir or os.path.isabs(cache_dir) + + if "legacy-certs" not in options.deprecated_features_enabled: + ssl_context = _create_truststore_ssl_context() + else: + ssl_context = None + + session = PipSession( + cache=os.path.join(cache_dir, "http-v2") if cache_dir else None, + retries=retries if retries is not None else options.retries, + trusted_hosts=options.trusted_hosts, + index_urls=self._get_index_urls(options), + ssl_context=ssl_context, + ) + + # Handle custom ca-bundles from the user + if options.cert: + session.verify = options.cert + + # Handle SSL client certificate + if options.client_cert: + session.cert = options.client_cert + + # Handle timeouts + if options.timeout or timeout: + session.timeout = timeout if timeout is not None else options.timeout + + # Handle configured proxies + if options.proxy: + session.proxies = { + "http": options.proxy, + "https": options.proxy, + } + session.trust_env = False + session.pip_proxy = options.proxy + + # Determine if we can prompt the user for authentication or not + session.auth.prompting = not options.no_input + session.auth.keyring_provider = options.keyring_provider + + return session + + +def _pip_self_version_check(session: "PipSession", options: Values) -> None: + from pip._internal.self_outdated_check import pip_self_version_check as check + + check(session, options) + + +class IndexGroupCommand(Command, SessionCommandMixin): + """ + Abstract base class for commands with the index_group options. + + This also corresponds to the commands that permit the pip version check. + """ + + def handle_pip_version_check(self, options: Values) -> None: + """ + Do the pip version check if not disabled. + + This overrides the default behavior of not doing the check. + """ + # Make sure the index_group options are present. + assert hasattr(options, "no_index") + + if options.disable_pip_version_check or options.no_index: + return + + try: + # Otherwise, check if we're using the latest version of pip available. + session = self._build_session( + options, + retries=0, + timeout=min(5, options.timeout), + ) + with session: + _pip_self_version_check(session, options) + except Exception: + logger.warning("There was an error checking the latest version of pip.") + logger.debug("See below for error", exc_info=True) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/main.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/main.py new file mode 100644 index 0000000000000000000000000000000000000000..377476c18c3b9acbe23916cea4fe16812fdc46d7 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/main.py @@ -0,0 +1,79 @@ +"""Primary application entrypoint.""" + +import locale +import logging +import os +import sys +import warnings +from typing import List, Optional + +from pip._internal.cli.autocompletion import autocomplete +from pip._internal.cli.main_parser import parse_command +from pip._internal.commands import create_command +from pip._internal.exceptions import PipError +from pip._internal.utils import deprecation + +logger = logging.getLogger(__name__) + + +# Do not import and use main() directly! Using it directly is actively +# discouraged by pip's maintainers. The name, location and behavior of +# this function is subject to change, so calling it directly is not +# portable across different pip versions. + +# In addition, running pip in-process is unsupported and unsafe. This is +# elaborated in detail at +# https://pip.pypa.io/en/stable/user_guide/#using-pip-from-your-program. +# That document also provides suggestions that should work for nearly +# all users that are considering importing and using main() directly. + +# However, we know that certain users will still want to invoke pip +# in-process. If you understand and accept the implications of using pip +# in an unsupported manner, the best approach is to use runpy to avoid +# depending on the exact location of this entry point. + +# The following example shows how to use runpy to invoke pip in that +# case: +# +# sys.argv = ["pip", your, args, here] +# runpy.run_module("pip", run_name="__main__") +# +# Note that this will exit the process after running, unlike a direct +# call to main. As it is not safe to do any processing after calling +# main, this should not be an issue in practice. + + +def main(args: Optional[List[str]] = None) -> int: + if args is None: + args = sys.argv[1:] + + # Suppress the pkg_resources deprecation warning + # Note - we use a module of .*pkg_resources to cover + # the normal case (pip._vendor.pkg_resources) and the + # devendored case (a bare pkg_resources) + warnings.filterwarnings( + action="ignore", category=DeprecationWarning, module=".*pkg_resources" + ) + + # Configure our deprecation warnings to be sent through loggers + deprecation.install_warning_logger() + + autocomplete() + + try: + cmd_name, cmd_args = parse_command(args) + except PipError as exc: + sys.stderr.write(f"ERROR: {exc}") + sys.stderr.write(os.linesep) + sys.exit(1) + + # Needed for locale.getpreferredencoding(False) to work + # in pip._internal.utils.encoding.auto_decode + try: + locale.setlocale(locale.LC_ALL, "") + except locale.Error as e: + # setlocale can apparently crash if locale are uninitialized + logger.debug("Ignoring error %s when setting locale", e) + command = create_command(cmd_name, isolated=("--isolated" in cmd_args)) + + return command.main(cmd_args) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/main_parser.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/main_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..c52684a81fed4da41ad5b6ba70103852d6172e3d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/main_parser.py @@ -0,0 +1,133 @@ +"""A single place for constructing and exposing the main parser""" + +import os +import subprocess +import sys +from typing import List, Optional, Tuple + +from pip._internal.build_env import get_runnable_pip +from pip._internal.cli import cmdoptions +from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter +from pip._internal.commands import commands_dict, get_similar_commands +from pip._internal.exceptions import CommandError +from pip._internal.utils.misc import get_pip_version, get_prog + +__all__ = ["create_main_parser", "parse_command"] + + +def create_main_parser() -> ConfigOptionParser: + """Creates and returns the main parser for pip's CLI""" + + parser = ConfigOptionParser( + usage="\n%prog [options]", + add_help_option=False, + formatter=UpdatingDefaultsHelpFormatter(), + name="global", + prog=get_prog(), + ) + parser.disable_interspersed_args() + + parser.version = get_pip_version() + + # add the general options + gen_opts = cmdoptions.make_option_group(cmdoptions.general_group, parser) + parser.add_option_group(gen_opts) + + # so the help formatter knows + parser.main = True # type: ignore + + # create command listing for description + description = [""] + [ + f"{name:27} {command_info.summary}" + for name, command_info in commands_dict.items() + ] + parser.description = "\n".join(description) + + return parser + + +def identify_python_interpreter(python: str) -> Optional[str]: + # If the named file exists, use it. + # If it's a directory, assume it's a virtual environment and + # look for the environment's Python executable. + if os.path.exists(python): + if os.path.isdir(python): + # bin/python for Unix, Scripts/python.exe for Windows + # Try both in case of odd cases like cygwin. + for exe in ("bin/python", "Scripts/python.exe"): + py = os.path.join(python, exe) + if os.path.exists(py): + return py + else: + return python + + # Could not find the interpreter specified + return None + + +def parse_command(args: List[str]) -> Tuple[str, List[str]]: + parser = create_main_parser() + + # Note: parser calls disable_interspersed_args(), so the result of this + # call is to split the initial args into the general options before the + # subcommand and everything else. + # For example: + # args: ['--timeout=5', 'install', '--user', 'INITools'] + # general_options: ['--timeout==5'] + # args_else: ['install', '--user', 'INITools'] + general_options, args_else = parser.parse_args(args) + + # --python + if general_options.python and "_PIP_RUNNING_IN_SUBPROCESS" not in os.environ: + # Re-invoke pip using the specified Python interpreter + interpreter = identify_python_interpreter(general_options.python) + if interpreter is None: + raise CommandError( + f"Could not locate Python interpreter {general_options.python}" + ) + + pip_cmd = [ + interpreter, + get_runnable_pip(), + ] + pip_cmd.extend(args) + + # Set a flag so the child doesn't re-invoke itself, causing + # an infinite loop. + os.environ["_PIP_RUNNING_IN_SUBPROCESS"] = "1" + returncode = 0 + try: + proc = subprocess.run(pip_cmd) + returncode = proc.returncode + except (subprocess.SubprocessError, OSError) as exc: + raise CommandError(f"Failed to run pip under {interpreter}: {exc}") + sys.exit(returncode) + + # --version + if general_options.version: + sys.stdout.write(parser.version) + sys.stdout.write(os.linesep) + sys.exit() + + # pip || pip help -> print_help() + if not args_else or (args_else[0] == "help" and len(args_else) == 1): + parser.print_help() + sys.exit() + + # the subcommand name + cmd_name = args_else[0] + + if cmd_name not in commands_dict: + guess = get_similar_commands(cmd_name) + + msg = [f'unknown command "{cmd_name}"'] + if guess: + msg.append(f'maybe you meant "{guess}"') + + raise CommandError(" - ".join(msg)) + + # all the args without the subcommand + cmd_args = args[:] + cmd_args.remove(cmd_name) + + return cmd_name, cmd_args diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/parser.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..bc4aca032d4adddbb7b9d8b0c981187aa11e40fc --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/parser.py @@ -0,0 +1,294 @@ +"""Base option parser setup""" + +import logging +import optparse +import shutil +import sys +import textwrap +from contextlib import suppress +from typing import Any, Dict, Generator, List, NoReturn, Optional, Tuple + +from pip._internal.cli.status_codes import UNKNOWN_ERROR +from pip._internal.configuration import Configuration, ConfigurationError +from pip._internal.utils.misc import redact_auth_from_url, strtobool + +logger = logging.getLogger(__name__) + + +class PrettyHelpFormatter(optparse.IndentedHelpFormatter): + """A prettier/less verbose help formatter for optparse.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + # help position must be aligned with __init__.parseopts.description + kwargs["max_help_position"] = 30 + kwargs["indent_increment"] = 1 + kwargs["width"] = shutil.get_terminal_size()[0] - 2 + super().__init__(*args, **kwargs) + + def format_option_strings(self, option: optparse.Option) -> str: + return self._format_option_strings(option) + + def _format_option_strings( + self, option: optparse.Option, mvarfmt: str = " <{}>", optsep: str = ", " + ) -> str: + """ + Return a comma-separated list of option strings and metavars. + + :param option: tuple of (short opt, long opt), e.g: ('-f', '--format') + :param mvarfmt: metavar format string + :param optsep: separator + """ + opts = [] + + if option._short_opts: + opts.append(option._short_opts[0]) + if option._long_opts: + opts.append(option._long_opts[0]) + if len(opts) > 1: + opts.insert(1, optsep) + + if option.takes_value(): + assert option.dest is not None + metavar = option.metavar or option.dest.lower() + opts.append(mvarfmt.format(metavar.lower())) + + return "".join(opts) + + def format_heading(self, heading: str) -> str: + if heading == "Options": + return "" + return heading + ":\n" + + def format_usage(self, usage: str) -> str: + """ + Ensure there is only one newline between usage and the first heading + if there is no description. + """ + msg = "\nUsage: {}\n".format(self.indent_lines(textwrap.dedent(usage), " ")) + return msg + + def format_description(self, description: Optional[str]) -> str: + # leave full control over description to us + if description: + if hasattr(self.parser, "main"): + label = "Commands" + else: + label = "Description" + # some doc strings have initial newlines, some don't + description = description.lstrip("\n") + # some doc strings have final newlines and spaces, some don't + description = description.rstrip() + # dedent, then reindent + description = self.indent_lines(textwrap.dedent(description), " ") + description = f"{label}:\n{description}\n" + return description + else: + return "" + + def format_epilog(self, epilog: Optional[str]) -> str: + # leave full control over epilog to us + if epilog: + return epilog + else: + return "" + + def indent_lines(self, text: str, indent: str) -> str: + new_lines = [indent + line for line in text.split("\n")] + return "\n".join(new_lines) + + +class UpdatingDefaultsHelpFormatter(PrettyHelpFormatter): + """Custom help formatter for use in ConfigOptionParser. + + This is updates the defaults before expanding them, allowing + them to show up correctly in the help listing. + + Also redact auth from url type options + """ + + def expand_default(self, option: optparse.Option) -> str: + default_values = None + if self.parser is not None: + assert isinstance(self.parser, ConfigOptionParser) + self.parser._update_defaults(self.parser.defaults) + assert option.dest is not None + default_values = self.parser.defaults.get(option.dest) + help_text = super().expand_default(option) + + if default_values and option.metavar == "URL": + if isinstance(default_values, str): + default_values = [default_values] + + # If its not a list, we should abort and just return the help text + if not isinstance(default_values, list): + default_values = [] + + for val in default_values: + help_text = help_text.replace(val, redact_auth_from_url(val)) + + return help_text + + +class CustomOptionParser(optparse.OptionParser): + def insert_option_group( + self, idx: int, *args: Any, **kwargs: Any + ) -> optparse.OptionGroup: + """Insert an OptionGroup at a given position.""" + group = self.add_option_group(*args, **kwargs) + + self.option_groups.pop() + self.option_groups.insert(idx, group) + + return group + + @property + def option_list_all(self) -> List[optparse.Option]: + """Get a list of all options, including those in option groups.""" + res = self.option_list[:] + for i in self.option_groups: + res.extend(i.option_list) + + return res + + +class ConfigOptionParser(CustomOptionParser): + """Custom option parser which updates its defaults by checking the + configuration files and environmental variables""" + + def __init__( + self, + *args: Any, + name: str, + isolated: bool = False, + **kwargs: Any, + ) -> None: + self.name = name + self.config = Configuration(isolated) + + assert self.name + super().__init__(*args, **kwargs) + + def check_default(self, option: optparse.Option, key: str, val: Any) -> Any: + try: + return option.check_value(key, val) + except optparse.OptionValueError as exc: + print(f"An error occurred during configuration: {exc}") + sys.exit(3) + + def _get_ordered_configuration_items( + self, + ) -> Generator[Tuple[str, Any], None, None]: + # Configuration gives keys in an unordered manner. Order them. + override_order = ["global", self.name, ":env:"] + + # Pool the options into different groups + section_items: Dict[str, List[Tuple[str, Any]]] = { + name: [] for name in override_order + } + for section_key, val in self.config.items(): + # ignore empty values + if not val: + logger.debug( + "Ignoring configuration key '%s' as it's value is empty.", + section_key, + ) + continue + + section, key = section_key.split(".", 1) + if section in override_order: + section_items[section].append((key, val)) + + # Yield each group in their override order + for section in override_order: + for key, val in section_items[section]: + yield key, val + + def _update_defaults(self, defaults: Dict[str, Any]) -> Dict[str, Any]: + """Updates the given defaults with values from the config files and + the environ. Does a little special handling for certain types of + options (lists).""" + + # Accumulate complex default state. + self.values = optparse.Values(self.defaults) + late_eval = set() + # Then set the options with those values + for key, val in self._get_ordered_configuration_items(): + # '--' because configuration supports only long names + option = self.get_option("--" + key) + + # Ignore options not present in this parser. E.g. non-globals put + # in [global] by users that want them to apply to all applicable + # commands. + if option is None: + continue + + assert option.dest is not None + + if option.action in ("store_true", "store_false"): + try: + val = strtobool(val) + except ValueError: + self.error( + f"{val} is not a valid value for {key} option, " + "please specify a boolean value like yes/no, " + "true/false or 1/0 instead." + ) + elif option.action == "count": + with suppress(ValueError): + val = strtobool(val) + with suppress(ValueError): + val = int(val) + if not isinstance(val, int) or val < 0: + self.error( + f"{val} is not a valid value for {key} option, " + "please instead specify either a non-negative integer " + "or a boolean value like yes/no or false/true " + "which is equivalent to 1/0." + ) + elif option.action == "append": + val = val.split() + val = [self.check_default(option, key, v) for v in val] + elif option.action == "callback": + assert option.callback is not None + late_eval.add(option.dest) + opt_str = option.get_opt_string() + val = option.convert_value(opt_str, val) + # From take_action + args = option.callback_args or () + kwargs = option.callback_kwargs or {} + option.callback(option, opt_str, val, self, *args, **kwargs) + else: + val = self.check_default(option, key, val) + + defaults[option.dest] = val + + for key in late_eval: + defaults[key] = getattr(self.values, key) + self.values = None + return defaults + + def get_default_values(self) -> optparse.Values: + """Overriding to make updating the defaults after instantiation of + the option parser possible, _update_defaults() does the dirty work.""" + if not self.process_default_values: + # Old, pre-Optik 1.5 behaviour. + return optparse.Values(self.defaults) + + # Load the configuration, or error out in case of an error + try: + self.config.load() + except ConfigurationError as err: + self.exit(UNKNOWN_ERROR, str(err)) + + defaults = self._update_defaults(self.defaults.copy()) # ours + for option in self._get_all_options(): + assert option.dest is not None + default = defaults.get(option.dest) + if isinstance(default, str): + opt_str = option.get_opt_string() + defaults[option.dest] = option.check_value(opt_str, default) + return optparse.Values(defaults) + + def error(self, msg: str) -> NoReturn: + self.print_usage(sys.stderr) + self.exit(UNKNOWN_ERROR, f"{msg}\n") diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/progress_bars.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/progress_bars.py new file mode 100644 index 0000000000000000000000000000000000000000..ab9d76b252ee1d77b48df80e8e66ea0087a73f33 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/progress_bars.py @@ -0,0 +1,144 @@ +import functools +import sys +from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple, TypeVar + +from pip._vendor.rich.progress import ( + BarColumn, + DownloadColumn, + FileSizeColumn, + MofNCompleteColumn, + Progress, + ProgressColumn, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, + TransferSpeedColumn, +) + +from pip._internal.cli.spinners import RateLimiter +from pip._internal.req.req_install import InstallRequirement +from pip._internal.utils.logging import get_console, get_indentation + +T = TypeVar("T") +ProgressRenderer = Callable[[Iterable[T]], Iterator[T]] + + +def _rich_download_progress_bar( + iterable: Iterable[bytes], + *, + bar_type: str, + size: Optional[int], + initial_progress: Optional[int] = None, +) -> Generator[bytes, None, None]: + assert bar_type == "on", "This should only be used in the default mode." + + if not size: + total = float("inf") + columns: Tuple[ProgressColumn, ...] = ( + TextColumn("[progress.description]{task.description}"), + SpinnerColumn("line", speed=1.5), + FileSizeColumn(), + TransferSpeedColumn(), + TimeElapsedColumn(), + ) + else: + total = size + columns = ( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + TextColumn("eta"), + TimeRemainingColumn(), + ) + + progress = Progress(*columns, refresh_per_second=5) + task_id = progress.add_task(" " * (get_indentation() + 2), total=total) + if initial_progress is not None: + progress.update(task_id, advance=initial_progress) + with progress: + for chunk in iterable: + yield chunk + progress.update(task_id, advance=len(chunk)) + + +def _rich_install_progress_bar( + iterable: Iterable[InstallRequirement], *, total: int +) -> Iterator[InstallRequirement]: + columns = ( + TextColumn("{task.fields[indent]}"), + BarColumn(), + MofNCompleteColumn(), + TextColumn("{task.description}"), + ) + console = get_console() + + bar = Progress(*columns, refresh_per_second=6, console=console, transient=True) + # Hiding the progress bar at initialization forces a refresh cycle to occur + # until the bar appears, avoiding very short flashes. + task = bar.add_task("", total=total, indent=" " * get_indentation(), visible=False) + with bar: + for req in iterable: + bar.update(task, description=rf"\[{req.name}]", visible=True) + yield req + bar.advance(task) + + +def _raw_progress_bar( + iterable: Iterable[bytes], + *, + size: Optional[int], + initial_progress: Optional[int] = None, +) -> Generator[bytes, None, None]: + def write_progress(current: int, total: int) -> None: + sys.stdout.write(f"Progress {current} of {total}\n") + sys.stdout.flush() + + current = initial_progress or 0 + total = size or 0 + rate_limiter = RateLimiter(0.25) + + write_progress(current, total) + for chunk in iterable: + current += len(chunk) + if rate_limiter.ready() or current == total: + write_progress(current, total) + rate_limiter.reset() + yield chunk + + +def get_download_progress_renderer( + *, bar_type: str, size: Optional[int] = None, initial_progress: Optional[int] = None +) -> ProgressRenderer[bytes]: + """Get an object that can be used to render the download progress. + + Returns a callable, that takes an iterable to "wrap". + """ + if bar_type == "on": + return functools.partial( + _rich_download_progress_bar, + bar_type=bar_type, + size=size, + initial_progress=initial_progress, + ) + elif bar_type == "raw": + return functools.partial( + _raw_progress_bar, + size=size, + initial_progress=initial_progress, + ) + else: + return iter # no-op, when passed an iterator + + +def get_install_progress_renderer( + *, bar_type: str, total: int +) -> ProgressRenderer[InstallRequirement]: + """Get an object that can be used to render the install progress. + Returns a callable, that takes an iterable to "wrap". + """ + if bar_type == "on": + return functools.partial(_rich_install_progress_bar, total=total) + else: + return iter diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/req_command.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/req_command.py new file mode 100644 index 0000000000000000000000000000000000000000..d9b51427055831cc1eef1112ff8076a660f0d607 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/req_command.py @@ -0,0 +1,347 @@ +"""Contains the RequirementCommand base class. + +This class is in a separate module so the commands that do not always +need PackageFinder capability don't unnecessarily import the +PackageFinder machinery and all its vendored dependencies, etc. +""" + +import logging +from functools import partial +from optparse import Values +from typing import Any, List, Optional, Tuple + +from pip._internal.cache import WheelCache +from pip._internal.cli import cmdoptions +from pip._internal.cli.index_command import IndexGroupCommand +from pip._internal.cli.index_command import SessionCommandMixin as SessionCommandMixin +from pip._internal.exceptions import CommandError, PreviousBuildDirError +from pip._internal.index.collector import LinkCollector +from pip._internal.index.package_finder import PackageFinder +from pip._internal.models.selection_prefs import SelectionPreferences +from pip._internal.models.target_python import TargetPython +from pip._internal.network.session import PipSession +from pip._internal.operations.build.build_tracker import BuildTracker +from pip._internal.operations.prepare import RequirementPreparer +from pip._internal.req.constructors import ( + install_req_from_editable, + install_req_from_line, + install_req_from_parsed_requirement, + install_req_from_req_string, +) +from pip._internal.req.req_dependency_group import parse_dependency_groups +from pip._internal.req.req_file import parse_requirements +from pip._internal.req.req_install import InstallRequirement +from pip._internal.resolution.base import BaseResolver +from pip._internal.utils.temp_dir import ( + TempDirectory, + TempDirectoryTypeRegistry, + tempdir_kinds, +) + +logger = logging.getLogger(__name__) + + +KEEPABLE_TEMPDIR_TYPES = [ + tempdir_kinds.BUILD_ENV, + tempdir_kinds.EPHEM_WHEEL_CACHE, + tempdir_kinds.REQ_BUILD, +] + + +def with_cleanup(func: Any) -> Any: + """Decorator for common logic related to managing temporary + directories. + """ + + def configure_tempdir_registry(registry: TempDirectoryTypeRegistry) -> None: + for t in KEEPABLE_TEMPDIR_TYPES: + registry.set_delete(t, False) + + def wrapper( + self: RequirementCommand, options: Values, args: List[Any] + ) -> Optional[int]: + assert self.tempdir_registry is not None + if options.no_clean: + configure_tempdir_registry(self.tempdir_registry) + + try: + return func(self, options, args) + except PreviousBuildDirError: + # This kind of conflict can occur when the user passes an explicit + # build directory with a pre-existing folder. In that case we do + # not want to accidentally remove it. + configure_tempdir_registry(self.tempdir_registry) + raise + + return wrapper + + +class RequirementCommand(IndexGroupCommand): + def __init__(self, *args: Any, **kw: Any) -> None: + super().__init__(*args, **kw) + + self.cmd_opts.add_option(cmdoptions.dependency_groups()) + self.cmd_opts.add_option(cmdoptions.no_clean()) + + @staticmethod + def determine_resolver_variant(options: Values) -> str: + """Determines which resolver should be used, based on the given options.""" + if "legacy-resolver" in options.deprecated_features_enabled: + return "legacy" + + return "resolvelib" + + @classmethod + def make_requirement_preparer( + cls, + temp_build_dir: TempDirectory, + options: Values, + build_tracker: BuildTracker, + session: PipSession, + finder: PackageFinder, + use_user_site: bool, + download_dir: Optional[str] = None, + verbosity: int = 0, + ) -> RequirementPreparer: + """ + Create a RequirementPreparer instance for the given parameters. + """ + temp_build_dir_path = temp_build_dir.path + assert temp_build_dir_path is not None + legacy_resolver = False + + resolver_variant = cls.determine_resolver_variant(options) + if resolver_variant == "resolvelib": + lazy_wheel = "fast-deps" in options.features_enabled + if lazy_wheel: + logger.warning( + "pip is using lazily downloaded wheels using HTTP " + "range requests to obtain dependency information. " + "This experimental feature is enabled through " + "--use-feature=fast-deps and it is not ready for " + "production." + ) + else: + legacy_resolver = True + lazy_wheel = False + if "fast-deps" in options.features_enabled: + logger.warning( + "fast-deps has no effect when used with the legacy resolver." + ) + + return RequirementPreparer( + build_dir=temp_build_dir_path, + src_dir=options.src_dir, + download_dir=download_dir, + build_isolation=options.build_isolation, + check_build_deps=options.check_build_deps, + build_tracker=build_tracker, + session=session, + progress_bar=options.progress_bar, + finder=finder, + require_hashes=options.require_hashes, + use_user_site=use_user_site, + lazy_wheel=lazy_wheel, + verbosity=verbosity, + legacy_resolver=legacy_resolver, + resume_retries=options.resume_retries, + ) + + @classmethod + def make_resolver( + cls, + preparer: RequirementPreparer, + finder: PackageFinder, + options: Values, + wheel_cache: Optional[WheelCache] = None, + use_user_site: bool = False, + ignore_installed: bool = True, + ignore_requires_python: bool = False, + force_reinstall: bool = False, + upgrade_strategy: str = "to-satisfy-only", + use_pep517: Optional[bool] = None, + py_version_info: Optional[Tuple[int, ...]] = None, + ) -> BaseResolver: + """ + Create a Resolver instance for the given parameters. + """ + make_install_req = partial( + install_req_from_req_string, + isolated=options.isolated_mode, + use_pep517=use_pep517, + ) + resolver_variant = cls.determine_resolver_variant(options) + # The long import name and duplicated invocation is needed to convince + # Mypy into correctly typechecking. Otherwise it would complain the + # "Resolver" class being redefined. + if resolver_variant == "resolvelib": + import pip._internal.resolution.resolvelib.resolver + + return pip._internal.resolution.resolvelib.resolver.Resolver( + preparer=preparer, + finder=finder, + wheel_cache=wheel_cache, + make_install_req=make_install_req, + use_user_site=use_user_site, + ignore_dependencies=options.ignore_dependencies, + ignore_installed=ignore_installed, + ignore_requires_python=ignore_requires_python, + force_reinstall=force_reinstall, + upgrade_strategy=upgrade_strategy, + py_version_info=py_version_info, + ) + import pip._internal.resolution.legacy.resolver + + return pip._internal.resolution.legacy.resolver.Resolver( + preparer=preparer, + finder=finder, + wheel_cache=wheel_cache, + make_install_req=make_install_req, + use_user_site=use_user_site, + ignore_dependencies=options.ignore_dependencies, + ignore_installed=ignore_installed, + ignore_requires_python=ignore_requires_python, + force_reinstall=force_reinstall, + upgrade_strategy=upgrade_strategy, + py_version_info=py_version_info, + ) + + def get_requirements( + self, + args: List[str], + options: Values, + finder: PackageFinder, + session: PipSession, + ) -> List[InstallRequirement]: + """ + Parse command-line arguments into the corresponding requirements. + """ + requirements: List[InstallRequirement] = [] + for filename in options.constraints: + for parsed_req in parse_requirements( + filename, + constraint=True, + finder=finder, + options=options, + session=session, + ): + req_to_add = install_req_from_parsed_requirement( + parsed_req, + isolated=options.isolated_mode, + user_supplied=False, + ) + requirements.append(req_to_add) + + for req in args: + req_to_add = install_req_from_line( + req, + comes_from=None, + isolated=options.isolated_mode, + use_pep517=options.use_pep517, + user_supplied=True, + config_settings=getattr(options, "config_settings", None), + ) + requirements.append(req_to_add) + + if options.dependency_groups: + for req in parse_dependency_groups(options.dependency_groups): + req_to_add = install_req_from_req_string( + req, + isolated=options.isolated_mode, + use_pep517=options.use_pep517, + user_supplied=True, + ) + requirements.append(req_to_add) + + for req in options.editables: + req_to_add = install_req_from_editable( + req, + user_supplied=True, + isolated=options.isolated_mode, + use_pep517=options.use_pep517, + config_settings=getattr(options, "config_settings", None), + ) + requirements.append(req_to_add) + + # NOTE: options.require_hashes may be set if --require-hashes is True + for filename in options.requirements: + for parsed_req in parse_requirements( + filename, finder=finder, options=options, session=session + ): + req_to_add = install_req_from_parsed_requirement( + parsed_req, + isolated=options.isolated_mode, + use_pep517=options.use_pep517, + user_supplied=True, + config_settings=( + parsed_req.options.get("config_settings") + if parsed_req.options + else None + ), + ) + requirements.append(req_to_add) + + # If any requirement has hash options, enable hash checking. + if any(req.has_hash_options for req in requirements): + options.require_hashes = True + + if not ( + args + or options.editables + or options.requirements + or options.dependency_groups + ): + opts = {"name": self.name} + if options.find_links: + raise CommandError( + "You must give at least one requirement to {name} " + '(maybe you meant "pip {name} {links}"?)'.format( + **dict(opts, links=" ".join(options.find_links)) + ) + ) + else: + raise CommandError( + "You must give at least one requirement to {name} " + '(see "pip help {name}")'.format(**opts) + ) + + return requirements + + @staticmethod + def trace_basic_info(finder: PackageFinder) -> None: + """ + Trace basic information about the provided objects. + """ + # Display where finder is looking for packages + search_scope = finder.search_scope + locations = search_scope.get_formatted_locations() + if locations: + logger.info(locations) + + def _build_package_finder( + self, + options: Values, + session: PipSession, + target_python: Optional[TargetPython] = None, + ignore_requires_python: Optional[bool] = None, + ) -> PackageFinder: + """ + Create a package finder appropriate to this requirement command. + + :param ignore_requires_python: Whether to ignore incompatible + "Requires-Python" values in links. Defaults to False. + """ + link_collector = LinkCollector.create(session, options=options) + selection_prefs = SelectionPreferences( + allow_yanked=True, + format_control=options.format_control, + allow_all_prereleases=options.pre, + prefer_binary=options.prefer_binary, + ignore_requires_python=ignore_requires_python, + ) + + return PackageFinder.create( + link_collector=link_collector, + selection_prefs=selection_prefs, + target_python=target_python, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/spinners.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/spinners.py new file mode 100644 index 0000000000000000000000000000000000000000..cf2b976f377c2656afb3d84add8d30b0fc280c03 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/spinners.py @@ -0,0 +1,159 @@ +import contextlib +import itertools +import logging +import sys +import time +from typing import IO, Generator, Optional + +from pip._internal.utils.compat import WINDOWS +from pip._internal.utils.logging import get_indentation + +logger = logging.getLogger(__name__) + + +class SpinnerInterface: + def spin(self) -> None: + raise NotImplementedError() + + def finish(self, final_status: str) -> None: + raise NotImplementedError() + + +class InteractiveSpinner(SpinnerInterface): + def __init__( + self, + message: str, + file: Optional[IO[str]] = None, + spin_chars: str = "-\\|/", + # Empirically, 8 updates/second looks nice + min_update_interval_seconds: float = 0.125, + ): + self._message = message + if file is None: + file = sys.stdout + self._file = file + self._rate_limiter = RateLimiter(min_update_interval_seconds) + self._finished = False + + self._spin_cycle = itertools.cycle(spin_chars) + + self._file.write(" " * get_indentation() + self._message + " ... ") + self._width = 0 + + def _write(self, status: str) -> None: + assert not self._finished + # Erase what we wrote before by backspacing to the beginning, writing + # spaces to overwrite the old text, and then backspacing again + backup = "\b" * self._width + self._file.write(backup + " " * self._width + backup) + # Now we have a blank slate to add our status + self._file.write(status) + self._width = len(status) + self._file.flush() + self._rate_limiter.reset() + + def spin(self) -> None: + if self._finished: + return + if not self._rate_limiter.ready(): + return + self._write(next(self._spin_cycle)) + + def finish(self, final_status: str) -> None: + if self._finished: + return + self._write(final_status) + self._file.write("\n") + self._file.flush() + self._finished = True + + +# Used for dumb terminals, non-interactive installs (no tty), etc. +# We still print updates occasionally (once every 60 seconds by default) to +# act as a keep-alive for systems like Travis-CI that take lack-of-output as +# an indication that a task has frozen. +class NonInteractiveSpinner(SpinnerInterface): + def __init__(self, message: str, min_update_interval_seconds: float = 60.0) -> None: + self._message = message + self._finished = False + self._rate_limiter = RateLimiter(min_update_interval_seconds) + self._update("started") + + def _update(self, status: str) -> None: + assert not self._finished + self._rate_limiter.reset() + logger.info("%s: %s", self._message, status) + + def spin(self) -> None: + if self._finished: + return + if not self._rate_limiter.ready(): + return + self._update("still running...") + + def finish(self, final_status: str) -> None: + if self._finished: + return + self._update(f"finished with status '{final_status}'") + self._finished = True + + +class RateLimiter: + def __init__(self, min_update_interval_seconds: float) -> None: + self._min_update_interval_seconds = min_update_interval_seconds + self._last_update: float = 0 + + def ready(self) -> bool: + now = time.time() + delta = now - self._last_update + return delta >= self._min_update_interval_seconds + + def reset(self) -> None: + self._last_update = time.time() + + +@contextlib.contextmanager +def open_spinner(message: str) -> Generator[SpinnerInterface, None, None]: + # Interactive spinner goes directly to sys.stdout rather than being routed + # through the logging system, but it acts like it has level INFO, + # i.e. it's only displayed if we're at level INFO or better. + # Non-interactive spinner goes through the logging system, so it is always + # in sync with logging configuration. + if sys.stdout.isatty() and logger.getEffectiveLevel() <= logging.INFO: + spinner: SpinnerInterface = InteractiveSpinner(message) + else: + spinner = NonInteractiveSpinner(message) + try: + with hidden_cursor(sys.stdout): + yield spinner + except KeyboardInterrupt: + spinner.finish("canceled") + raise + except Exception: + spinner.finish("error") + raise + else: + spinner.finish("done") + + +HIDE_CURSOR = "\x1b[?25l" +SHOW_CURSOR = "\x1b[?25h" + + +@contextlib.contextmanager +def hidden_cursor(file: IO[str]) -> Generator[None, None, None]: + # The Windows terminal does not support the hide/show cursor ANSI codes, + # even via colorama. So don't even try. + if WINDOWS: + yield + # We don't want to clutter the output with control characters if we're + # writing to a file, or if the user is running with --quiet. + # See https://github.com/pypa/pip/issues/3418 + elif not file.isatty() or logger.getEffectiveLevel() > logging.INFO: + yield + else: + file.write(HIDE_CURSOR) + try: + yield + finally: + file.write(SHOW_CURSOR) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/status_codes.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/status_codes.py new file mode 100644 index 0000000000000000000000000000000000000000..5e29502cddfa9a9887a93399ab4193fb75dfe605 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/cli/status_codes.py @@ -0,0 +1,6 @@ +SUCCESS = 0 +ERROR = 1 +UNKNOWN_ERROR = 2 +VIRTUALENV_NOT_FOUND = 3 +PREVIOUS_BUILD_DIR_ERROR = 4 +NO_MATCHES_FOUND = 23 diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bc4f216a826475eaa73e8746f1472bf94e3f32fa --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/__init__.py @@ -0,0 +1,137 @@ +""" +Package containing all pip commands +""" + +import importlib +from collections import namedtuple +from typing import Any, Dict, Optional + +from pip._internal.cli.base_command import Command + +CommandInfo = namedtuple("CommandInfo", "module_path, class_name, summary") + +# This dictionary does a bunch of heavy lifting for help output: +# - Enables avoiding additional (costly) imports for presenting `--help`. +# - The ordering matters for help display. +# +# Even though the module path starts with the same "pip._internal.commands" +# prefix, the full path makes testing easier (specifically when modifying +# `commands_dict` in test setup / teardown). +commands_dict: Dict[str, CommandInfo] = { + "install": CommandInfo( + "pip._internal.commands.install", + "InstallCommand", + "Install packages.", + ), + "lock": CommandInfo( + "pip._internal.commands.lock", + "LockCommand", + "Generate a lock file.", + ), + "download": CommandInfo( + "pip._internal.commands.download", + "DownloadCommand", + "Download packages.", + ), + "uninstall": CommandInfo( + "pip._internal.commands.uninstall", + "UninstallCommand", + "Uninstall packages.", + ), + "freeze": CommandInfo( + "pip._internal.commands.freeze", + "FreezeCommand", + "Output installed packages in requirements format.", + ), + "inspect": CommandInfo( + "pip._internal.commands.inspect", + "InspectCommand", + "Inspect the python environment.", + ), + "list": CommandInfo( + "pip._internal.commands.list", + "ListCommand", + "List installed packages.", + ), + "show": CommandInfo( + "pip._internal.commands.show", + "ShowCommand", + "Show information about installed packages.", + ), + "check": CommandInfo( + "pip._internal.commands.check", + "CheckCommand", + "Verify installed packages have compatible dependencies.", + ), + "config": CommandInfo( + "pip._internal.commands.configuration", + "ConfigurationCommand", + "Manage local and global configuration.", + ), + "search": CommandInfo( + "pip._internal.commands.search", + "SearchCommand", + "Search PyPI for packages.", + ), + "cache": CommandInfo( + "pip._internal.commands.cache", + "CacheCommand", + "Inspect and manage pip's wheel cache.", + ), + "index": CommandInfo( + "pip._internal.commands.index", + "IndexCommand", + "Inspect information available from package indexes.", + ), + "wheel": CommandInfo( + "pip._internal.commands.wheel", + "WheelCommand", + "Build wheels from your requirements.", + ), + "hash": CommandInfo( + "pip._internal.commands.hash", + "HashCommand", + "Compute hashes of package archives.", + ), + "completion": CommandInfo( + "pip._internal.commands.completion", + "CompletionCommand", + "A helper command used for command completion.", + ), + "debug": CommandInfo( + "pip._internal.commands.debug", + "DebugCommand", + "Show information useful for debugging.", + ), + "help": CommandInfo( + "pip._internal.commands.help", + "HelpCommand", + "Show help for commands.", + ), +} + + +def create_command(name: str, **kwargs: Any) -> Command: + """ + Create an instance of the Command class with the given name. + """ + module_path, class_name, summary = commands_dict[name] + module = importlib.import_module(module_path) + command_class = getattr(module, class_name) + command = command_class(name=name, summary=summary, **kwargs) + + return command + + +def get_similar_commands(name: str) -> Optional[str]: + """Command name auto-correct.""" + from difflib import get_close_matches + + name = name.lower() + + close_commands = get_close_matches(name, commands_dict.keys()) + + if close_commands: + return close_commands[0] + else: + return None diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/cache.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..ad65641edb262fd85c5065c76d6a81aae1168192 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/cache.py @@ -0,0 +1,228 @@ +import os +import textwrap +from optparse import Values +from typing import Any, List + +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.exceptions import CommandError, PipError +from pip._internal.utils import filesystem +from pip._internal.utils.logging import getLogger +from pip._internal.utils.misc import format_size + +logger = getLogger(__name__) + + +class CacheCommand(Command): + """ + Inspect and manage pip's wheel cache. + + Subcommands: + + - dir: Show the cache directory. + - info: Show information about the cache. + - list: List filenames of packages stored in the cache. + - remove: Remove one or more package from the cache. + - purge: Remove all items from the cache. + + ```` can be a glob expression or a package name. + """ + + ignore_require_venv = True + usage = """ + %prog dir + %prog info + %prog list [] [--format=[human, abspath]] + %prog remove + %prog purge + """ + + def add_options(self) -> None: + self.cmd_opts.add_option( + "--format", + action="store", + dest="list_format", + default="human", + choices=("human", "abspath"), + help="Select the output format among: human (default) or abspath", + ) + + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + handlers = { + "dir": self.get_cache_dir, + "info": self.get_cache_info, + "list": self.list_cache_items, + "remove": self.remove_cache_items, + "purge": self.purge_cache, + } + + if not options.cache_dir: + logger.error("pip cache commands can not function since cache is disabled.") + return ERROR + + # Determine action + if not args or args[0] not in handlers: + logger.error( + "Need an action (%s) to perform.", + ", ".join(sorted(handlers)), + ) + return ERROR + + action = args[0] + + # Error handling happens here, not in the action-handlers. + try: + handlers[action](options, args[1:]) + except PipError as e: + logger.error(e.args[0]) + return ERROR + + return SUCCESS + + def get_cache_dir(self, options: Values, args: List[Any]) -> None: + if args: + raise CommandError("Too many arguments") + + logger.info(options.cache_dir) + + def get_cache_info(self, options: Values, args: List[Any]) -> None: + if args: + raise CommandError("Too many arguments") + + num_http_files = len(self._find_http_files(options)) + num_packages = len(self._find_wheels(options, "*")) + + http_cache_location = self._cache_dir(options, "http-v2") + old_http_cache_location = self._cache_dir(options, "http") + wheels_cache_location = self._cache_dir(options, "wheels") + http_cache_size = filesystem.format_size( + filesystem.directory_size(http_cache_location) + + filesystem.directory_size(old_http_cache_location) + ) + wheels_cache_size = filesystem.format_directory_size(wheels_cache_location) + + message = ( + textwrap.dedent( + """ + Package index page cache location (pip v23.3+): {http_cache_location} + Package index page cache location (older pips): {old_http_cache_location} + Package index page cache size: {http_cache_size} + Number of HTTP files: {num_http_files} + Locally built wheels location: {wheels_cache_location} + Locally built wheels size: {wheels_cache_size} + Number of locally built wheels: {package_count} + """ # noqa: E501 + ) + .format( + http_cache_location=http_cache_location, + old_http_cache_location=old_http_cache_location, + http_cache_size=http_cache_size, + num_http_files=num_http_files, + wheels_cache_location=wheels_cache_location, + package_count=num_packages, + wheels_cache_size=wheels_cache_size, + ) + .strip() + ) + + logger.info(message) + + def list_cache_items(self, options: Values, args: List[Any]) -> None: + if len(args) > 1: + raise CommandError("Too many arguments") + + if args: + pattern = args[0] + else: + pattern = "*" + + files = self._find_wheels(options, pattern) + if options.list_format == "human": + self.format_for_human(files) + else: + self.format_for_abspath(files) + + def format_for_human(self, files: List[str]) -> None: + if not files: + logger.info("No locally built wheels cached.") + return + + results = [] + for filename in files: + wheel = os.path.basename(filename) + size = filesystem.format_file_size(filename) + results.append(f" - {wheel} ({size})") + logger.info("Cache contents:\n") + logger.info("\n".join(sorted(results))) + + def format_for_abspath(self, files: List[str]) -> None: + if files: + logger.info("\n".join(sorted(files))) + + def remove_cache_items(self, options: Values, args: List[Any]) -> None: + if len(args) > 1: + raise CommandError("Too many arguments") + + if not args: + raise CommandError("Please provide a pattern") + + files = self._find_wheels(options, args[0]) + + no_matching_msg = "No matching packages" + if args[0] == "*": + # Only fetch http files if no specific pattern given + files += self._find_http_files(options) + else: + # Add the pattern to the log message + no_matching_msg += f' for pattern "{args[0]}"' + + if not files: + logger.warning(no_matching_msg) + + bytes_removed = 0 + for filename in files: + bytes_removed += os.stat(filename).st_size + os.unlink(filename) + logger.verbose("Removed %s", filename) + logger.info("Files removed: %s (%s)", len(files), format_size(bytes_removed)) + + def purge_cache(self, options: Values, args: List[Any]) -> None: + if args: + raise CommandError("Too many arguments") + + return self.remove_cache_items(options, ["*"]) + + def _cache_dir(self, options: Values, subdir: str) -> str: + return os.path.join(options.cache_dir, subdir) + + def _find_http_files(self, options: Values) -> List[str]: + old_http_dir = self._cache_dir(options, "http") + new_http_dir = self._cache_dir(options, "http-v2") + return filesystem.find_files(old_http_dir, "*") + filesystem.find_files( + new_http_dir, "*" + ) + + def _find_wheels(self, options: Values, pattern: str) -> List[str]: + wheel_dir = self._cache_dir(options, "wheels") + + # The wheel filename format, as specified in PEP 427, is: + # {distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl + # + # Additionally, non-alphanumeric values in the distribution are + # normalized to underscores (_), meaning hyphens can never occur + # before `-{version}`. + # + # Given that information: + # - If the pattern we're given contains a hyphen (-), the user is + # providing at least the version. Thus, we can just append `*.whl` + # to match the rest of it. + # - If the pattern we're given doesn't contain a hyphen (-), the + # user is only providing the name. Thus, we append `-*.whl` to + # match the hyphen before the version, followed by anything else. + # + # PEP 427: https://www.python.org/dev/peps/pep-0427/ + pattern = pattern + ("*.whl" if "-" in pattern else "-*.whl") + + return filesystem.find_files(wheel_dir, pattern) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/check.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/check.py new file mode 100644 index 0000000000000000000000000000000000000000..f54a16dc0a15892d935a5e79679bab3510c8b0d5 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/check.py @@ -0,0 +1,67 @@ +import logging +from optparse import Values +from typing import List + +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.metadata import get_default_environment +from pip._internal.operations.check import ( + check_package_set, + check_unsupported, + create_package_set_from_installed, +) +from pip._internal.utils.compatibility_tags import get_supported +from pip._internal.utils.misc import write_output + +logger = logging.getLogger(__name__) + + +class CheckCommand(Command): + """Verify installed packages have compatible dependencies.""" + + ignore_require_venv = True + usage = """ + %prog [options]""" + + def run(self, options: Values, args: List[str]) -> int: + package_set, parsing_probs = create_package_set_from_installed() + missing, conflicting = check_package_set(package_set) + unsupported = list( + check_unsupported( + get_default_environment().iter_installed_distributions(), + get_supported(), + ) + ) + + for project_name in missing: + version = package_set[project_name].version + for dependency in missing[project_name]: + write_output( + "%s %s requires %s, which is not installed.", + project_name, + version, + dependency[0], + ) + + for project_name in conflicting: + version = package_set[project_name].version + for dep_name, dep_version, req in conflicting[project_name]: + write_output( + "%s %s has requirement %s, but you have %s %s.", + project_name, + version, + req, + dep_name, + dep_version, + ) + for package in unsupported: + write_output( + "%s %s is not supported on this platform", + package.raw_name, + package.version, + ) + if missing or conflicting or parsing_probs or unsupported: + return ERROR + else: + write_output("No broken requirements found.") + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/completion.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/completion.py new file mode 100644 index 0000000000000000000000000000000000000000..fe041d2951aa29a4cff25cb00f0fc4541f29afd6 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/completion.py @@ -0,0 +1,136 @@ +import sys +import textwrap +from optparse import Values +from typing import List + +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.utils.misc import get_prog + +BASE_COMPLETION = """ +# pip {shell} completion start{script}# pip {shell} completion end +""" + +COMPLETION_SCRIPTS = { + "bash": """ + _pip_completion() + {{ + COMPREPLY=( $( COMP_WORDS="${{COMP_WORDS[*]}}" \\ + COMP_CWORD=$COMP_CWORD \\ + PIP_AUTO_COMPLETE=1 $1 2>/dev/null ) ) + }} + complete -o default -F _pip_completion {prog} + """, + "zsh": """ + #compdef -P pip[0-9.]# + __pip() {{ + compadd $( COMP_WORDS="$words[*]" \\ + COMP_CWORD=$((CURRENT-1)) \\ + PIP_AUTO_COMPLETE=1 $words[1] 2>/dev/null ) + }} + if [[ $zsh_eval_context[-1] == loadautofunc ]]; then + # autoload from fpath, call function directly + __pip "$@" + else + # eval/source/. command, register function for later + compdef __pip -P 'pip[0-9.]#' + fi + """, + "fish": """ + function __fish_complete_pip + set -lx COMP_WORDS \\ + (commandline --current-process --tokenize --cut-at-cursor) \\ + (commandline --current-token --cut-at-cursor) + set -lx COMP_CWORD (math (count $COMP_WORDS) - 1) + set -lx PIP_AUTO_COMPLETE 1 + set -l completions + if string match -q '2.*' $version + set completions (eval $COMP_WORDS[1]) + else + set completions ($COMP_WORDS[1]) + end + string split \\ -- $completions + end + complete -fa "(__fish_complete_pip)" -c {prog} + """, + "powershell": """ + if ((Test-Path Function:\\TabExpansion) -and -not ` + (Test-Path Function:\\_pip_completeBackup)) {{ + Rename-Item Function:\\TabExpansion _pip_completeBackup + }} + function TabExpansion($line, $lastWord) {{ + $lastBlock = [regex]::Split($line, '[|;]')[-1].TrimStart() + if ($lastBlock.StartsWith("{prog} ")) {{ + $Env:COMP_WORDS=$lastBlock + $Env:COMP_CWORD=$lastBlock.Split().Length - 1 + $Env:PIP_AUTO_COMPLETE=1 + (& {prog}).Split() + Remove-Item Env:COMP_WORDS + Remove-Item Env:COMP_CWORD + Remove-Item Env:PIP_AUTO_COMPLETE + }} + elseif (Test-Path Function:\\_pip_completeBackup) {{ + # Fall back on existing tab expansion + _pip_completeBackup $line $lastWord + }} + }} + """, +} + + +class CompletionCommand(Command): + """A helper command to be used for command completion.""" + + ignore_require_venv = True + + def add_options(self) -> None: + self.cmd_opts.add_option( + "--bash", + "-b", + action="store_const", + const="bash", + dest="shell", + help="Emit completion code for bash", + ) + self.cmd_opts.add_option( + "--zsh", + "-z", + action="store_const", + const="zsh", + dest="shell", + help="Emit completion code for zsh", + ) + self.cmd_opts.add_option( + "--fish", + "-f", + action="store_const", + const="fish", + dest="shell", + help="Emit completion code for fish", + ) + self.cmd_opts.add_option( + "--powershell", + "-p", + action="store_const", + const="powershell", + dest="shell", + help="Emit completion code for powershell", + ) + + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + """Prints the completion code of the given shell""" + shells = COMPLETION_SCRIPTS.keys() + shell_options = ["--" + shell for shell in sorted(shells)] + if options.shell in shells: + script = textwrap.dedent( + COMPLETION_SCRIPTS.get(options.shell, "").format(prog=get_prog()) + ) + print(BASE_COMPLETION.format(script=script, shell=options.shell)) + return SUCCESS + else: + sys.stderr.write( + "ERROR: You must pass {}\n".format(" or ".join(shell_options)) + ) + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/configuration.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/configuration.py new file mode 100644 index 0000000000000000000000000000000000000000..1a1dc6b6cd841dc2a9f5666fb34d8120e612346e --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/configuration.py @@ -0,0 +1,280 @@ +import logging +import os +import subprocess +from optparse import Values +from typing import Any, List, Optional + +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.configuration import ( + Configuration, + Kind, + get_configuration_files, + kinds, +) +from pip._internal.exceptions import PipError +from pip._internal.utils.logging import indent_log +from pip._internal.utils.misc import get_prog, write_output + +logger = logging.getLogger(__name__) + + +class ConfigurationCommand(Command): + """ + Manage local and global configuration. + + Subcommands: + + - list: List the active configuration (or from the file specified) + - edit: Edit the configuration file in an editor + - get: Get the value associated with command.option + - set: Set the command.option=value + - unset: Unset the value associated with command.option + - debug: List the configuration files and values defined under them + + Configuration keys should be dot separated command and option name, + with the special prefix "global" affecting any command. For example, + "pip config set global.index-url https://example.org/" would configure + the index url for all commands, but "pip config set download.timeout 10" + would configure a 10 second timeout only for "pip download" commands. + + If none of --user, --global and --site are passed, a virtual + environment configuration file is used if one is active and the file + exists. Otherwise, all modifications happen to the user file by + default. + """ + + ignore_require_venv = True + usage = """ + %prog [] list + %prog [] [--editor ] edit + + %prog [] get command.option + %prog [] set command.option value + %prog [] unset command.option + %prog [] debug + """ + + def add_options(self) -> None: + self.cmd_opts.add_option( + "--editor", + dest="editor", + action="store", + default=None, + help=( + "Editor to use to edit the file. Uses VISUAL or EDITOR " + "environment variables if not provided." + ), + ) + + self.cmd_opts.add_option( + "--global", + dest="global_file", + action="store_true", + default=False, + help="Use the system-wide configuration file only", + ) + + self.cmd_opts.add_option( + "--user", + dest="user_file", + action="store_true", + default=False, + help="Use the user configuration file only", + ) + + self.cmd_opts.add_option( + "--site", + dest="site_file", + action="store_true", + default=False, + help="Use the current environment configuration file only", + ) + + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + handlers = { + "list": self.list_values, + "edit": self.open_in_editor, + "get": self.get_name, + "set": self.set_name_value, + "unset": self.unset_name, + "debug": self.list_config_values, + } + + # Determine action + if not args or args[0] not in handlers: + logger.error( + "Need an action (%s) to perform.", + ", ".join(sorted(handlers)), + ) + return ERROR + + action = args[0] + + # Determine which configuration files are to be loaded + # Depends on whether the command is modifying. + try: + load_only = self._determine_file( + options, need_value=(action in ["get", "set", "unset", "edit"]) + ) + except PipError as e: + logger.error(e.args[0]) + return ERROR + + # Load a new configuration + self.configuration = Configuration( + isolated=options.isolated_mode, load_only=load_only + ) + self.configuration.load() + + # Error handling happens here, not in the action-handlers. + try: + handlers[action](options, args[1:]) + except PipError as e: + logger.error(e.args[0]) + return ERROR + + return SUCCESS + + def _determine_file(self, options: Values, need_value: bool) -> Optional[Kind]: + file_options = [ + key + for key, value in ( + (kinds.USER, options.user_file), + (kinds.GLOBAL, options.global_file), + (kinds.SITE, options.site_file), + ) + if value + ] + + if not file_options: + if not need_value: + return None + # Default to user, unless there's a site file. + elif any( + os.path.exists(site_config_file) + for site_config_file in get_configuration_files()[kinds.SITE] + ): + return kinds.SITE + else: + return kinds.USER + elif len(file_options) == 1: + return file_options[0] + + raise PipError( + "Need exactly one file to operate upon " + "(--user, --site, --global) to perform." + ) + + def list_values(self, options: Values, args: List[str]) -> None: + self._get_n_args(args, "list", n=0) + + for key, value in sorted(self.configuration.items()): + write_output("%s=%r", key, value) + + def get_name(self, options: Values, args: List[str]) -> None: + key = self._get_n_args(args, "get [name]", n=1) + value = self.configuration.get_value(key) + + write_output("%s", value) + + def set_name_value(self, options: Values, args: List[str]) -> None: + key, value = self._get_n_args(args, "set [name] [value]", n=2) + self.configuration.set_value(key, value) + + self._save_configuration() + + def unset_name(self, options: Values, args: List[str]) -> None: + key = self._get_n_args(args, "unset [name]", n=1) + self.configuration.unset_value(key) + + self._save_configuration() + + def list_config_values(self, options: Values, args: List[str]) -> None: + """List config key-value pairs across different config files""" + self._get_n_args(args, "debug", n=0) + + self.print_env_var_values() + # Iterate over config files and print if they exist, and the + # key-value pairs present in them if they do + for variant, files in sorted(self.configuration.iter_config_files()): + write_output("%s:", variant) + for fname in files: + with indent_log(): + file_exists = os.path.exists(fname) + write_output("%s, exists: %r", fname, file_exists) + if file_exists: + self.print_config_file_values(variant) + + def print_config_file_values(self, variant: Kind) -> None: + """Get key-value pairs from the file of a variant""" + for name, value in self.configuration.get_values_in_config(variant).items(): + with indent_log(): + write_output("%s: %s", name, value) + + def print_env_var_values(self) -> None: + """Get key-values pairs present as environment variables""" + write_output("%s:", "env_var") + with indent_log(): + for key, value in sorted(self.configuration.get_environ_vars()): + env_var = f"PIP_{key.upper()}" + write_output("%s=%r", env_var, value) + + def open_in_editor(self, options: Values, args: List[str]) -> None: + editor = self._determine_editor(options) + + fname = self.configuration.get_file_to_edit() + if fname is None: + raise PipError("Could not determine appropriate file.") + elif '"' in fname: + # This shouldn't happen, unless we see a username like that. + # If that happens, we'd appreciate a pull request fixing this. + raise PipError( + f'Can not open an editor for a file name containing "\n{fname}' + ) + + try: + subprocess.check_call(f'{editor} "{fname}"', shell=True) + except FileNotFoundError as e: + if not e.filename: + e.filename = editor + raise + except subprocess.CalledProcessError as e: + raise PipError(f"Editor Subprocess exited with exit code {e.returncode}") + + def _get_n_args(self, args: List[str], example: str, n: int) -> Any: + """Helper to make sure the command got the right number of arguments""" + if len(args) != n: + msg = ( + f"Got unexpected number of arguments, expected {n}. " + f'(example: "{get_prog()} config {example}")' + ) + raise PipError(msg) + + if n == 1: + return args[0] + else: + return args + + def _save_configuration(self) -> None: + # We successfully ran a modifying command. Need to save the + # configuration. + try: + self.configuration.save() + except Exception: + logger.exception( + "Unable to save configuration. Please report this as a bug." + ) + raise PipError("Internal Error.") + + def _determine_editor(self, options: Values) -> str: + if options.editor is not None: + return options.editor + elif "VISUAL" in os.environ: + return os.environ["VISUAL"] + elif "EDITOR" in os.environ: + return os.environ["EDITOR"] + else: + raise PipError("Could not determine editor to use.") diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/debug.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/debug.py new file mode 100644 index 0000000000000000000000000000000000000000..567ca967e5b64478d17455288b79dd80301b4888 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/debug.py @@ -0,0 +1,201 @@ +import locale +import logging +import os +import sys +from optparse import Values +from types import ModuleType +from typing import Any, Dict, List, Optional + +import pip._vendor +from pip._vendor.certifi import where +from pip._vendor.packaging.version import parse as parse_version + +from pip._internal.cli import cmdoptions +from pip._internal.cli.base_command import Command +from pip._internal.cli.cmdoptions import make_target_python +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.configuration import Configuration +from pip._internal.metadata import get_environment +from pip._internal.utils.compat import open_text_resource +from pip._internal.utils.logging import indent_log +from pip._internal.utils.misc import get_pip_version + +logger = logging.getLogger(__name__) + + +def show_value(name: str, value: Any) -> None: + logger.info("%s: %s", name, value) + + +def show_sys_implementation() -> None: + logger.info("sys.implementation:") + implementation_name = sys.implementation.name + with indent_log(): + show_value("name", implementation_name) + + +def create_vendor_txt_map() -> Dict[str, str]: + with open_text_resource("pip._vendor", "vendor.txt") as f: + # Purge non version specifying lines. + # Also, remove any space prefix or suffixes (including comments). + lines = [ + line.strip().split(" ", 1)[0] for line in f.readlines() if "==" in line + ] + + # Transform into "module" -> version dict. + return dict(line.split("==", 1) for line in lines) + + +def get_module_from_module_name(module_name: str) -> Optional[ModuleType]: + # Module name can be uppercase in vendor.txt for some reason... + module_name = module_name.lower().replace("-", "_") + # PATCH: setuptools is actually only pkg_resources. + if module_name == "setuptools": + module_name = "pkg_resources" + + try: + __import__(f"pip._vendor.{module_name}", globals(), locals(), level=0) + return getattr(pip._vendor, module_name) + except ImportError: + # We allow 'truststore' to fail to import due + # to being unavailable on Python 3.9 and earlier. + if module_name == "truststore" and sys.version_info < (3, 10): + return None + raise + + +def get_vendor_version_from_module(module_name: str) -> Optional[str]: + module = get_module_from_module_name(module_name) + version = getattr(module, "__version__", None) + + if module and not version: + # Try to find version in debundled module info. + assert module.__file__ is not None + env = get_environment([os.path.dirname(module.__file__)]) + dist = env.get_distribution(module_name) + if dist: + version = str(dist.version) + + return version + + +def show_actual_vendor_versions(vendor_txt_versions: Dict[str, str]) -> None: + """Log the actual version and print extra info if there is + a conflict or if the actual version could not be imported. + """ + for module_name, expected_version in vendor_txt_versions.items(): + extra_message = "" + actual_version = get_vendor_version_from_module(module_name) + if not actual_version: + extra_message = ( + " (Unable to locate actual module version, using" + " vendor.txt specified version)" + ) + actual_version = expected_version + elif parse_version(actual_version) != parse_version(expected_version): + extra_message = ( + " (CONFLICT: vendor.txt suggests version should" + f" be {expected_version})" + ) + logger.info("%s==%s%s", module_name, actual_version, extra_message) + + +def show_vendor_versions() -> None: + logger.info("vendored library versions:") + + vendor_txt_versions = create_vendor_txt_map() + with indent_log(): + show_actual_vendor_versions(vendor_txt_versions) + + +def show_tags(options: Values) -> None: + tag_limit = 10 + + target_python = make_target_python(options) + tags = target_python.get_sorted_tags() + + # Display the target options that were explicitly provided. + formatted_target = target_python.format_given() + suffix = "" + if formatted_target: + suffix = f" (target: {formatted_target})" + + msg = f"Compatible tags: {len(tags)}{suffix}" + logger.info(msg) + + if options.verbose < 1 and len(tags) > tag_limit: + tags_limited = True + tags = tags[:tag_limit] + else: + tags_limited = False + + with indent_log(): + for tag in tags: + logger.info(str(tag)) + + if tags_limited: + msg = f"...\n[First {tag_limit} tags shown. Pass --verbose to show all.]" + logger.info(msg) + + +def ca_bundle_info(config: Configuration) -> str: + levels = {key.split(".", 1)[0] for key, _ in config.items()} + if not levels: + return "Not specified" + + levels_that_override_global = ["install", "wheel", "download"] + global_overriding_level = [ + level for level in levels if level in levels_that_override_global + ] + if not global_overriding_level: + return "global" + + if "global" in levels: + levels.remove("global") + return ", ".join(levels) + + +class DebugCommand(Command): + """ + Display debug information. + """ + + usage = """ + %prog """ + ignore_require_venv = True + + def add_options(self) -> None: + cmdoptions.add_target_python_options(self.cmd_opts) + self.parser.insert_option_group(0, self.cmd_opts) + self.parser.config.load() + + def run(self, options: Values, args: List[str]) -> int: + logger.warning( + "This command is only meant for debugging. " + "Do not use this with automation for parsing and getting these " + "details, since the output and options of this command may " + "change without notice." + ) + show_value("pip version", get_pip_version()) + show_value("sys.version", sys.version) + show_value("sys.executable", sys.executable) + show_value("sys.getdefaultencoding", sys.getdefaultencoding()) + show_value("sys.getfilesystemencoding", sys.getfilesystemencoding()) + show_value( + "locale.getpreferredencoding", + locale.getpreferredencoding(), + ) + show_value("sys.platform", sys.platform) + show_sys_implementation() + + show_value("'cert' config value", ca_bundle_info(self.parser.config)) + show_value("REQUESTS_CA_BUNDLE", os.environ.get("REQUESTS_CA_BUNDLE")) + show_value("CURL_CA_BUNDLE", os.environ.get("CURL_CA_BUNDLE")) + show_value("pip._vendor.certifi.where()", where()) + show_value("pip._vendor.DEBUNDLED", pip._vendor.DEBUNDLED) + + show_vendor_versions() + + show_tags(options) + + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/download.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/download.py new file mode 100644 index 0000000000000000000000000000000000000000..917bbb91d83cbb81a5814d510b5dd3bdd6c0f510 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/download.py @@ -0,0 +1,146 @@ +import logging +import os +from optparse import Values +from typing import List + +from pip._internal.cli import cmdoptions +from pip._internal.cli.cmdoptions import make_target_python +from pip._internal.cli.req_command import RequirementCommand, with_cleanup +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.operations.build.build_tracker import get_build_tracker +from pip._internal.req.req_install import check_legacy_setup_py_options +from pip._internal.utils.misc import ensure_dir, normalize_path, write_output +from pip._internal.utils.temp_dir import TempDirectory + +logger = logging.getLogger(__name__) + + +class DownloadCommand(RequirementCommand): + """ + Download packages from: + + - PyPI (and other indexes) using requirement specifiers. + - VCS project urls. + - Local project directories. + - Local or remote source archives. + + pip also supports downloading from "requirements files", which provide + an easy way to specify a whole environment to be downloaded. + """ + + usage = """ + %prog [options] [package-index-options] ... + %prog [options] -r [package-index-options] ... + %prog [options] ... + %prog [options] ... + %prog [options] ...""" + + def add_options(self) -> None: + self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.no_deps()) + self.cmd_opts.add_option(cmdoptions.global_options()) + self.cmd_opts.add_option(cmdoptions.no_binary()) + self.cmd_opts.add_option(cmdoptions.only_binary()) + self.cmd_opts.add_option(cmdoptions.prefer_binary()) + self.cmd_opts.add_option(cmdoptions.src()) + self.cmd_opts.add_option(cmdoptions.pre()) + self.cmd_opts.add_option(cmdoptions.require_hashes()) + self.cmd_opts.add_option(cmdoptions.progress_bar()) + self.cmd_opts.add_option(cmdoptions.no_build_isolation()) + self.cmd_opts.add_option(cmdoptions.use_pep517()) + self.cmd_opts.add_option(cmdoptions.no_use_pep517()) + self.cmd_opts.add_option(cmdoptions.check_build_deps()) + self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) + + self.cmd_opts.add_option( + "-d", + "--dest", + "--destination-dir", + "--destination-directory", + dest="download_dir", + metavar="dir", + default=os.curdir, + help="Download packages into .", + ) + + cmdoptions.add_target_python_options(self.cmd_opts) + + index_opts = cmdoptions.make_option_group( + cmdoptions.index_group, + self.parser, + ) + + self.parser.insert_option_group(0, index_opts) + self.parser.insert_option_group(0, self.cmd_opts) + + @with_cleanup + def run(self, options: Values, args: List[str]) -> int: + options.ignore_installed = True + # editable doesn't really make sense for `pip download`, but the bowels + # of the RequirementSet code require that property. + options.editables = [] + + cmdoptions.check_dist_restriction(options) + + options.download_dir = normalize_path(options.download_dir) + ensure_dir(options.download_dir) + + session = self.get_default_session(options) + + target_python = make_target_python(options) + finder = self._build_package_finder( + options=options, + session=session, + target_python=target_python, + ignore_requires_python=options.ignore_requires_python, + ) + + build_tracker = self.enter_context(get_build_tracker()) + + directory = TempDirectory( + delete=not options.no_clean, + kind="download", + globally_managed=True, + ) + + reqs = self.get_requirements(args, options, finder, session) + check_legacy_setup_py_options(options, reqs) + + preparer = self.make_requirement_preparer( + temp_build_dir=directory, + options=options, + build_tracker=build_tracker, + session=session, + finder=finder, + download_dir=options.download_dir, + use_user_site=False, + verbosity=self.verbosity, + ) + + resolver = self.make_resolver( + preparer=preparer, + finder=finder, + options=options, + ignore_requires_python=options.ignore_requires_python, + use_pep517=options.use_pep517, + py_version_info=options.python_version, + ) + + self.trace_basic_info(finder) + + requirement_set = resolver.resolve(reqs, check_supported_wheels=True) + + downloaded: List[str] = [] + for req in requirement_set.requirements.values(): + if req.satisfied_by is None: + assert req.name is not None + preparer.save_linked_requirement(req) + downloaded.append(req.name) + + preparer.prepare_linked_requirements_more(requirement_set.requirements.values()) + + if downloaded: + write_output("Successfully downloaded %s", " ".join(downloaded)) + + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/freeze.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/freeze.py new file mode 100644 index 0000000000000000000000000000000000000000..8eb1cd06f1d5f797f73773cea52f99a75bfd9cc3 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/freeze.py @@ -0,0 +1,109 @@ +import sys +from optparse import Values +from typing import AbstractSet, List + +from pip._internal.cli import cmdoptions +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.operations.freeze import freeze +from pip._internal.utils.compat import stdlib_pkgs + + +def _should_suppress_build_backends() -> bool: + return sys.version_info < (3, 12) + + +def _dev_pkgs() -> AbstractSet[str]: + pkgs = {"pip"} + + if _should_suppress_build_backends(): + pkgs |= {"setuptools", "distribute", "wheel"} + pkgs |= {"setuptools", "distribute", "wheel", "pkg-resources"} + + return pkgs + + +class FreezeCommand(Command): + """ + Output installed packages in requirements format. + + packages are listed in a case-insensitive sorted order. + """ + + ignore_require_venv = True + usage = """ + %prog [options]""" + + def add_options(self) -> None: + self.cmd_opts.add_option( + "-r", + "--requirement", + dest="requirements", + action="append", + default=[], + metavar="file", + help=( + "Use the order in the given requirements file and its " + "comments when generating output. This option can be " + "used multiple times." + ), + ) + self.cmd_opts.add_option( + "-l", + "--local", + dest="local", + action="store_true", + default=False, + help=( + "If in a virtualenv that has global access, do not output " + "globally-installed packages." + ), + ) + self.cmd_opts.add_option( + "--user", + dest="user", + action="store_true", + default=False, + help="Only output packages installed in user-site.", + ) + self.cmd_opts.add_option(cmdoptions.list_path()) + self.cmd_opts.add_option( + "--all", + dest="freeze_all", + action="store_true", + help=( + "Do not skip these packages in the output:" + " {}".format(", ".join(_dev_pkgs())) + ), + ) + self.cmd_opts.add_option( + "--exclude-editable", + dest="exclude_editable", + action="store_true", + help="Exclude editable package from output.", + ) + self.cmd_opts.add_option(cmdoptions.list_exclude()) + + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + skip = set(stdlib_pkgs) + if not options.freeze_all: + skip.update(_dev_pkgs()) + + if options.excludes: + skip.update(options.excludes) + + cmdoptions.check_list_path_option(options) + + for line in freeze( + requirement=options.requirements, + local_only=options.local, + user_only=options.user, + paths=options.path, + isolated=options.isolated_mode, + skip=skip, + exclude_editable=options.exclude_editable, + ): + sys.stdout.write(line + "\n") + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/hash.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/hash.py new file mode 100644 index 0000000000000000000000000000000000000000..042dac813e74b8187c3754cb9a937c7f7183e331 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/hash.py @@ -0,0 +1,59 @@ +import hashlib +import logging +import sys +from optparse import Values +from typing import List + +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.utils.hashes import FAVORITE_HASH, STRONG_HASHES +from pip._internal.utils.misc import read_chunks, write_output + +logger = logging.getLogger(__name__) + + +class HashCommand(Command): + """ + Compute a hash of a local package archive. + + These can be used with --hash in a requirements file to do repeatable + installs. + """ + + usage = "%prog [options] ..." + ignore_require_venv = True + + def add_options(self) -> None: + self.cmd_opts.add_option( + "-a", + "--algorithm", + dest="algorithm", + choices=STRONG_HASHES, + action="store", + default=FAVORITE_HASH, + help="The hash algorithm to use: one of {}".format( + ", ".join(STRONG_HASHES) + ), + ) + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + if not args: + self.parser.print_usage(sys.stderr) + return ERROR + + algorithm = options.algorithm + for path in args: + write_output( + "%s:\n--hash=%s:%s", path, algorithm, _hash_of_file(path, algorithm) + ) + return SUCCESS + + +def _hash_of_file(path: str, algorithm: str) -> str: + """Return the hash digest of a file.""" + with open(path, "rb") as archive: + hash = hashlib.new(algorithm) + for chunk in read_chunks(archive): + hash.update(chunk) + return hash.hexdigest() diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/help.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/help.py new file mode 100644 index 0000000000000000000000000000000000000000..62066318b74dcc5c32bcd24b9493fb34d1ce52d7 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/help.py @@ -0,0 +1,41 @@ +from optparse import Values +from typing import List + +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.exceptions import CommandError + + +class HelpCommand(Command): + """Show help for commands""" + + usage = """ + %prog """ + ignore_require_venv = True + + def run(self, options: Values, args: List[str]) -> int: + from pip._internal.commands import ( + commands_dict, + create_command, + get_similar_commands, + ) + + try: + # 'pip help' with no args is handled by pip.__init__.parseopt() + cmd_name = args[0] # the command we need help for + except IndexError: + return SUCCESS + + if cmd_name not in commands_dict: + guess = get_similar_commands(cmd_name) + + msg = [f'unknown command "{cmd_name}"'] + if guess: + msg.append(f'maybe you meant "{guess}"') + + raise CommandError(" - ".join(msg)) + + command = create_command(cmd_name) + command.parser.print_help() + + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/index.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/index.py new file mode 100644 index 0000000000000000000000000000000000000000..e8714a7270cf028bf32fa8243e0fa5412e4645ee --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/index.py @@ -0,0 +1,153 @@ +import json +import logging +from optparse import Values +from typing import Any, Iterable, List, Optional + +from pip._vendor.packaging.version import Version + +from pip._internal.cli import cmdoptions +from pip._internal.cli.req_command import IndexGroupCommand +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.commands.search import ( + get_installed_distribution, + print_dist_installation_info, +) +from pip._internal.exceptions import CommandError, DistributionNotFound, PipError +from pip._internal.index.collector import LinkCollector +from pip._internal.index.package_finder import PackageFinder +from pip._internal.models.selection_prefs import SelectionPreferences +from pip._internal.models.target_python import TargetPython +from pip._internal.network.session import PipSession +from pip._internal.utils.misc import write_output + +logger = logging.getLogger(__name__) + + +class IndexCommand(IndexGroupCommand): + """ + Inspect information available from package indexes. + """ + + ignore_require_venv = True + usage = """ + %prog versions + """ + + def add_options(self) -> None: + cmdoptions.add_target_python_options(self.cmd_opts) + + self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) + self.cmd_opts.add_option(cmdoptions.pre()) + self.cmd_opts.add_option(cmdoptions.json()) + self.cmd_opts.add_option(cmdoptions.no_binary()) + self.cmd_opts.add_option(cmdoptions.only_binary()) + + index_opts = cmdoptions.make_option_group( + cmdoptions.index_group, + self.parser, + ) + + self.parser.insert_option_group(0, index_opts) + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + handlers = { + "versions": self.get_available_package_versions, + } + + # Determine action + if not args or args[0] not in handlers: + logger.error( + "Need an action (%s) to perform.", + ", ".join(sorted(handlers)), + ) + return ERROR + + action = args[0] + + # Error handling happens here, not in the action-handlers. + try: + handlers[action](options, args[1:]) + except PipError as e: + logger.error(e.args[0]) + return ERROR + + return SUCCESS + + def _build_package_finder( + self, + options: Values, + session: PipSession, + target_python: Optional[TargetPython] = None, + ignore_requires_python: Optional[bool] = None, + ) -> PackageFinder: + """ + Create a package finder appropriate to the index command. + """ + link_collector = LinkCollector.create(session, options=options) + + # Pass allow_yanked=False to ignore yanked versions. + selection_prefs = SelectionPreferences( + allow_yanked=False, + allow_all_prereleases=options.pre, + ignore_requires_python=ignore_requires_python, + ) + + return PackageFinder.create( + link_collector=link_collector, + selection_prefs=selection_prefs, + target_python=target_python, + ) + + def get_available_package_versions(self, options: Values, args: List[Any]) -> None: + if len(args) != 1: + raise CommandError("You need to specify exactly one argument") + + target_python = cmdoptions.make_target_python(options) + query = args[0] + + with self._build_session(options) as session: + finder = self._build_package_finder( + options=options, + session=session, + target_python=target_python, + ignore_requires_python=options.ignore_requires_python, + ) + + versions: Iterable[Version] = ( + candidate.version for candidate in finder.find_all_candidates(query) + ) + + if not options.pre: + # Remove prereleases + versions = ( + version for version in versions if not version.is_prerelease + ) + versions = set(versions) + + if not versions: + raise DistributionNotFound( + f"No matching distribution found for {query}" + ) + + formatted_versions = [str(ver) for ver in sorted(versions, reverse=True)] + latest = formatted_versions[0] + + dist = get_installed_distribution(query) + + if options.json: + structured_output = { + "name": query, + "versions": formatted_versions, + "latest": latest, + } + + if dist is not None: + structured_output["installed_version"] = str(dist.version) + + write_output(json.dumps(structured_output)) + + else: + write_output(f"{query} ({latest})") + write_output("Available versions: {}".format(", ".join(formatted_versions))) + print_dist_installation_info(latest, dist) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/inspect.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/inspect.py new file mode 100644 index 0000000000000000000000000000000000000000..e810c13166bbdf0e38c94bc3ef66af7aeda2bba7 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/inspect.py @@ -0,0 +1,92 @@ +import logging +from optparse import Values +from typing import Any, Dict, List + +from pip._vendor.packaging.markers import default_environment +from pip._vendor.rich import print_json + +from pip import __version__ +from pip._internal.cli import cmdoptions +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.metadata import BaseDistribution, get_environment +from pip._internal.utils.compat import stdlib_pkgs +from pip._internal.utils.urls import path_to_url + +logger = logging.getLogger(__name__) + + +class InspectCommand(Command): + """ + Inspect the content of a Python environment and produce a report in JSON format. + """ + + ignore_require_venv = True + usage = """ + %prog [options]""" + + def add_options(self) -> None: + self.cmd_opts.add_option( + "--local", + action="store_true", + default=False, + help=( + "If in a virtualenv that has global access, do not list " + "globally-installed packages." + ), + ) + self.cmd_opts.add_option( + "--user", + dest="user", + action="store_true", + default=False, + help="Only output packages installed in user-site.", + ) + self.cmd_opts.add_option(cmdoptions.list_path()) + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + cmdoptions.check_list_path_option(options) + dists = get_environment(options.path).iter_installed_distributions( + local_only=options.local, + user_only=options.user, + skip=set(stdlib_pkgs), + ) + output = { + "version": "1", + "pip_version": __version__, + "installed": [self._dist_to_dict(dist) for dist in dists], + "environment": default_environment(), + # TODO tags? scheme? + } + print_json(data=output) + return SUCCESS + + def _dist_to_dict(self, dist: BaseDistribution) -> Dict[str, Any]: + res: Dict[str, Any] = { + "metadata": dist.metadata_dict, + "metadata_location": dist.info_location, + } + # direct_url. Note that we don't have download_info (as in the installation + # report) since it is not recorded in installed metadata. + direct_url = dist.direct_url + if direct_url is not None: + res["direct_url"] = direct_url.to_dict() + else: + # Emulate direct_url for legacy editable installs. + editable_project_location = dist.editable_project_location + if editable_project_location is not None: + res["direct_url"] = { + "url": path_to_url(editable_project_location), + "dir_info": { + "editable": True, + }, + } + # installer + installer = dist.installer + if dist.installer: + res["installer"] = installer + # requested + if dist.installed_with_dist_info: + res["requested"] = dist.requested + return res diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/install.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/install.py new file mode 100644 index 0000000000000000000000000000000000000000..300ae92b337e5737e4e731b553ea6da42fdcc79d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/install.py @@ -0,0 +1,793 @@ +import errno +import json +import operator +import os +import shutil +import site +from optparse import SUPPRESS_HELP, Values +from typing import List, Optional + +from pip._vendor.packaging.utils import canonicalize_name +from pip._vendor.requests.exceptions import InvalidProxyURL +from pip._vendor.rich import print_json + +# Eagerly import self_outdated_check to avoid crashes. Otherwise, +# this module would be imported *after* pip was replaced, resulting +# in crashes if the new self_outdated_check module was incompatible +# with the rest of pip that's already imported, or allowing a +# wheel to execute arbitrary code on install by replacing +# self_outdated_check. +import pip._internal.self_outdated_check # noqa: F401 +from pip._internal.cache import WheelCache +from pip._internal.cli import cmdoptions +from pip._internal.cli.cmdoptions import make_target_python +from pip._internal.cli.req_command import ( + RequirementCommand, + with_cleanup, +) +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.exceptions import CommandError, InstallationError +from pip._internal.locations import get_scheme +from pip._internal.metadata import get_environment +from pip._internal.models.installation_report import InstallationReport +from pip._internal.operations.build.build_tracker import get_build_tracker +from pip._internal.operations.check import ConflictDetails, check_install_conflicts +from pip._internal.req import install_given_reqs +from pip._internal.req.req_install import ( + InstallRequirement, + check_legacy_setup_py_options, +) +from pip._internal.utils.compat import WINDOWS +from pip._internal.utils.filesystem import test_writable_dir +from pip._internal.utils.logging import getLogger +from pip._internal.utils.misc import ( + check_externally_managed, + ensure_dir, + get_pip_version, + protect_pip_from_modification_on_windows, + warn_if_run_as_root, + write_output, +) +from pip._internal.utils.temp_dir import TempDirectory +from pip._internal.utils.virtualenv import ( + running_under_virtualenv, + virtualenv_no_global, +) +from pip._internal.wheel_builder import build, should_build_for_install_command + +logger = getLogger(__name__) + + +class InstallCommand(RequirementCommand): + """ + Install packages from: + + - PyPI (and other indexes) using requirement specifiers. + - VCS project urls. + - Local project directories. + - Local or remote source archives. + + pip also supports installing from "requirements files", which provide + an easy way to specify a whole environment to be installed. + """ + + usage = """ + %prog [options] [package-index-options] ... + %prog [options] -r [package-index-options] ... + %prog [options] [-e] ... + %prog [options] [-e] ... + %prog [options] ...""" + + def add_options(self) -> None: + self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.no_deps()) + self.cmd_opts.add_option(cmdoptions.pre()) + + self.cmd_opts.add_option(cmdoptions.editable()) + self.cmd_opts.add_option( + "--dry-run", + action="store_true", + dest="dry_run", + default=False, + help=( + "Don't actually install anything, just print what would be. " + "Can be used in combination with --ignore-installed " + "to 'resolve' the requirements." + ), + ) + self.cmd_opts.add_option( + "-t", + "--target", + dest="target_dir", + metavar="dir", + default=None, + help=( + "Install packages into . " + "By default this will not replace existing files/folders in " + ". Use --upgrade to replace existing packages in " + "with new versions." + ), + ) + cmdoptions.add_target_python_options(self.cmd_opts) + + self.cmd_opts.add_option( + "--user", + dest="use_user_site", + action="store_true", + help=( + "Install to the Python user install directory for your " + "platform. Typically ~/.local/, or %APPDATA%\\Python on " + "Windows. (See the Python documentation for site.USER_BASE " + "for full details.)" + ), + ) + self.cmd_opts.add_option( + "--no-user", + dest="use_user_site", + action="store_false", + help=SUPPRESS_HELP, + ) + self.cmd_opts.add_option( + "--root", + dest="root_path", + metavar="dir", + default=None, + help="Install everything relative to this alternate root directory.", + ) + self.cmd_opts.add_option( + "--prefix", + dest="prefix_path", + metavar="dir", + default=None, + help=( + "Installation prefix where lib, bin and other top-level " + "folders are placed. Note that the resulting installation may " + "contain scripts and other resources which reference the " + "Python interpreter of pip, and not that of ``--prefix``. " + "See also the ``--python`` option if the intention is to " + "install packages into another (possibly pip-free) " + "environment." + ), + ) + + self.cmd_opts.add_option(cmdoptions.src()) + + self.cmd_opts.add_option( + "-U", + "--upgrade", + dest="upgrade", + action="store_true", + help=( + "Upgrade all specified packages to the newest available " + "version. The handling of dependencies depends on the " + "upgrade-strategy used." + ), + ) + + self.cmd_opts.add_option( + "--upgrade-strategy", + dest="upgrade_strategy", + default="only-if-needed", + choices=["only-if-needed", "eager"], + help=( + "Determines how dependency upgrading should be handled " + "[default: %default]. " + '"eager" - dependencies are upgraded regardless of ' + "whether the currently installed version satisfies the " + "requirements of the upgraded package(s). " + '"only-if-needed" - are upgraded only when they do not ' + "satisfy the requirements of the upgraded package(s)." + ), + ) + + self.cmd_opts.add_option( + "--force-reinstall", + dest="force_reinstall", + action="store_true", + help="Reinstall all packages even if they are already up-to-date.", + ) + + self.cmd_opts.add_option( + "-I", + "--ignore-installed", + dest="ignore_installed", + action="store_true", + help=( + "Ignore the installed packages, overwriting them. " + "This can break your system if the existing package " + "is of a different version or was installed " + "with a different package manager!" + ), + ) + + self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) + self.cmd_opts.add_option(cmdoptions.no_build_isolation()) + self.cmd_opts.add_option(cmdoptions.use_pep517()) + self.cmd_opts.add_option(cmdoptions.no_use_pep517()) + self.cmd_opts.add_option(cmdoptions.check_build_deps()) + self.cmd_opts.add_option(cmdoptions.override_externally_managed()) + + self.cmd_opts.add_option(cmdoptions.config_settings()) + self.cmd_opts.add_option(cmdoptions.global_options()) + + self.cmd_opts.add_option( + "--compile", + action="store_true", + dest="compile", + default=True, + help="Compile Python source files to bytecode", + ) + + self.cmd_opts.add_option( + "--no-compile", + action="store_false", + dest="compile", + help="Do not compile Python source files to bytecode", + ) + + self.cmd_opts.add_option( + "--no-warn-script-location", + action="store_false", + dest="warn_script_location", + default=True, + help="Do not warn when installing scripts outside PATH", + ) + self.cmd_opts.add_option( + "--no-warn-conflicts", + action="store_false", + dest="warn_about_conflicts", + default=True, + help="Do not warn about broken dependencies", + ) + self.cmd_opts.add_option(cmdoptions.no_binary()) + self.cmd_opts.add_option(cmdoptions.only_binary()) + self.cmd_opts.add_option(cmdoptions.prefer_binary()) + self.cmd_opts.add_option(cmdoptions.require_hashes()) + self.cmd_opts.add_option(cmdoptions.progress_bar()) + self.cmd_opts.add_option(cmdoptions.root_user_action()) + + index_opts = cmdoptions.make_option_group( + cmdoptions.index_group, + self.parser, + ) + + self.parser.insert_option_group(0, index_opts) + self.parser.insert_option_group(0, self.cmd_opts) + + self.cmd_opts.add_option( + "--report", + dest="json_report_file", + metavar="file", + default=None, + help=( + "Generate a JSON file describing what pip did to install " + "the provided requirements. " + "Can be used in combination with --dry-run and --ignore-installed " + "to 'resolve' the requirements. " + "When - is used as file name it writes to stdout. " + "When writing to stdout, please combine with the --quiet option " + "to avoid mixing pip logging output with JSON output." + ), + ) + + @with_cleanup + def run(self, options: Values, args: List[str]) -> int: + if options.use_user_site and options.target_dir is not None: + raise CommandError("Can not combine '--user' and '--target'") + + # Check whether the environment we're installing into is externally + # managed, as specified in PEP 668. Specifying --root, --target, or + # --prefix disables the check, since there's no reliable way to locate + # the EXTERNALLY-MANAGED file for those cases. An exception is also + # made specifically for "--dry-run --report" for convenience. + installing_into_current_environment = ( + not (options.dry_run and options.json_report_file) + and options.root_path is None + and options.target_dir is None + and options.prefix_path is None + ) + if ( + installing_into_current_environment + and not options.override_externally_managed + ): + check_externally_managed() + + upgrade_strategy = "to-satisfy-only" + if options.upgrade: + upgrade_strategy = options.upgrade_strategy + + cmdoptions.check_dist_restriction(options, check_target=True) + + logger.verbose("Using %s", get_pip_version()) + options.use_user_site = decide_user_install( + options.use_user_site, + prefix_path=options.prefix_path, + target_dir=options.target_dir, + root_path=options.root_path, + isolated_mode=options.isolated_mode, + ) + + target_temp_dir: Optional[TempDirectory] = None + target_temp_dir_path: Optional[str] = None + if options.target_dir: + options.ignore_installed = True + options.target_dir = os.path.abspath(options.target_dir) + if ( + # fmt: off + os.path.exists(options.target_dir) and + not os.path.isdir(options.target_dir) + # fmt: on + ): + raise CommandError( + "Target path exists but is not a directory, will not continue." + ) + + # Create a target directory for using with the target option + target_temp_dir = TempDirectory(kind="target") + target_temp_dir_path = target_temp_dir.path + self.enter_context(target_temp_dir) + + global_options = options.global_options or [] + + session = self.get_default_session(options) + + target_python = make_target_python(options) + finder = self._build_package_finder( + options=options, + session=session, + target_python=target_python, + ignore_requires_python=options.ignore_requires_python, + ) + build_tracker = self.enter_context(get_build_tracker()) + + directory = TempDirectory( + delete=not options.no_clean, + kind="install", + globally_managed=True, + ) + + try: + reqs = self.get_requirements(args, options, finder, session) + check_legacy_setup_py_options(options, reqs) + + wheel_cache = WheelCache(options.cache_dir) + + # Only when installing is it permitted to use PEP 660. + # In other circumstances (pip wheel, pip download) we generate + # regular (i.e. non editable) metadata and wheels. + for req in reqs: + req.permit_editable_wheels = True + + preparer = self.make_requirement_preparer( + temp_build_dir=directory, + options=options, + build_tracker=build_tracker, + session=session, + finder=finder, + use_user_site=options.use_user_site, + verbosity=self.verbosity, + ) + resolver = self.make_resolver( + preparer=preparer, + finder=finder, + options=options, + wheel_cache=wheel_cache, + use_user_site=options.use_user_site, + ignore_installed=options.ignore_installed, + ignore_requires_python=options.ignore_requires_python, + force_reinstall=options.force_reinstall, + upgrade_strategy=upgrade_strategy, + use_pep517=options.use_pep517, + py_version_info=options.python_version, + ) + + self.trace_basic_info(finder) + + requirement_set = resolver.resolve( + reqs, check_supported_wheels=not options.target_dir + ) + + if options.json_report_file: + report = InstallationReport(requirement_set.requirements_to_install) + if options.json_report_file == "-": + print_json(data=report.to_dict()) + else: + with open(options.json_report_file, "w", encoding="utf-8") as f: + json.dump(report.to_dict(), f, indent=2, ensure_ascii=False) + + if options.dry_run: + would_install_items = sorted( + (r.metadata["name"], r.metadata["version"]) + for r in requirement_set.requirements_to_install + ) + if would_install_items: + write_output( + "Would install %s", + " ".join("-".join(item) for item in would_install_items), + ) + return SUCCESS + + try: + pip_req = requirement_set.get_requirement("pip") + except KeyError: + modifying_pip = False + else: + # If we're not replacing an already installed pip, + # we're not modifying it. + modifying_pip = pip_req.satisfied_by is None + protect_pip_from_modification_on_windows(modifying_pip=modifying_pip) + + reqs_to_build = [ + r + for r in requirement_set.requirements_to_install + if should_build_for_install_command(r) + ] + + _, build_failures = build( + reqs_to_build, + wheel_cache=wheel_cache, + verify=True, + build_options=[], + global_options=global_options, + ) + + if build_failures: + raise InstallationError( + "Failed to build installable wheels for some " + "pyproject.toml based projects ({})".format( + ", ".join(r.name for r in build_failures) # type: ignore + ) + ) + + to_install = resolver.get_installation_order(requirement_set) + + # Check for conflicts in the package set we're installing. + conflicts: Optional[ConflictDetails] = None + should_warn_about_conflicts = ( + not options.ignore_dependencies and options.warn_about_conflicts + ) + if should_warn_about_conflicts: + conflicts = self._determine_conflicts(to_install) + + # Don't warn about script install locations if + # --target or --prefix has been specified + warn_script_location = options.warn_script_location + if options.target_dir or options.prefix_path: + warn_script_location = False + + installed = install_given_reqs( + to_install, + global_options, + root=options.root_path, + home=target_temp_dir_path, + prefix=options.prefix_path, + warn_script_location=warn_script_location, + use_user_site=options.use_user_site, + pycompile=options.compile, + progress_bar=options.progress_bar, + ) + + lib_locations = get_lib_location_guesses( + user=options.use_user_site, + home=target_temp_dir_path, + root=options.root_path, + prefix=options.prefix_path, + isolated=options.isolated_mode, + ) + env = get_environment(lib_locations) + + # Display a summary of installed packages, with extra care to + # display a package name as it was requested by the user. + installed.sort(key=operator.attrgetter("name")) + summary = [] + installed_versions = {} + for distribution in env.iter_all_distributions(): + installed_versions[distribution.canonical_name] = distribution.version + for package in installed: + display_name = package.name + version = installed_versions.get(canonicalize_name(display_name), None) + if version: + text = f"{display_name}-{version}" + else: + text = display_name + summary.append(text) + + if conflicts is not None: + self._warn_about_conflicts( + conflicts, + resolver_variant=self.determine_resolver_variant(options), + ) + + installed_desc = " ".join(summary) + if installed_desc: + write_output( + "Successfully installed %s", + installed_desc, + ) + except OSError as error: + show_traceback = self.verbosity >= 1 + + message = create_os_error_message( + error, + show_traceback, + options.use_user_site, + ) + logger.error(message, exc_info=show_traceback) + + return ERROR + + if options.target_dir: + assert target_temp_dir + self._handle_target_dir( + options.target_dir, target_temp_dir, options.upgrade + ) + if options.root_user_action == "warn": + warn_if_run_as_root() + return SUCCESS + + def _handle_target_dir( + self, target_dir: str, target_temp_dir: TempDirectory, upgrade: bool + ) -> None: + ensure_dir(target_dir) + + # Checking both purelib and platlib directories for installed + # packages to be moved to target directory + lib_dir_list = [] + + # Checking both purelib and platlib directories for installed + # packages to be moved to target directory + scheme = get_scheme("", home=target_temp_dir.path) + purelib_dir = scheme.purelib + platlib_dir = scheme.platlib + data_dir = scheme.data + + if os.path.exists(purelib_dir): + lib_dir_list.append(purelib_dir) + if os.path.exists(platlib_dir) and platlib_dir != purelib_dir: + lib_dir_list.append(platlib_dir) + if os.path.exists(data_dir): + lib_dir_list.append(data_dir) + + for lib_dir in lib_dir_list: + for item in os.listdir(lib_dir): + if lib_dir == data_dir: + ddir = os.path.join(data_dir, item) + if any(s.startswith(ddir) for s in lib_dir_list[:-1]): + continue + target_item_dir = os.path.join(target_dir, item) + if os.path.exists(target_item_dir): + if not upgrade: + logger.warning( + "Target directory %s already exists. Specify " + "--upgrade to force replacement.", + target_item_dir, + ) + continue + if os.path.islink(target_item_dir): + logger.warning( + "Target directory %s already exists and is " + "a link. pip will not automatically replace " + "links, please remove if replacement is " + "desired.", + target_item_dir, + ) + continue + if os.path.isdir(target_item_dir): + shutil.rmtree(target_item_dir) + else: + os.remove(target_item_dir) + + shutil.move(os.path.join(lib_dir, item), target_item_dir) + + def _determine_conflicts( + self, to_install: List[InstallRequirement] + ) -> Optional[ConflictDetails]: + try: + return check_install_conflicts(to_install) + except Exception: + logger.exception( + "Error while checking for conflicts. Please file an issue on " + "pip's issue tracker: https://github.com/pypa/pip/issues/new" + ) + return None + + def _warn_about_conflicts( + self, conflict_details: ConflictDetails, resolver_variant: str + ) -> None: + package_set, (missing, conflicting) = conflict_details + if not missing and not conflicting: + return + + parts: List[str] = [] + if resolver_variant == "legacy": + parts.append( + "pip's legacy dependency resolver does not consider dependency " + "conflicts when selecting packages. This behaviour is the " + "source of the following dependency conflicts." + ) + else: + assert resolver_variant == "resolvelib" + parts.append( + "pip's dependency resolver does not currently take into account " + "all the packages that are installed. This behaviour is the " + "source of the following dependency conflicts." + ) + + # NOTE: There is some duplication here, with commands/check.py + for project_name in missing: + version = package_set[project_name][0] + for dependency in missing[project_name]: + message = ( + f"{project_name} {version} requires {dependency[1]}, " + "which is not installed." + ) + parts.append(message) + + for project_name in conflicting: + version = package_set[project_name][0] + for dep_name, dep_version, req in conflicting[project_name]: + message = ( + "{name} {version} requires {requirement}, but {you} have " + "{dep_name} {dep_version} which is incompatible." + ).format( + name=project_name, + version=version, + requirement=req, + dep_name=dep_name, + dep_version=dep_version, + you=("you" if resolver_variant == "resolvelib" else "you'll"), + ) + parts.append(message) + + logger.critical("\n".join(parts)) + + +def get_lib_location_guesses( + user: bool = False, + home: Optional[str] = None, + root: Optional[str] = None, + isolated: bool = False, + prefix: Optional[str] = None, +) -> List[str]: + scheme = get_scheme( + "", + user=user, + home=home, + root=root, + isolated=isolated, + prefix=prefix, + ) + return [scheme.purelib, scheme.platlib] + + +def site_packages_writable(root: Optional[str], isolated: bool) -> bool: + return all( + test_writable_dir(d) + for d in set(get_lib_location_guesses(root=root, isolated=isolated)) + ) + + +def decide_user_install( + use_user_site: Optional[bool], + prefix_path: Optional[str] = None, + target_dir: Optional[str] = None, + root_path: Optional[str] = None, + isolated_mode: bool = False, +) -> bool: + """Determine whether to do a user install based on the input options. + + If use_user_site is False, no additional checks are done. + If use_user_site is True, it is checked for compatibility with other + options. + If use_user_site is None, the default behaviour depends on the environment, + which is provided by the other arguments. + """ + # In some cases (config from tox), use_user_site can be set to an integer + # rather than a bool, which 'use_user_site is False' wouldn't catch. + if (use_user_site is not None) and (not use_user_site): + logger.debug("Non-user install by explicit request") + return False + + if use_user_site: + if prefix_path: + raise CommandError( + "Can not combine '--user' and '--prefix' as they imply " + "different installation locations" + ) + if virtualenv_no_global(): + raise InstallationError( + "Can not perform a '--user' install. User site-packages " + "are not visible in this virtualenv." + ) + logger.debug("User install by explicit request") + return True + + # If we are here, user installs have not been explicitly requested/avoided + assert use_user_site is None + + # user install incompatible with --prefix/--target + if prefix_path or target_dir: + logger.debug("Non-user install due to --prefix or --target option") + return False + + # If user installs are not enabled, choose a non-user install + if not site.ENABLE_USER_SITE: + logger.debug("Non-user install because user site-packages disabled") + return False + + # If we have permission for a non-user install, do that, + # otherwise do a user install. + if site_packages_writable(root=root_path, isolated=isolated_mode): + logger.debug("Non-user install because site-packages writeable") + return False + + logger.info( + "Defaulting to user installation because normal site-packages " + "is not writeable" + ) + return True + + +def create_os_error_message( + error: OSError, show_traceback: bool, using_user_site: bool +) -> str: + """Format an error message for an OSError + + It may occur anytime during the execution of the install command. + """ + parts = [] + + # Mention the error if we are not going to show a traceback + parts.append("Could not install packages due to an OSError") + if not show_traceback: + parts.append(": ") + parts.append(str(error)) + else: + parts.append(".") + + # Spilt the error indication from a helper message (if any) + parts[-1] += "\n" + + # Suggest useful actions to the user: + # (1) using user site-packages or (2) verifying the permissions + if error.errno == errno.EACCES: + user_option_part = "Consider using the `--user` option" + permissions_part = "Check the permissions" + + if not running_under_virtualenv() and not using_user_site: + parts.extend( + [ + user_option_part, + " or ", + permissions_part.lower(), + ] + ) + else: + parts.append(permissions_part) + parts.append(".\n") + + # Suggest to check "pip config debug" in case of invalid proxy + if type(error) is InvalidProxyURL: + parts.append( + 'Consider checking your local proxy configuration with "pip config debug"' + ) + parts.append(".\n") + + # Suggest the user to enable Long Paths if path length is + # more than 260 + if ( + WINDOWS + and error.errno == errno.ENOENT + and error.filename + and len(error.filename) > 260 + ): + parts.append( + "HINT: This error might have occurred since " + "this system does not have Windows Long Path " + "support enabled. You can find information on " + "how to enable this at " + "https://pip.pypa.io/warnings/enable-long-paths\n" + ) + + return "".join(parts).strip() + "\n" diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/list.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/list.py new file mode 100644 index 0000000000000000000000000000000000000000..b14b1db71ddb92f0195ef40ca279b79b09a6f58d --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/list.py @@ -0,0 +1,393 @@ +import json +import logging +from email.parser import Parser +from optparse import Values +from typing import TYPE_CHECKING, Generator, List, Optional, Sequence, Tuple, cast + +from pip._vendor.packaging.utils import canonicalize_name +from pip._vendor.packaging.version import Version + +from pip._internal.cli import cmdoptions +from pip._internal.cli.index_command import IndexGroupCommand +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.exceptions import CommandError +from pip._internal.metadata import BaseDistribution, get_environment +from pip._internal.models.selection_prefs import SelectionPreferences +from pip._internal.utils.compat import stdlib_pkgs +from pip._internal.utils.misc import tabulate, write_output + +if TYPE_CHECKING: + from pip._internal.index.package_finder import PackageFinder + from pip._internal.network.session import PipSession + + class _DistWithLatestInfo(BaseDistribution): + """Give the distribution object a couple of extra fields. + + These will be populated during ``get_outdated()``. This is dirty but + makes the rest of the code much cleaner. + """ + + latest_version: Version + latest_filetype: str + + _ProcessedDists = Sequence[_DistWithLatestInfo] + + +from pip._vendor.packaging.version import parse + +logger = logging.getLogger(__name__) + + +class ListCommand(IndexGroupCommand): + """ + List installed packages, including editables. + + Packages are listed in a case-insensitive sorted order. + """ + + ignore_require_venv = True + usage = """ + %prog [options]""" + + def add_options(self) -> None: + self.cmd_opts.add_option( + "-o", + "--outdated", + action="store_true", + default=False, + help="List outdated packages", + ) + self.cmd_opts.add_option( + "-u", + "--uptodate", + action="store_true", + default=False, + help="List uptodate packages", + ) + self.cmd_opts.add_option( + "-e", + "--editable", + action="store_true", + default=False, + help="List editable projects.", + ) + self.cmd_opts.add_option( + "-l", + "--local", + action="store_true", + default=False, + help=( + "If in a virtualenv that has global access, do not list " + "globally-installed packages." + ), + ) + self.cmd_opts.add_option( + "--user", + dest="user", + action="store_true", + default=False, + help="Only output packages installed in user-site.", + ) + self.cmd_opts.add_option(cmdoptions.list_path()) + self.cmd_opts.add_option( + "--pre", + action="store_true", + default=False, + help=( + "Include pre-release and development versions. By default, " + "pip only finds stable versions." + ), + ) + + self.cmd_opts.add_option( + "--format", + action="store", + dest="list_format", + default="columns", + choices=("columns", "freeze", "json"), + help=( + "Select the output format among: columns (default), freeze, or json. " + "The 'freeze' format cannot be used with the --outdated option." + ), + ) + + self.cmd_opts.add_option( + "--not-required", + action="store_true", + dest="not_required", + help="List packages that are not dependencies of installed packages.", + ) + + self.cmd_opts.add_option( + "--exclude-editable", + action="store_false", + dest="include_editable", + help="Exclude editable package from output.", + ) + self.cmd_opts.add_option( + "--include-editable", + action="store_true", + dest="include_editable", + help="Include editable package in output.", + default=True, + ) + self.cmd_opts.add_option(cmdoptions.list_exclude()) + index_opts = cmdoptions.make_option_group(cmdoptions.index_group, self.parser) + + self.parser.insert_option_group(0, index_opts) + self.parser.insert_option_group(0, self.cmd_opts) + + def handle_pip_version_check(self, options: Values) -> None: + if options.outdated or options.uptodate: + super().handle_pip_version_check(options) + + def _build_package_finder( + self, options: Values, session: "PipSession" + ) -> "PackageFinder": + """ + Create a package finder appropriate to this list command. + """ + # Lazy import the heavy index modules as most list invocations won't need 'em. + from pip._internal.index.collector import LinkCollector + from pip._internal.index.package_finder import PackageFinder + + link_collector = LinkCollector.create(session, options=options) + + # Pass allow_yanked=False to ignore yanked versions. + selection_prefs = SelectionPreferences( + allow_yanked=False, + allow_all_prereleases=options.pre, + ) + + return PackageFinder.create( + link_collector=link_collector, + selection_prefs=selection_prefs, + ) + + def run(self, options: Values, args: List[str]) -> int: + if options.outdated and options.uptodate: + raise CommandError("Options --outdated and --uptodate cannot be combined.") + + if options.outdated and options.list_format == "freeze": + raise CommandError( + "List format 'freeze' cannot be used with the --outdated option." + ) + + cmdoptions.check_list_path_option(options) + + skip = set(stdlib_pkgs) + if options.excludes: + skip.update(canonicalize_name(n) for n in options.excludes) + + packages: _ProcessedDists = [ + cast("_DistWithLatestInfo", d) + for d in get_environment(options.path).iter_installed_distributions( + local_only=options.local, + user_only=options.user, + editables_only=options.editable, + include_editables=options.include_editable, + skip=skip, + ) + ] + + # get_not_required must be called firstly in order to find and + # filter out all dependencies correctly. Otherwise a package + # can't be identified as requirement because some parent packages + # could be filtered out before. + if options.not_required: + packages = self.get_not_required(packages, options) + + if options.outdated: + packages = self.get_outdated(packages, options) + elif options.uptodate: + packages = self.get_uptodate(packages, options) + + self.output_package_listing(packages, options) + return SUCCESS + + def get_outdated( + self, packages: "_ProcessedDists", options: Values + ) -> "_ProcessedDists": + return [ + dist + for dist in self.iter_packages_latest_infos(packages, options) + if parse(str(dist.latest_version)) > parse(str(dist.version)) + ] + + def get_uptodate( + self, packages: "_ProcessedDists", options: Values + ) -> "_ProcessedDists": + return [ + dist + for dist in self.iter_packages_latest_infos(packages, options) + if parse(str(dist.latest_version)) == parse(str(dist.version)) + ] + + def get_not_required( + self, packages: "_ProcessedDists", options: Values + ) -> "_ProcessedDists": + dep_keys = { + canonicalize_name(dep.name) + for dist in packages + for dep in (dist.iter_dependencies() or ()) + } + + # Create a set to remove duplicate packages, and cast it to a list + # to keep the return type consistent with get_outdated and + # get_uptodate + return list({pkg for pkg in packages if pkg.canonical_name not in dep_keys}) + + def iter_packages_latest_infos( + self, packages: "_ProcessedDists", options: Values + ) -> Generator["_DistWithLatestInfo", None, None]: + with self._build_session(options) as session: + finder = self._build_package_finder(options, session) + + def latest_info( + dist: "_DistWithLatestInfo", + ) -> Optional["_DistWithLatestInfo"]: + all_candidates = finder.find_all_candidates(dist.canonical_name) + if not options.pre: + # Remove prereleases + all_candidates = [ + candidate + for candidate in all_candidates + if not candidate.version.is_prerelease + ] + + evaluator = finder.make_candidate_evaluator( + project_name=dist.canonical_name, + ) + best_candidate = evaluator.sort_best_candidate(all_candidates) + if best_candidate is None: + return None + + remote_version = best_candidate.version + if best_candidate.link.is_wheel: + typ = "wheel" + else: + typ = "sdist" + dist.latest_version = remote_version + dist.latest_filetype = typ + return dist + + for dist in map(latest_info, packages): + if dist is not None: + yield dist + + def output_package_listing( + self, packages: "_ProcessedDists", options: Values + ) -> None: + packages = sorted( + packages, + key=lambda dist: dist.canonical_name, + ) + if options.list_format == "columns" and packages: + data, header = format_for_columns(packages, options) + self.output_package_listing_columns(data, header) + elif options.list_format == "freeze": + for dist in packages: + if options.verbose >= 1: + write_output( + "%s==%s (%s)", dist.raw_name, dist.version, dist.location + ) + else: + write_output("%s==%s", dist.raw_name, dist.version) + elif options.list_format == "json": + write_output(format_for_json(packages, options)) + + def output_package_listing_columns( + self, data: List[List[str]], header: List[str] + ) -> None: + # insert the header first: we need to know the size of column names + if len(data) > 0: + data.insert(0, header) + + pkg_strings, sizes = tabulate(data) + + # Create and add a separator. + if len(data) > 0: + pkg_strings.insert(1, " ".join("-" * x for x in sizes)) + + for val in pkg_strings: + write_output(val) + + +def format_for_columns( + pkgs: "_ProcessedDists", options: Values +) -> Tuple[List[List[str]], List[str]]: + """ + Convert the package data into something usable + by output_package_listing_columns. + """ + header = ["Package", "Version"] + + running_outdated = options.outdated + if running_outdated: + header.extend(["Latest", "Type"]) + + def wheel_build_tag(dist: BaseDistribution) -> Optional[str]: + try: + wheel_file = dist.read_text("WHEEL") + except FileNotFoundError: + return None + return Parser().parsestr(wheel_file).get("Build") + + build_tags = [wheel_build_tag(p) for p in pkgs] + has_build_tags = any(build_tags) + if has_build_tags: + header.append("Build") + + if options.verbose >= 1: + header.append("Location") + if options.verbose >= 1: + header.append("Installer") + + has_editables = any(x.editable for x in pkgs) + if has_editables: + header.append("Editable project location") + + data = [] + for i, proj in enumerate(pkgs): + # if we're working on the 'outdated' list, separate out the + # latest_version and type + row = [proj.raw_name, proj.raw_version] + + if running_outdated: + row.append(str(proj.latest_version)) + row.append(proj.latest_filetype) + + if has_build_tags: + row.append(build_tags[i] or "") + + if has_editables: + row.append(proj.editable_project_location or "") + + if options.verbose >= 1: + row.append(proj.location or "") + if options.verbose >= 1: + row.append(proj.installer) + + data.append(row) + + return data, header + + +def format_for_json(packages: "_ProcessedDists", options: Values) -> str: + data = [] + for dist in packages: + info = { + "name": dist.raw_name, + "version": str(dist.version), + } + if options.verbose >= 1: + info["location"] = dist.location or "" + info["installer"] = dist.installer + if options.outdated: + info["latest_version"] = str(dist.latest_version) + info["latest_filetype"] = dist.latest_filetype + editable_project_location = dist.editable_project_location + if editable_project_location: + info["editable_project_location"] = editable_project_location + data.append(info) + return json.dumps(data) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/lock.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/lock.py new file mode 100644 index 0000000000000000000000000000000000000000..39f27e8677b76aa1b7d1ffc64523d8a8b1b94a49 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/lock.py @@ -0,0 +1,171 @@ +import sys +from optparse import Values +from pathlib import Path +from typing import List + +from pip._internal.cache import WheelCache +from pip._internal.cli import cmdoptions +from pip._internal.cli.req_command import ( + RequirementCommand, + with_cleanup, +) +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.models.pylock import Pylock, is_valid_pylock_file_name +from pip._internal.operations.build.build_tracker import get_build_tracker +from pip._internal.req.req_install import ( + check_legacy_setup_py_options, +) +from pip._internal.utils.logging import getLogger +from pip._internal.utils.misc import ( + get_pip_version, +) +from pip._internal.utils.temp_dir import TempDirectory + +logger = getLogger(__name__) + + +class LockCommand(RequirementCommand): + """ + EXPERIMENTAL - Lock packages and their dependencies from: + + - PyPI (and other indexes) using requirement specifiers. + - VCS project urls. + - Local project directories. + - Local or remote source archives. + + pip also supports locking from "requirements files", which provide an easy + way to specify a whole environment to be installed. + + The generated lock file is only guaranteed to be valid for the current + python version and platform. + """ + + usage = """ + %prog [options] [-e] ... + %prog [options] [package-index-options] ... + %prog [options] -r [package-index-options] ... + %prog [options] ...""" + + def add_options(self) -> None: + self.cmd_opts.add_option( + cmdoptions.PipOption( + "--output", + "-o", + dest="output_file", + metavar="path", + type="path", + default="pylock.toml", + help="Lock file name (default=pylock.toml). Use - for stdout.", + ) + ) + self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.no_deps()) + self.cmd_opts.add_option(cmdoptions.pre()) + + self.cmd_opts.add_option(cmdoptions.editable()) + + self.cmd_opts.add_option(cmdoptions.src()) + + self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) + self.cmd_opts.add_option(cmdoptions.no_build_isolation()) + self.cmd_opts.add_option(cmdoptions.use_pep517()) + self.cmd_opts.add_option(cmdoptions.no_use_pep517()) + self.cmd_opts.add_option(cmdoptions.check_build_deps()) + + self.cmd_opts.add_option(cmdoptions.config_settings()) + + self.cmd_opts.add_option(cmdoptions.no_binary()) + self.cmd_opts.add_option(cmdoptions.only_binary()) + self.cmd_opts.add_option(cmdoptions.prefer_binary()) + self.cmd_opts.add_option(cmdoptions.require_hashes()) + self.cmd_opts.add_option(cmdoptions.progress_bar()) + + index_opts = cmdoptions.make_option_group( + cmdoptions.index_group, + self.parser, + ) + + self.parser.insert_option_group(0, index_opts) + self.parser.insert_option_group(0, self.cmd_opts) + + @with_cleanup + def run(self, options: Values, args: List[str]) -> int: + logger.verbose("Using %s", get_pip_version()) + + logger.warning( + "pip lock is currently an experimental command. " + "It may be removed/changed in a future release " + "without prior warning." + ) + + session = self.get_default_session(options) + + finder = self._build_package_finder( + options=options, + session=session, + ignore_requires_python=options.ignore_requires_python, + ) + build_tracker = self.enter_context(get_build_tracker()) + + directory = TempDirectory( + delete=not options.no_clean, + kind="install", + globally_managed=True, + ) + + reqs = self.get_requirements(args, options, finder, session) + check_legacy_setup_py_options(options, reqs) + + wheel_cache = WheelCache(options.cache_dir) + + # Only when installing is it permitted to use PEP 660. + # In other circumstances (pip wheel, pip download) we generate + # regular (i.e. non editable) metadata and wheels. + for req in reqs: + req.permit_editable_wheels = True + + preparer = self.make_requirement_preparer( + temp_build_dir=directory, + options=options, + build_tracker=build_tracker, + session=session, + finder=finder, + use_user_site=False, + verbosity=self.verbosity, + ) + resolver = self.make_resolver( + preparer=preparer, + finder=finder, + options=options, + wheel_cache=wheel_cache, + use_user_site=False, + ignore_installed=True, + ignore_requires_python=options.ignore_requires_python, + upgrade_strategy="to-satisfy-only", + use_pep517=options.use_pep517, + ) + + self.trace_basic_info(finder) + + requirement_set = resolver.resolve(reqs, check_supported_wheels=True) + + if options.output_file == "-": + base_dir = Path.cwd() + else: + output_file_path = Path(options.output_file) + if not is_valid_pylock_file_name(output_file_path): + logger.warning( + "%s is not a valid lock file name.", + output_file_path, + ) + base_dir = output_file_path.parent + pylock_toml = Pylock.from_install_requirements( + requirement_set.requirements.values(), base_dir=base_dir + ).as_toml() + if options.output_file == "-": + sys.stdout.write(pylock_toml) + else: + output_file_path.write_text(pylock_toml, encoding="utf-8") + + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/search.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/search.py new file mode 100644 index 0000000000000000000000000000000000000000..c58c2b3b11eed568569e08d388a43fb0e614f990 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/search.py @@ -0,0 +1,176 @@ +import logging +import shutil +import sys +import textwrap +import xmlrpc.client +from collections import OrderedDict +from optparse import Values +from typing import Dict, List, Optional, TypedDict + +from pip._vendor.packaging.version import parse as parse_version + +from pip._internal.cli.base_command import Command +from pip._internal.cli.req_command import SessionCommandMixin +from pip._internal.cli.status_codes import NO_MATCHES_FOUND, SUCCESS +from pip._internal.exceptions import CommandError +from pip._internal.metadata import get_default_environment +from pip._internal.metadata.base import BaseDistribution +from pip._internal.models.index import PyPI +from pip._internal.network.xmlrpc import PipXmlrpcTransport +from pip._internal.utils.logging import indent_log +from pip._internal.utils.misc import write_output + + +class TransformedHit(TypedDict): + name: str + summary: str + versions: List[str] + + +logger = logging.getLogger(__name__) + + +class SearchCommand(Command, SessionCommandMixin): + """Search for PyPI packages whose name or summary contains .""" + + usage = """ + %prog [options] """ + ignore_require_venv = True + + def add_options(self) -> None: + self.cmd_opts.add_option( + "-i", + "--index", + dest="index", + metavar="URL", + default=PyPI.pypi_url, + help="Base URL of Python Package Index (default %default)", + ) + + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + if not args: + raise CommandError("Missing required argument (search query).") + query = args + pypi_hits = self.search(query, options) + hits = transform_hits(pypi_hits) + + terminal_width = None + if sys.stdout.isatty(): + terminal_width = shutil.get_terminal_size()[0] + + print_results(hits, terminal_width=terminal_width) + if pypi_hits: + return SUCCESS + return NO_MATCHES_FOUND + + def search(self, query: List[str], options: Values) -> List[Dict[str, str]]: + index_url = options.index + + session = self.get_default_session(options) + + transport = PipXmlrpcTransport(index_url, session) + pypi = xmlrpc.client.ServerProxy(index_url, transport) + try: + hits = pypi.search({"name": query, "summary": query}, "or") + except xmlrpc.client.Fault as fault: + message = ( + f"XMLRPC request failed [code: {fault.faultCode}]\n{fault.faultString}" + ) + raise CommandError(message) + assert isinstance(hits, list) + return hits + + +def transform_hits(hits: List[Dict[str, str]]) -> List["TransformedHit"]: + """ + The list from pypi is really a list of versions. We want a list of + packages with the list of versions stored inline. This converts the + list from pypi into one we can use. + """ + packages: Dict[str, TransformedHit] = OrderedDict() + for hit in hits: + name = hit["name"] + summary = hit["summary"] + version = hit["version"] + + if name not in packages.keys(): + packages[name] = { + "name": name, + "summary": summary, + "versions": [version], + } + else: + packages[name]["versions"].append(version) + + # if this is the highest version, replace summary and score + if version == highest_version(packages[name]["versions"]): + packages[name]["summary"] = summary + + return list(packages.values()) + + +def print_dist_installation_info(latest: str, dist: Optional[BaseDistribution]) -> None: + if dist is not None: + with indent_log(): + if dist.version == latest: + write_output("INSTALLED: %s (latest)", dist.version) + else: + write_output("INSTALLED: %s", dist.version) + if parse_version(latest).pre: + write_output( + "LATEST: %s (pre-release; install" + " with `pip install --pre`)", + latest, + ) + else: + write_output("LATEST: %s", latest) + + +def get_installed_distribution(name: str) -> Optional[BaseDistribution]: + env = get_default_environment() + return env.get_distribution(name) + + +def print_results( + hits: List["TransformedHit"], + name_column_width: Optional[int] = None, + terminal_width: Optional[int] = None, +) -> None: + if not hits: + return + if name_column_width is None: + name_column_width = ( + max( + [ + len(hit["name"]) + len(highest_version(hit.get("versions", ["-"]))) + for hit in hits + ] + ) + + 4 + ) + + for hit in hits: + name = hit["name"] + summary = hit["summary"] or "" + latest = highest_version(hit.get("versions", ["-"])) + if terminal_width is not None: + target_width = terminal_width - name_column_width - 5 + if target_width > 10: + # wrap and indent summary to fit terminal + summary_lines = textwrap.wrap(summary, target_width) + summary = ("\n" + " " * (name_column_width + 3)).join(summary_lines) + + name_latest = f"{name} ({latest})" + line = f"{name_latest:{name_column_width}} - {summary}" + try: + write_output(line) + dist = get_installed_distribution(name) + print_dist_installation_info(latest, dist) + except UnicodeEncodeError: + pass + + +def highest_version(versions: List[str]) -> str: + return max(versions, key=parse_version) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/show.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/show.py new file mode 100644 index 0000000000000000000000000000000000000000..7aaf6f4b6ca97c7746f1322cd473268ec550b576 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/show.py @@ -0,0 +1,228 @@ +import logging +import string +from optparse import Values +from typing import Generator, Iterable, Iterator, List, NamedTuple, Optional + +from pip._vendor.packaging.requirements import InvalidRequirement +from pip._vendor.packaging.utils import canonicalize_name + +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.metadata import BaseDistribution, get_default_environment +from pip._internal.utils.misc import write_output + +logger = logging.getLogger(__name__) + + +def normalize_project_url_label(label: str) -> str: + # This logic is from PEP 753 (Well-known Project URLs in Metadata). + chars_to_remove = string.punctuation + string.whitespace + removal_map = str.maketrans("", "", chars_to_remove) + return label.translate(removal_map).lower() + + +class ShowCommand(Command): + """ + Show information about one or more installed packages. + + The output is in RFC-compliant mail header format. + """ + + usage = """ + %prog [options] ...""" + ignore_require_venv = True + + def add_options(self) -> None: + self.cmd_opts.add_option( + "-f", + "--files", + dest="files", + action="store_true", + default=False, + help="Show the full list of installed files for each package.", + ) + + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + if not args: + logger.warning("ERROR: Please provide a package name or names.") + return ERROR + query = args + + results = search_packages_info(query) + if not print_results( + results, list_files=options.files, verbose=options.verbose + ): + return ERROR + return SUCCESS + + +class _PackageInfo(NamedTuple): + name: str + version: str + location: str + editable_project_location: Optional[str] + requires: List[str] + required_by: List[str] + installer: str + metadata_version: str + classifiers: List[str] + summary: str + homepage: str + project_urls: List[str] + author: str + author_email: str + license: str + license_expression: str + entry_points: List[str] + files: Optional[List[str]] + + +def search_packages_info(query: List[str]) -> Generator[_PackageInfo, None, None]: + """ + Gather details from installed distributions. Print distribution name, + version, location, and installed files. Installed files requires a + pip generated 'installed-files.txt' in the distributions '.egg-info' + directory. + """ + env = get_default_environment() + + installed = {dist.canonical_name: dist for dist in env.iter_all_distributions()} + query_names = [canonicalize_name(name) for name in query] + missing = sorted( + [name for name, pkg in zip(query, query_names) if pkg not in installed] + ) + if missing: + logger.warning("Package(s) not found: %s", ", ".join(missing)) + + def _get_requiring_packages(current_dist: BaseDistribution) -> Iterator[str]: + return ( + dist.metadata["Name"] or "UNKNOWN" + for dist in installed.values() + if current_dist.canonical_name + in {canonicalize_name(d.name) for d in dist.iter_dependencies()} + ) + + for query_name in query_names: + try: + dist = installed[query_name] + except KeyError: + continue + + try: + requires = sorted( + # Avoid duplicates in requirements (e.g. due to environment markers). + {req.name for req in dist.iter_dependencies()}, + key=str.lower, + ) + except InvalidRequirement: + requires = sorted(dist.iter_raw_dependencies(), key=str.lower) + + try: + required_by = sorted(_get_requiring_packages(dist), key=str.lower) + except InvalidRequirement: + required_by = ["#N/A"] + + try: + entry_points_text = dist.read_text("entry_points.txt") + entry_points = entry_points_text.splitlines(keepends=False) + except FileNotFoundError: + entry_points = [] + + files_iter = dist.iter_declared_entries() + if files_iter is None: + files: Optional[List[str]] = None + else: + files = sorted(files_iter) + + metadata = dist.metadata + + project_urls = metadata.get_all("Project-URL", []) + homepage = metadata.get("Home-page", "") + if not homepage: + # It's common that there is a "homepage" Project-URL, but Home-page + # remains unset (especially as PEP 621 doesn't surface the field). + for url in project_urls: + url_label, url = url.split(",", maxsplit=1) + normalized_label = normalize_project_url_label(url_label) + if normalized_label == "homepage": + homepage = url.strip() + break + + yield _PackageInfo( + name=dist.raw_name, + version=dist.raw_version, + location=dist.location or "", + editable_project_location=dist.editable_project_location, + requires=requires, + required_by=required_by, + installer=dist.installer, + metadata_version=dist.metadata_version or "", + classifiers=metadata.get_all("Classifier", []), + summary=metadata.get("Summary", ""), + homepage=homepage, + project_urls=project_urls, + author=metadata.get("Author", ""), + author_email=metadata.get("Author-email", ""), + license=metadata.get("License", ""), + license_expression=metadata.get("License-Expression", ""), + entry_points=entry_points, + files=files, + ) + + +def print_results( + distributions: Iterable[_PackageInfo], + list_files: bool, + verbose: bool, +) -> bool: + """ + Print the information from installed distributions found. + """ + results_printed = False + for i, dist in enumerate(distributions): + results_printed = True + if i > 0: + write_output("---") + + metadata_version_tuple = tuple(map(int, dist.metadata_version.split("."))) + + write_output("Name: %s", dist.name) + write_output("Version: %s", dist.version) + write_output("Summary: %s", dist.summary) + write_output("Home-page: %s", dist.homepage) + write_output("Author: %s", dist.author) + write_output("Author-email: %s", dist.author_email) + if metadata_version_tuple >= (2, 4) and dist.license_expression: + write_output("License-Expression: %s", dist.license_expression) + else: + write_output("License: %s", dist.license) + write_output("Location: %s", dist.location) + if dist.editable_project_location is not None: + write_output( + "Editable project location: %s", dist.editable_project_location + ) + write_output("Requires: %s", ", ".join(dist.requires)) + write_output("Required-by: %s", ", ".join(dist.required_by)) + + if verbose: + write_output("Metadata-Version: %s", dist.metadata_version) + write_output("Installer: %s", dist.installer) + write_output("Classifiers:") + for classifier in dist.classifiers: + write_output(" %s", classifier) + write_output("Entry-points:") + for entry in dist.entry_points: + write_output(" %s", entry.strip()) + write_output("Project-URLs:") + for project_url in dist.project_urls: + write_output(" %s", project_url) + if list_files: + write_output("Files:") + if dist.files is None: + write_output("Cannot locate RECORD or installed-files.txt") + else: + for line in dist.files: + write_output(" %s", line.strip()) + return results_printed diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/uninstall.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/uninstall.py new file mode 100644 index 0000000000000000000000000000000000000000..bc0edeac9fb74e4dfb6d7a095483c11557d268ba --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/uninstall.py @@ -0,0 +1,114 @@ +import logging +from optparse import Values +from typing import List + +from pip._vendor.packaging.utils import canonicalize_name + +from pip._internal.cli import cmdoptions +from pip._internal.cli.base_command import Command +from pip._internal.cli.index_command import SessionCommandMixin +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.exceptions import InstallationError +from pip._internal.req import parse_requirements +from pip._internal.req.constructors import ( + install_req_from_line, + install_req_from_parsed_requirement, +) +from pip._internal.utils.misc import ( + check_externally_managed, + protect_pip_from_modification_on_windows, + warn_if_run_as_root, +) + +logger = logging.getLogger(__name__) + + +class UninstallCommand(Command, SessionCommandMixin): + """ + Uninstall packages. + + pip is able to uninstall most installed packages. Known exceptions are: + + - Pure distutils packages installed with ``python setup.py install``, which + leave behind no metadata to determine what files were installed. + - Script wrappers installed by ``python setup.py develop``. + """ + + usage = """ + %prog [options] ... + %prog [options] -r ...""" + + def add_options(self) -> None: + self.cmd_opts.add_option( + "-r", + "--requirement", + dest="requirements", + action="append", + default=[], + metavar="file", + help=( + "Uninstall all the packages listed in the given requirements " + "file. This option can be used multiple times." + ), + ) + self.cmd_opts.add_option( + "-y", + "--yes", + dest="yes", + action="store_true", + help="Don't ask for confirmation of uninstall deletions.", + ) + self.cmd_opts.add_option(cmdoptions.root_user_action()) + self.cmd_opts.add_option(cmdoptions.override_externally_managed()) + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + session = self.get_default_session(options) + + reqs_to_uninstall = {} + for name in args: + req = install_req_from_line( + name, + isolated=options.isolated_mode, + ) + if req.name: + reqs_to_uninstall[canonicalize_name(req.name)] = req + else: + logger.warning( + "Invalid requirement: %r ignored -" + " the uninstall command expects named" + " requirements.", + name, + ) + for filename in options.requirements: + for parsed_req in parse_requirements( + filename, options=options, session=session + ): + req = install_req_from_parsed_requirement( + parsed_req, isolated=options.isolated_mode + ) + if req.name: + reqs_to_uninstall[canonicalize_name(req.name)] = req + if not reqs_to_uninstall: + raise InstallationError( + f"You must give at least one requirement to {self.name} (see " + f'"pip help {self.name}")' + ) + + if not options.override_externally_managed: + check_externally_managed() + + protect_pip_from_modification_on_windows( + modifying_pip="pip" in reqs_to_uninstall + ) + + for req in reqs_to_uninstall.values(): + uninstall_pathset = req.uninstall( + auto_confirm=options.yes, + verbose=self.verbosity > 0, + ) + if uninstall_pathset: + uninstall_pathset.commit() + if options.root_user_action == "warn": + warn_if_run_as_root() + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/wheel.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/wheel.py new file mode 100644 index 0000000000000000000000000000000000000000..b380754bc89b1789dd9c310c1973852c4030465b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/commands/wheel.py @@ -0,0 +1,182 @@ +import logging +import os +import shutil +from optparse import Values +from typing import List + +from pip._internal.cache import WheelCache +from pip._internal.cli import cmdoptions +from pip._internal.cli.req_command import RequirementCommand, with_cleanup +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.exceptions import CommandError +from pip._internal.operations.build.build_tracker import get_build_tracker +from pip._internal.req.req_install import ( + InstallRequirement, + check_legacy_setup_py_options, +) +from pip._internal.utils.misc import ensure_dir, normalize_path +from pip._internal.utils.temp_dir import TempDirectory +from pip._internal.wheel_builder import build + +logger = logging.getLogger(__name__) + + +class WheelCommand(RequirementCommand): + """ + Build Wheel archives for your requirements and dependencies. + + Wheel is a built-package format, and offers the advantage of not + recompiling your software during every install. For more details, see the + wheel docs: https://wheel.readthedocs.io/en/latest/ + + 'pip wheel' uses the build system interface as described here: + https://pip.pypa.io/en/stable/reference/build-system/ + + """ + + usage = """ + %prog [options] ... + %prog [options] -r ... + %prog [options] [-e] ... + %prog [options] [-e] ... + %prog [options] ...""" + + def add_options(self) -> None: + self.cmd_opts.add_option( + "-w", + "--wheel-dir", + dest="wheel_dir", + metavar="dir", + default=os.curdir, + help=( + "Build wheels into , where the default is the " + "current working directory." + ), + ) + self.cmd_opts.add_option(cmdoptions.no_binary()) + self.cmd_opts.add_option(cmdoptions.only_binary()) + self.cmd_opts.add_option(cmdoptions.prefer_binary()) + self.cmd_opts.add_option(cmdoptions.no_build_isolation()) + self.cmd_opts.add_option(cmdoptions.use_pep517()) + self.cmd_opts.add_option(cmdoptions.no_use_pep517()) + self.cmd_opts.add_option(cmdoptions.check_build_deps()) + self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.editable()) + self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.src()) + self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) + self.cmd_opts.add_option(cmdoptions.no_deps()) + self.cmd_opts.add_option(cmdoptions.progress_bar()) + + self.cmd_opts.add_option( + "--no-verify", + dest="no_verify", + action="store_true", + default=False, + help="Don't verify if built wheel is valid.", + ) + + self.cmd_opts.add_option(cmdoptions.config_settings()) + self.cmd_opts.add_option(cmdoptions.build_options()) + self.cmd_opts.add_option(cmdoptions.global_options()) + + self.cmd_opts.add_option( + "--pre", + action="store_true", + default=False, + help=( + "Include pre-release and development versions. By default, " + "pip only finds stable versions." + ), + ) + + self.cmd_opts.add_option(cmdoptions.require_hashes()) + + index_opts = cmdoptions.make_option_group( + cmdoptions.index_group, + self.parser, + ) + + self.parser.insert_option_group(0, index_opts) + self.parser.insert_option_group(0, self.cmd_opts) + + @with_cleanup + def run(self, options: Values, args: List[str]) -> int: + session = self.get_default_session(options) + + finder = self._build_package_finder(options, session) + + options.wheel_dir = normalize_path(options.wheel_dir) + ensure_dir(options.wheel_dir) + + build_tracker = self.enter_context(get_build_tracker()) + + directory = TempDirectory( + delete=not options.no_clean, + kind="wheel", + globally_managed=True, + ) + + reqs = self.get_requirements(args, options, finder, session) + check_legacy_setup_py_options(options, reqs) + + wheel_cache = WheelCache(options.cache_dir) + + preparer = self.make_requirement_preparer( + temp_build_dir=directory, + options=options, + build_tracker=build_tracker, + session=session, + finder=finder, + download_dir=options.wheel_dir, + use_user_site=False, + verbosity=self.verbosity, + ) + + resolver = self.make_resolver( + preparer=preparer, + finder=finder, + options=options, + wheel_cache=wheel_cache, + ignore_requires_python=options.ignore_requires_python, + use_pep517=options.use_pep517, + ) + + self.trace_basic_info(finder) + + requirement_set = resolver.resolve(reqs, check_supported_wheels=True) + + reqs_to_build: List[InstallRequirement] = [] + for req in requirement_set.requirements.values(): + if req.is_wheel: + preparer.save_linked_requirement(req) + else: + reqs_to_build.append(req) + + preparer.prepare_linked_requirements_more(requirement_set.requirements.values()) + + # build wheels + build_successes, build_failures = build( + reqs_to_build, + wheel_cache=wheel_cache, + verify=(not options.no_verify), + build_options=options.build_options or [], + global_options=options.global_options or [], + ) + for req in build_successes: + assert req.link and req.link.is_wheel + assert req.local_file_path + # copy from cache to target directory + try: + shutil.copy(req.local_file_path, options.wheel_dir) + except OSError as e: + logger.warning( + "Building wheel for %s failed: %s", + req.name, + e, + ) + build_failures.append(req) + if len(build_failures) != 0: + raise CommandError("Failed to build one or more wheels") + + return SUCCESS diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/configuration.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/configuration.py new file mode 100644 index 0000000000000000000000000000000000000000..ffeda1d47a1bd2ec27174ed46b34ce9c7676bbca --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/configuration.py @@ -0,0 +1,383 @@ +"""Configuration management setup + +Some terminology: +- name + As written in config files. +- value + Value associated with a name +- key + Name combined with it's section (section.name) +- variant + A single word describing where the configuration key-value pair came from +""" + +import configparser +import locale +import os +import sys +from typing import Any, Dict, Iterable, List, NewType, Optional, Tuple + +from pip._internal.exceptions import ( + ConfigurationError, + ConfigurationFileCouldNotBeLoaded, +) +from pip._internal.utils import appdirs +from pip._internal.utils.compat import WINDOWS +from pip._internal.utils.logging import getLogger +from pip._internal.utils.misc import ensure_dir, enum + +RawConfigParser = configparser.RawConfigParser # Shorthand +Kind = NewType("Kind", str) + +CONFIG_BASENAME = "pip.ini" if WINDOWS else "pip.conf" +ENV_NAMES_IGNORED = "version", "help" + +# The kinds of configurations there are. +kinds = enum( + USER="user", # User Specific + GLOBAL="global", # System Wide + SITE="site", # [Virtual] Environment Specific + ENV="env", # from PIP_CONFIG_FILE + ENV_VAR="env-var", # from Environment Variables +) +OVERRIDE_ORDER = kinds.GLOBAL, kinds.USER, kinds.SITE, kinds.ENV, kinds.ENV_VAR +VALID_LOAD_ONLY = kinds.USER, kinds.GLOBAL, kinds.SITE + +logger = getLogger(__name__) + + +# NOTE: Maybe use the optionx attribute to normalize keynames. +def _normalize_name(name: str) -> str: + """Make a name consistent regardless of source (environment or file)""" + name = name.lower().replace("_", "-") + if name.startswith("--"): + name = name[2:] # only prefer long opts + return name + + +def _disassemble_key(name: str) -> List[str]: + if "." not in name: + error_message = ( + "Key does not contain dot separated section and key. " + f"Perhaps you wanted to use 'global.{name}' instead?" + ) + raise ConfigurationError(error_message) + return name.split(".", 1) + + +def get_configuration_files() -> Dict[Kind, List[str]]: + global_config_files = [ + os.path.join(path, CONFIG_BASENAME) for path in appdirs.site_config_dirs("pip") + ] + + site_config_file = os.path.join(sys.prefix, CONFIG_BASENAME) + legacy_config_file = os.path.join( + os.path.expanduser("~"), + "pip" if WINDOWS else ".pip", + CONFIG_BASENAME, + ) + new_config_file = os.path.join(appdirs.user_config_dir("pip"), CONFIG_BASENAME) + return { + kinds.GLOBAL: global_config_files, + kinds.SITE: [site_config_file], + kinds.USER: [legacy_config_file, new_config_file], + } + + +class Configuration: + """Handles management of configuration. + + Provides an interface to accessing and managing configuration files. + + This class converts provides an API that takes "section.key-name" style + keys and stores the value associated with it as "key-name" under the + section "section". + + This allows for a clean interface wherein the both the section and the + key-name are preserved in an easy to manage form in the configuration files + and the data stored is also nice. + """ + + def __init__(self, isolated: bool, load_only: Optional[Kind] = None) -> None: + super().__init__() + + if load_only is not None and load_only not in VALID_LOAD_ONLY: + raise ConfigurationError( + "Got invalid value for load_only - should be one of {}".format( + ", ".join(map(repr, VALID_LOAD_ONLY)) + ) + ) + self.isolated = isolated + self.load_only = load_only + + # Because we keep track of where we got the data from + self._parsers: Dict[Kind, List[Tuple[str, RawConfigParser]]] = { + variant: [] for variant in OVERRIDE_ORDER + } + self._config: Dict[Kind, Dict[str, Any]] = { + variant: {} for variant in OVERRIDE_ORDER + } + self._modified_parsers: List[Tuple[str, RawConfigParser]] = [] + + def load(self) -> None: + """Loads configuration from configuration files and environment""" + self._load_config_files() + if not self.isolated: + self._load_environment_vars() + + def get_file_to_edit(self) -> Optional[str]: + """Returns the file with highest priority in configuration""" + assert self.load_only is not None, "Need to be specified a file to be editing" + + try: + return self._get_parser_to_modify()[0] + except IndexError: + return None + + def items(self) -> Iterable[Tuple[str, Any]]: + """Returns key-value pairs like dict.items() representing the loaded + configuration + """ + return self._dictionary.items() + + def get_value(self, key: str) -> Any: + """Get a value from the configuration.""" + orig_key = key + key = _normalize_name(key) + try: + return self._dictionary[key] + except KeyError: + # disassembling triggers a more useful error message than simply + # "No such key" in the case that the key isn't in the form command.option + _disassemble_key(key) + raise ConfigurationError(f"No such key - {orig_key}") + + def set_value(self, key: str, value: Any) -> None: + """Modify a value in the configuration.""" + key = _normalize_name(key) + self._ensure_have_load_only() + + assert self.load_only + fname, parser = self._get_parser_to_modify() + + if parser is not None: + section, name = _disassemble_key(key) + + # Modify the parser and the configuration + if not parser.has_section(section): + parser.add_section(section) + parser.set(section, name, value) + + self._config[self.load_only][key] = value + self._mark_as_modified(fname, parser) + + def unset_value(self, key: str) -> None: + """Unset a value in the configuration.""" + orig_key = key + key = _normalize_name(key) + self._ensure_have_load_only() + + assert self.load_only + if key not in self._config[self.load_only]: + raise ConfigurationError(f"No such key - {orig_key}") + + fname, parser = self._get_parser_to_modify() + + if parser is not None: + section, name = _disassemble_key(key) + if not ( + parser.has_section(section) and parser.remove_option(section, name) + ): + # The option was not removed. + raise ConfigurationError( + "Fatal Internal error [id=1]. Please report as a bug." + ) + + # The section may be empty after the option was removed. + if not parser.items(section): + parser.remove_section(section) + self._mark_as_modified(fname, parser) + + del self._config[self.load_only][key] + + def save(self) -> None: + """Save the current in-memory state.""" + self._ensure_have_load_only() + + for fname, parser in self._modified_parsers: + logger.info("Writing to %s", fname) + + # Ensure directory exists. + ensure_dir(os.path.dirname(fname)) + + # Ensure directory's permission(need to be writeable) + try: + with open(fname, "w") as f: + parser.write(f) + except OSError as error: + raise ConfigurationError( + f"An error occurred while writing to the configuration file " + f"{fname}: {error}" + ) + + # + # Private routines + # + + def _ensure_have_load_only(self) -> None: + if self.load_only is None: + raise ConfigurationError("Needed a specific file to be modifying.") + logger.debug("Will be working with %s variant only", self.load_only) + + @property + def _dictionary(self) -> Dict[str, Any]: + """A dictionary representing the loaded configuration.""" + # NOTE: Dictionaries are not populated if not loaded. So, conditionals + # are not needed here. + retval = {} + + for variant in OVERRIDE_ORDER: + retval.update(self._config[variant]) + + return retval + + def _load_config_files(self) -> None: + """Loads configuration from configuration files""" + config_files = dict(self.iter_config_files()) + if config_files[kinds.ENV][0:1] == [os.devnull]: + logger.debug( + "Skipping loading configuration files due to " + "environment's PIP_CONFIG_FILE being os.devnull" + ) + return + + for variant, files in config_files.items(): + for fname in files: + # If there's specific variant set in `load_only`, load only + # that variant, not the others. + if self.load_only is not None and variant != self.load_only: + logger.debug("Skipping file '%s' (variant: %s)", fname, variant) + continue + + parser = self._load_file(variant, fname) + + # Keeping track of the parsers used + self._parsers[variant].append((fname, parser)) + + def _load_file(self, variant: Kind, fname: str) -> RawConfigParser: + logger.verbose("For variant '%s', will try loading '%s'", variant, fname) + parser = self._construct_parser(fname) + + for section in parser.sections(): + items = parser.items(section) + self._config[variant].update(self._normalized_keys(section, items)) + + return parser + + def _construct_parser(self, fname: str) -> RawConfigParser: + parser = configparser.RawConfigParser() + # If there is no such file, don't bother reading it but create the + # parser anyway, to hold the data. + # Doing this is useful when modifying and saving files, where we don't + # need to construct a parser. + if os.path.exists(fname): + locale_encoding = locale.getpreferredencoding(False) + try: + parser.read(fname, encoding=locale_encoding) + except UnicodeDecodeError: + # See https://github.com/pypa/pip/issues/4963 + raise ConfigurationFileCouldNotBeLoaded( + reason=f"contains invalid {locale_encoding} characters", + fname=fname, + ) + except configparser.Error as error: + # See https://github.com/pypa/pip/issues/4893 + raise ConfigurationFileCouldNotBeLoaded(error=error) + return parser + + def _load_environment_vars(self) -> None: + """Loads configuration from environment variables""" + self._config[kinds.ENV_VAR].update( + self._normalized_keys(":env:", self.get_environ_vars()) + ) + + def _normalized_keys( + self, section: str, items: Iterable[Tuple[str, Any]] + ) -> Dict[str, Any]: + """Normalizes items to construct a dictionary with normalized keys. + + This routine is where the names become keys and are made the same + regardless of source - configuration files or environment. + """ + normalized = {} + for name, val in items: + key = section + "." + _normalize_name(name) + normalized[key] = val + return normalized + + def get_environ_vars(self) -> Iterable[Tuple[str, str]]: + """Returns a generator with all environmental vars with prefix PIP_""" + for key, val in os.environ.items(): + if key.startswith("PIP_"): + name = key[4:].lower() + if name not in ENV_NAMES_IGNORED: + yield name, val + + # XXX: This is patched in the tests. + def iter_config_files(self) -> Iterable[Tuple[Kind, List[str]]]: + """Yields variant and configuration files associated with it. + + This should be treated like items of a dictionary. The order + here doesn't affect what gets overridden. That is controlled + by OVERRIDE_ORDER. However this does control the order they are + displayed to the user. It's probably most ergonomic to display + things in the same order as OVERRIDE_ORDER + """ + # SMELL: Move the conditions out of this function + + env_config_file = os.environ.get("PIP_CONFIG_FILE", None) + config_files = get_configuration_files() + + yield kinds.GLOBAL, config_files[kinds.GLOBAL] + + # per-user config is not loaded when env_config_file exists + should_load_user_config = not self.isolated and not ( + env_config_file and os.path.exists(env_config_file) + ) + if should_load_user_config: + # The legacy config file is overridden by the new config file + yield kinds.USER, config_files[kinds.USER] + + # virtualenv config + yield kinds.SITE, config_files[kinds.SITE] + + if env_config_file is not None: + yield kinds.ENV, [env_config_file] + else: + yield kinds.ENV, [] + + def get_values_in_config(self, variant: Kind) -> Dict[str, Any]: + """Get values present in a config file""" + return self._config[variant] + + def _get_parser_to_modify(self) -> Tuple[str, RawConfigParser]: + # Determine which parser to modify + assert self.load_only + parsers = self._parsers[self.load_only] + if not parsers: + # This should not happen if everything works correctly. + raise ConfigurationError( + "Fatal Internal error [id=2]. Please report as a bug." + ) + + # Use the highest priority parser. + return parsers[-1] + + # XXX: This is patched in the tests. + def _mark_as_modified(self, fname: str, parser: RawConfigParser) -> None: + file_parser_tuple = (fname, parser) + if file_parser_tuple not in self._modified_parsers: + self._modified_parsers.append(file_parser_tuple) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self._dictionary!r})" diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9a89a838b9a5cb264e9ae9d269fbedca6e2d6333 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/__init__.py @@ -0,0 +1,21 @@ +from pip._internal.distributions.base import AbstractDistribution +from pip._internal.distributions.sdist import SourceDistribution +from pip._internal.distributions.wheel import WheelDistribution +from pip._internal.req.req_install import InstallRequirement + + +def make_distribution_for_install_requirement( + install_req: InstallRequirement, +) -> AbstractDistribution: + """Returns a Distribution for the given InstallRequirement""" + # Editable requirements will always be source distributions. They use the + # legacy logic until we create a modern standard for them. + if install_req.editable: + return SourceDistribution(install_req) + + # If it's a wheel, it's a WheelDistribution + if install_req.is_wheel: + return WheelDistribution(install_req) + + # Otherwise, a SourceDistribution + return SourceDistribution(install_req) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/base.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/base.py new file mode 100644 index 0000000000000000000000000000000000000000..6e4d0c91a901c46ab20be813af083cd19809318a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/base.py @@ -0,0 +1,53 @@ +import abc +from typing import TYPE_CHECKING, Optional + +from pip._internal.metadata.base import BaseDistribution +from pip._internal.req import InstallRequirement + +if TYPE_CHECKING: + from pip._internal.index.package_finder import PackageFinder + + +class AbstractDistribution(metaclass=abc.ABCMeta): + """A base class for handling installable artifacts. + + The requirements for anything installable are as follows: + + - we must be able to determine the requirement name + (or we can't correctly handle the non-upgrade case). + + - for packages with setup requirements, we must also be able + to determine their requirements without installing additional + packages (for the same reason as run-time dependencies) + + - we must be able to create a Distribution object exposing the + above metadata. + + - if we need to do work in the build tracker, we must be able to generate a unique + string to identify the requirement in the build tracker. + """ + + def __init__(self, req: InstallRequirement) -> None: + super().__init__() + self.req = req + + @abc.abstractproperty + def build_tracker_id(self) -> Optional[str]: + """A string that uniquely identifies this requirement to the build tracker. + + If None, then this dist has no work to do in the build tracker, and + ``.prepare_distribution_metadata()`` will not be called.""" + raise NotImplementedError() + + @abc.abstractmethod + def get_metadata_distribution(self) -> BaseDistribution: + raise NotImplementedError() + + @abc.abstractmethod + def prepare_distribution_metadata( + self, + finder: "PackageFinder", + build_isolation: bool, + check_build_deps: bool, + ) -> None: + raise NotImplementedError() diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/installed.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/installed.py new file mode 100644 index 0000000000000000000000000000000000000000..ab8d53be7408626719c27aa29fdc2e143b7c380a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/installed.py @@ -0,0 +1,29 @@ +from typing import Optional + +from pip._internal.distributions.base import AbstractDistribution +from pip._internal.index.package_finder import PackageFinder +from pip._internal.metadata import BaseDistribution + + +class InstalledDistribution(AbstractDistribution): + """Represents an installed package. + + This does not need any preparation as the required information has already + been computed. + """ + + @property + def build_tracker_id(self) -> Optional[str]: + return None + + def get_metadata_distribution(self) -> BaseDistribution: + assert self.req.satisfied_by is not None, "not actually installed" + return self.req.satisfied_by + + def prepare_distribution_metadata( + self, + finder: PackageFinder, + build_isolation: bool, + check_build_deps: bool, + ) -> None: + pass diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/sdist.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/sdist.py new file mode 100644 index 0000000000000000000000000000000000000000..28ea5cea16cdf9b740809553cbf2d3bf8d626e1e --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/sdist.py @@ -0,0 +1,158 @@ +import logging +from typing import TYPE_CHECKING, Iterable, Optional, Set, Tuple + +from pip._internal.build_env import BuildEnvironment +from pip._internal.distributions.base import AbstractDistribution +from pip._internal.exceptions import InstallationError +from pip._internal.metadata import BaseDistribution +from pip._internal.utils.subprocess import runner_with_spinner_message + +if TYPE_CHECKING: + from pip._internal.index.package_finder import PackageFinder + +logger = logging.getLogger(__name__) + + +class SourceDistribution(AbstractDistribution): + """Represents a source distribution. + + The preparation step for these needs metadata for the packages to be + generated, either using PEP 517 or using the legacy `setup.py egg_info`. + """ + + @property + def build_tracker_id(self) -> Optional[str]: + """Identify this requirement uniquely by its link.""" + assert self.req.link + return self.req.link.url_without_fragment + + def get_metadata_distribution(self) -> BaseDistribution: + return self.req.get_dist() + + def prepare_distribution_metadata( + self, + finder: "PackageFinder", + build_isolation: bool, + check_build_deps: bool, + ) -> None: + # Load pyproject.toml, to determine whether PEP 517 is to be used + self.req.load_pyproject_toml() + + # Set up the build isolation, if this requirement should be isolated + should_isolate = self.req.use_pep517 and build_isolation + if should_isolate: + # Setup an isolated environment and install the build backend static + # requirements in it. + self._prepare_build_backend(finder) + # Check that if the requirement is editable, it either supports PEP 660 or + # has a setup.py or a setup.cfg. This cannot be done earlier because we need + # to setup the build backend to verify it supports build_editable, nor can + # it be done later, because we want to avoid installing build requirements + # needlessly. Doing it here also works around setuptools generating + # UNKNOWN.egg-info when running get_requires_for_build_wheel on a directory + # without setup.py nor setup.cfg. + self.req.isolated_editable_sanity_check() + # Install the dynamic build requirements. + self._install_build_reqs(finder) + # Check if the current environment provides build dependencies + should_check_deps = self.req.use_pep517 and check_build_deps + if should_check_deps: + pyproject_requires = self.req.pyproject_requires + assert pyproject_requires is not None + conflicting, missing = self.req.build_env.check_requirements( + pyproject_requires + ) + if conflicting: + self._raise_conflicts("the backend dependencies", conflicting) + if missing: + self._raise_missing_reqs(missing) + self.req.prepare_metadata() + + def _prepare_build_backend(self, finder: "PackageFinder") -> None: + # Isolate in a BuildEnvironment and install the build-time + # requirements. + pyproject_requires = self.req.pyproject_requires + assert pyproject_requires is not None + + self.req.build_env = BuildEnvironment() + self.req.build_env.install_requirements( + finder, pyproject_requires, "overlay", kind="build dependencies" + ) + conflicting, missing = self.req.build_env.check_requirements( + self.req.requirements_to_check + ) + if conflicting: + self._raise_conflicts("PEP 517/518 supported requirements", conflicting) + if missing: + logger.warning( + "Missing build requirements in pyproject.toml for %s.", + self.req, + ) + logger.warning( + "The project does not specify a build backend, and " + "pip cannot fall back to setuptools without %s.", + " and ".join(map(repr, sorted(missing))), + ) + + def _get_build_requires_wheel(self) -> Iterable[str]: + with self.req.build_env: + runner = runner_with_spinner_message("Getting requirements to build wheel") + backend = self.req.pep517_backend + assert backend is not None + with backend.subprocess_runner(runner): + return backend.get_requires_for_build_wheel() + + def _get_build_requires_editable(self) -> Iterable[str]: + with self.req.build_env: + runner = runner_with_spinner_message( + "Getting requirements to build editable" + ) + backend = self.req.pep517_backend + assert backend is not None + with backend.subprocess_runner(runner): + return backend.get_requires_for_build_editable() + + def _install_build_reqs(self, finder: "PackageFinder") -> None: + # Install any extra build dependencies that the backend requests. + # This must be done in a second pass, as the pyproject.toml + # dependencies must be installed before we can call the backend. + if ( + self.req.editable + and self.req.permit_editable_wheels + and self.req.supports_pyproject_editable + ): + build_reqs = self._get_build_requires_editable() + else: + build_reqs = self._get_build_requires_wheel() + conflicting, missing = self.req.build_env.check_requirements(build_reqs) + if conflicting: + self._raise_conflicts("the backend dependencies", conflicting) + self.req.build_env.install_requirements( + finder, missing, "normal", kind="backend dependencies" + ) + + def _raise_conflicts( + self, conflicting_with: str, conflicting_reqs: Set[Tuple[str, str]] + ) -> None: + format_string = ( + "Some build dependencies for {requirement} " + "conflict with {conflicting_with}: {description}." + ) + error_message = format_string.format( + requirement=self.req, + conflicting_with=conflicting_with, + description=", ".join( + f"{installed} is incompatible with {wanted}" + for installed, wanted in sorted(conflicting_reqs) + ), + ) + raise InstallationError(error_message) + + def _raise_missing_reqs(self, missing: Set[str]) -> None: + format_string = ( + "Some build dependencies for {requirement} are missing: {missing}." + ) + error_message = format_string.format( + requirement=self.req, missing=", ".join(map(repr, sorted(missing))) + ) + raise InstallationError(error_message) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/wheel.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/wheel.py new file mode 100644 index 0000000000000000000000000000000000000000..bfadd39dcb77dfdaa2cca24e8a6db7e5beac181e --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/distributions/wheel.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING, Optional + +from pip._vendor.packaging.utils import canonicalize_name + +from pip._internal.distributions.base import AbstractDistribution +from pip._internal.metadata import ( + BaseDistribution, + FilesystemWheel, + get_wheel_distribution, +) + +if TYPE_CHECKING: + from pip._internal.index.package_finder import PackageFinder + + +class WheelDistribution(AbstractDistribution): + """Represents a wheel distribution. + + This does not need any preparation as wheels can be directly unpacked. + """ + + @property + def build_tracker_id(self) -> Optional[str]: + return None + + def get_metadata_distribution(self) -> BaseDistribution: + """Loads the metadata from the wheel file into memory and returns a + Distribution that uses it, not relying on the wheel file or + requirement. + """ + assert self.req.local_file_path, "Set as part of preparation during download" + assert self.req.name, "Wheels are never unnamed" + wheel = FilesystemWheel(self.req.local_file_path) + return get_wheel_distribution(wheel, canonicalize_name(self.req.name)) + + def prepare_distribution_metadata( + self, + finder: "PackageFinder", + build_isolation: bool, + check_build_deps: bool, + ) -> None: + pass diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/exceptions.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..4fe4aadef2f037d0cbad0c7dfd0f3a28aee2d981 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/exceptions.py @@ -0,0 +1,862 @@ +"""Exceptions used throughout package. + +This module MUST NOT try to import from anything within `pip._internal` to +operate. This is expected to be importable from any/all files within the +subpackage and, thus, should not depend on them. +""" + +import configparser +import contextlib +import locale +import logging +import pathlib +import re +import sys +from itertools import chain, groupby, repeat +from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union + +from pip._vendor.packaging.requirements import InvalidRequirement +from pip._vendor.packaging.version import InvalidVersion +from pip._vendor.rich.console import Console, ConsoleOptions, RenderResult +from pip._vendor.rich.markup import escape +from pip._vendor.rich.text import Text + +if TYPE_CHECKING: + from hashlib import _Hash + + from pip._vendor.requests.models import Request, Response + + from pip._internal.metadata import BaseDistribution + from pip._internal.models.link import Link + from pip._internal.req.req_install import InstallRequirement + +logger = logging.getLogger(__name__) + + +# +# Scaffolding +# +def _is_kebab_case(s: str) -> bool: + return re.match(r"^[a-z]+(-[a-z]+)*$", s) is not None + + +def _prefix_with_indent( + s: Union[Text, str], + console: Console, + *, + prefix: str, + indent: str, +) -> Text: + if isinstance(s, Text): + text = s + else: + text = console.render_str(s) + + return console.render_str(prefix, overflow="ignore") + console.render_str( + f"\n{indent}", overflow="ignore" + ).join(text.split(allow_blank=True)) + + +class PipError(Exception): + """The base pip error.""" + + +class DiagnosticPipError(PipError): + """An error, that presents diagnostic information to the user. + + This contains a bunch of logic, to enable pretty presentation of our error + messages. Each error gets a unique reference. Each error can also include + additional context, a hint and/or a note -- which are presented with the + main error message in a consistent style. + + This is adapted from the error output styling in `sphinx-theme-builder`. + """ + + reference: str + + def __init__( + self, + *, + kind: 'Literal["error", "warning"]' = "error", + reference: Optional[str] = None, + message: Union[str, Text], + context: Optional[Union[str, Text]], + hint_stmt: Optional[Union[str, Text]], + note_stmt: Optional[Union[str, Text]] = None, + link: Optional[str] = None, + ) -> None: + # Ensure a proper reference is provided. + if reference is None: + assert hasattr(self, "reference"), "error reference not provided!" + reference = self.reference + assert _is_kebab_case(reference), "error reference must be kebab-case!" + + self.kind = kind + self.reference = reference + + self.message = message + self.context = context + + self.note_stmt = note_stmt + self.hint_stmt = hint_stmt + + self.link = link + + super().__init__(f"<{self.__class__.__name__}: {self.reference}>") + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__}(" + f"reference={self.reference!r}, " + f"message={self.message!r}, " + f"context={self.context!r}, " + f"note_stmt={self.note_stmt!r}, " + f"hint_stmt={self.hint_stmt!r}" + ")>" + ) + + def __rich_console__( + self, + console: Console, + options: ConsoleOptions, + ) -> RenderResult: + colour = "red" if self.kind == "error" else "yellow" + + yield f"[{colour} bold]{self.kind}[/]: [bold]{self.reference}[/]" + yield "" + + if not options.ascii_only: + # Present the main message, with relevant context indented. + if self.context is not None: + yield _prefix_with_indent( + self.message, + console, + prefix=f"[{colour}]ร—[/] ", + indent=f"[{colour}]โ”‚[/] ", + ) + yield _prefix_with_indent( + self.context, + console, + prefix=f"[{colour}]โ•ฐโ”€>[/] ", + indent=f"[{colour}] [/] ", + ) + else: + yield _prefix_with_indent( + self.message, + console, + prefix="[red]ร—[/] ", + indent=" ", + ) + else: + yield self.message + if self.context is not None: + yield "" + yield self.context + + if self.note_stmt is not None or self.hint_stmt is not None: + yield "" + + if self.note_stmt is not None: + yield _prefix_with_indent( + self.note_stmt, + console, + prefix="[magenta bold]note[/]: ", + indent=" ", + ) + if self.hint_stmt is not None: + yield _prefix_with_indent( + self.hint_stmt, + console, + prefix="[cyan bold]hint[/]: ", + indent=" ", + ) + + if self.link is not None: + yield "" + yield f"Link: {self.link}" + + +# +# Actual Errors +# +class ConfigurationError(PipError): + """General exception in configuration""" + + +class InstallationError(PipError): + """General exception during installation""" + + +class MissingPyProjectBuildRequires(DiagnosticPipError): + """Raised when pyproject.toml has `build-system`, but no `build-system.requires`.""" + + reference = "missing-pyproject-build-system-requires" + + def __init__(self, *, package: str) -> None: + super().__init__( + message=f"Can not process {escape(package)}", + context=Text( + "This package has an invalid pyproject.toml file.\n" + "The [build-system] table is missing the mandatory `requires` key." + ), + note_stmt="This is an issue with the package mentioned above, not pip.", + hint_stmt=Text("See PEP 518 for the detailed specification."), + ) + + +class InvalidPyProjectBuildRequires(DiagnosticPipError): + """Raised when pyproject.toml an invalid `build-system.requires`.""" + + reference = "invalid-pyproject-build-system-requires" + + def __init__(self, *, package: str, reason: str) -> None: + super().__init__( + message=f"Can not process {escape(package)}", + context=Text( + "This package has an invalid `build-system.requires` key in " + f"pyproject.toml.\n{reason}" + ), + note_stmt="This is an issue with the package mentioned above, not pip.", + hint_stmt=Text("See PEP 518 for the detailed specification."), + ) + + +class NoneMetadataError(PipError): + """Raised when accessing a Distribution's "METADATA" or "PKG-INFO". + + This signifies an inconsistency, when the Distribution claims to have + the metadata file (if not, raise ``FileNotFoundError`` instead), but is + not actually able to produce its content. This may be due to permission + errors. + """ + + def __init__( + self, + dist: "BaseDistribution", + metadata_name: str, + ) -> None: + """ + :param dist: A Distribution object. + :param metadata_name: The name of the metadata being accessed + (can be "METADATA" or "PKG-INFO"). + """ + self.dist = dist + self.metadata_name = metadata_name + + def __str__(self) -> str: + # Use `dist` in the error message because its stringification + # includes more information, like the version and location. + return f"None {self.metadata_name} metadata found for distribution: {self.dist}" + + +class UserInstallationInvalid(InstallationError): + """A --user install is requested on an environment without user site.""" + + def __str__(self) -> str: + return "User base directory is not specified" + + +class InvalidSchemeCombination(InstallationError): + def __str__(self) -> str: + before = ", ".join(str(a) for a in self.args[:-1]) + return f"Cannot set {before} and {self.args[-1]} together" + + +class DistributionNotFound(InstallationError): + """Raised when a distribution cannot be found to satisfy a requirement""" + + +class RequirementsFileParseError(InstallationError): + """Raised when a general error occurs parsing a requirements file line.""" + + +class BestVersionAlreadyInstalled(PipError): + """Raised when the most up-to-date version of a package is already + installed.""" + + +class BadCommand(PipError): + """Raised when virtualenv or a command is not found""" + + +class CommandError(PipError): + """Raised when there is an error in command-line arguments""" + + +class PreviousBuildDirError(PipError): + """Raised when there's a previous conflicting build directory""" + + +class NetworkConnectionError(PipError): + """HTTP connection error""" + + def __init__( + self, + error_msg: str, + response: Optional["Response"] = None, + request: Optional["Request"] = None, + ) -> None: + """ + Initialize NetworkConnectionError with `request` and `response` + objects. + """ + self.response = response + self.request = request + self.error_msg = error_msg + if ( + self.response is not None + and not self.request + and hasattr(response, "request") + ): + self.request = self.response.request + super().__init__(error_msg, response, request) + + def __str__(self) -> str: + return str(self.error_msg) + + +class InvalidWheelFilename(InstallationError): + """Invalid wheel filename.""" + + +class UnsupportedWheel(InstallationError): + """Unsupported wheel.""" + + +class InvalidWheel(InstallationError): + """Invalid (e.g. corrupt) wheel.""" + + def __init__(self, location: str, name: str): + self.location = location + self.name = name + + def __str__(self) -> str: + return f"Wheel '{self.name}' located at {self.location} is invalid." + + +class MetadataInconsistent(InstallationError): + """Built metadata contains inconsistent information. + + This is raised when the metadata contains values (e.g. name and version) + that do not match the information previously obtained from sdist filename, + user-supplied ``#egg=`` value, or an install requirement name. + """ + + def __init__( + self, ireq: "InstallRequirement", field: str, f_val: str, m_val: str + ) -> None: + self.ireq = ireq + self.field = field + self.f_val = f_val + self.m_val = m_val + + def __str__(self) -> str: + return ( + f"Requested {self.ireq} has inconsistent {self.field}: " + f"expected {self.f_val!r}, but metadata has {self.m_val!r}" + ) + + +class MetadataInvalid(InstallationError): + """Metadata is invalid.""" + + def __init__(self, ireq: "InstallRequirement", error: str) -> None: + self.ireq = ireq + self.error = error + + def __str__(self) -> str: + return f"Requested {self.ireq} has invalid metadata: {self.error}" + + +class InstallationSubprocessError(DiagnosticPipError, InstallationError): + """A subprocess call failed.""" + + reference = "subprocess-exited-with-error" + + def __init__( + self, + *, + command_description: str, + exit_code: int, + output_lines: Optional[List[str]], + ) -> None: + if output_lines is None: + output_prompt = Text("See above for output.") + else: + output_prompt = ( + Text.from_markup(f"[red][{len(output_lines)} lines of output][/]\n") + + Text("".join(output_lines)) + + Text.from_markup(R"[red]\[end of output][/]") + ) + + super().__init__( + message=( + f"[green]{escape(command_description)}[/] did not run successfully.\n" + f"exit code: {exit_code}" + ), + context=output_prompt, + hint_stmt=None, + note_stmt=( + "This error originates from a subprocess, and is likely not a " + "problem with pip." + ), + ) + + self.command_description = command_description + self.exit_code = exit_code + + def __str__(self) -> str: + return f"{self.command_description} exited with {self.exit_code}" + + +class MetadataGenerationFailed(InstallationSubprocessError, InstallationError): + reference = "metadata-generation-failed" + + def __init__( + self, + *, + package_details: str, + ) -> None: + super(InstallationSubprocessError, self).__init__( + message="Encountered error while generating package metadata.", + context=escape(package_details), + hint_stmt="See above for details.", + note_stmt="This is an issue with the package mentioned above, not pip.", + ) + + def __str__(self) -> str: + return "metadata generation failed" + + +class HashErrors(InstallationError): + """Multiple HashError instances rolled into one for reporting""" + + def __init__(self) -> None: + self.errors: List[HashError] = [] + + def append(self, error: "HashError") -> None: + self.errors.append(error) + + def __str__(self) -> str: + lines = [] + self.errors.sort(key=lambda e: e.order) + for cls, errors_of_cls in groupby(self.errors, lambda e: e.__class__): + lines.append(cls.head) + lines.extend(e.body() for e in errors_of_cls) + if lines: + return "\n".join(lines) + return "" + + def __bool__(self) -> bool: + return bool(self.errors) + + +class HashError(InstallationError): + """ + A failure to verify a package against known-good hashes + + :cvar order: An int sorting hash exception classes by difficulty of + recovery (lower being harder), so the user doesn't bother fretting + about unpinned packages when he has deeper issues, like VCS + dependencies, to deal with. Also keeps error reports in a + deterministic order. + :cvar head: A section heading for display above potentially many + exceptions of this kind + :ivar req: The InstallRequirement that triggered this error. This is + pasted on after the exception is instantiated, because it's not + typically available earlier. + + """ + + req: Optional["InstallRequirement"] = None + head = "" + order: int = -1 + + def body(self) -> str: + """Return a summary of me for display under the heading. + + This default implementation simply prints a description of the + triggering requirement. + + :param req: The InstallRequirement that provoked this error, with + its link already populated by the resolver's _populate_link(). + + """ + return f" {self._requirement_name()}" + + def __str__(self) -> str: + return f"{self.head}\n{self.body()}" + + def _requirement_name(self) -> str: + """Return a description of the requirement that triggered me. + + This default implementation returns long description of the req, with + line numbers + + """ + return str(self.req) if self.req else "unknown package" + + +class VcsHashUnsupported(HashError): + """A hash was provided for a version-control-system-based requirement, but + we don't have a method for hashing those.""" + + order = 0 + head = ( + "Can't verify hashes for these requirements because we don't " + "have a way to hash version control repositories:" + ) + + +class DirectoryUrlHashUnsupported(HashError): + """A hash was provided for a version-control-system-based requirement, but + we don't have a method for hashing those.""" + + order = 1 + head = ( + "Can't verify hashes for these file:// requirements because they " + "point to directories:" + ) + + +class HashMissing(HashError): + """A hash was needed for a requirement but is absent.""" + + order = 2 + head = ( + "Hashes are required in --require-hashes mode, but they are " + "missing from some requirements. Here is a list of those " + "requirements along with the hashes their downloaded archives " + "actually had. Add lines like these to your requirements files to " + "prevent tampering. (If you did not enable --require-hashes " + "manually, note that it turns on automatically when any package " + "has a hash.)" + ) + + def __init__(self, gotten_hash: str) -> None: + """ + :param gotten_hash: The hash of the (possibly malicious) archive we + just downloaded + """ + self.gotten_hash = gotten_hash + + def body(self) -> str: + # Dodge circular import. + from pip._internal.utils.hashes import FAVORITE_HASH + + package = None + if self.req: + # In the case of URL-based requirements, display the original URL + # seen in the requirements file rather than the package name, + # so the output can be directly copied into the requirements file. + package = ( + self.req.original_link + if self.req.is_direct + # In case someone feeds something downright stupid + # to InstallRequirement's constructor. + else getattr(self.req, "req", None) + ) + return " {} --hash={}:{}".format( + package or "unknown package", FAVORITE_HASH, self.gotten_hash + ) + + +class HashUnpinned(HashError): + """A requirement had a hash specified but was not pinned to a specific + version.""" + + order = 3 + head = ( + "In --require-hashes mode, all requirements must have their " + "versions pinned with ==. These do not:" + ) + + +class HashMismatch(HashError): + """ + Distribution file hash values don't match. + + :ivar package_name: The name of the package that triggered the hash + mismatch. Feel free to write to this after the exception is raise to + improve its error message. + + """ + + order = 4 + head = ( + "THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS " + "FILE. If you have updated the package versions, please update " + "the hashes. Otherwise, examine the package contents carefully; " + "someone may have tampered with them." + ) + + def __init__(self, allowed: Dict[str, List[str]], gots: Dict[str, "_Hash"]) -> None: + """ + :param allowed: A dict of algorithm names pointing to lists of allowed + hex digests + :param gots: A dict of algorithm names pointing to hashes we + actually got from the files under suspicion + """ + self.allowed = allowed + self.gots = gots + + def body(self) -> str: + return f" {self._requirement_name()}:\n{self._hash_comparison()}" + + def _hash_comparison(self) -> str: + """ + Return a comparison of actual and expected hash values. + + Example:: + + Expected sha256 abcdeabcdeabcdeabcdeabcdeabcdeabcdeabcdeabcde + or 123451234512345123451234512345123451234512345 + Got bcdefbcdefbcdefbcdefbcdefbcdefbcdefbcdefbcdef + + """ + + def hash_then_or(hash_name: str) -> "chain[str]": + # For now, all the decent hashes have 6-char names, so we can get + # away with hard-coding space literals. + return chain([hash_name], repeat(" or")) + + lines: List[str] = [] + for hash_name, expecteds in self.allowed.items(): + prefix = hash_then_or(hash_name) + lines.extend((f" Expected {next(prefix)} {e}") for e in expecteds) + lines.append( + f" Got {self.gots[hash_name].hexdigest()}\n" + ) + return "\n".join(lines) + + +class UnsupportedPythonVersion(InstallationError): + """Unsupported python version according to Requires-Python package + metadata.""" + + +class ConfigurationFileCouldNotBeLoaded(ConfigurationError): + """When there are errors while loading a configuration file""" + + def __init__( + self, + reason: str = "could not be loaded", + fname: Optional[str] = None, + error: Optional[configparser.Error] = None, + ) -> None: + super().__init__(error) + self.reason = reason + self.fname = fname + self.error = error + + def __str__(self) -> str: + if self.fname is not None: + message_part = f" in {self.fname}." + else: + assert self.error is not None + message_part = f".\n{self.error}\n" + return f"Configuration file {self.reason}{message_part}" + + +_DEFAULT_EXTERNALLY_MANAGED_ERROR = f"""\ +The Python environment under {sys.prefix} is managed externally, and may not be +manipulated by the user. Please use specific tooling from the distributor of +the Python installation to interact with this environment instead. +""" + + +class ExternallyManagedEnvironment(DiagnosticPipError): + """The current environment is externally managed. + + This is raised when the current environment is externally managed, as + defined by `PEP 668`_. The ``EXTERNALLY-MANAGED`` configuration is checked + and displayed when the error is bubbled up to the user. + + :param error: The error message read from ``EXTERNALLY-MANAGED``. + """ + + reference = "externally-managed-environment" + + def __init__(self, error: Optional[str]) -> None: + if error is None: + context = Text(_DEFAULT_EXTERNALLY_MANAGED_ERROR) + else: + context = Text(error) + super().__init__( + message="This environment is externally managed", + context=context, + note_stmt=( + "If you believe this is a mistake, please contact your " + "Python installation or OS distribution provider. " + "You can override this, at the risk of breaking your Python " + "installation or OS, by passing --break-system-packages." + ), + hint_stmt=Text("See PEP 668 for the detailed specification."), + ) + + @staticmethod + def _iter_externally_managed_error_keys() -> Iterator[str]: + # LC_MESSAGES is in POSIX, but not the C standard. The most common + # platform that does not implement this category is Windows, where + # using other categories for console message localization is equally + # unreliable, so we fall back to the locale-less vendor message. This + # can always be re-evaluated when a vendor proposes a new alternative. + try: + category = locale.LC_MESSAGES + except AttributeError: + lang: Optional[str] = None + else: + lang, _ = locale.getlocale(category) + if lang is not None: + yield f"Error-{lang}" + for sep in ("-", "_"): + before, found, _ = lang.partition(sep) + if not found: + continue + yield f"Error-{before}" + yield "Error" + + @classmethod + def from_config( + cls, + config: Union[pathlib.Path, str], + ) -> "ExternallyManagedEnvironment": + parser = configparser.ConfigParser(interpolation=None) + try: + parser.read(config, encoding="utf-8") + section = parser["externally-managed"] + for key in cls._iter_externally_managed_error_keys(): + with contextlib.suppress(KeyError): + return cls(section[key]) + except KeyError: + pass + except (OSError, UnicodeDecodeError, configparser.ParsingError): + from pip._internal.utils._log import VERBOSE + + exc_info = logger.isEnabledFor(VERBOSE) + logger.warning("Failed to read %s", config, exc_info=exc_info) + return cls(None) + + +class UninstallMissingRecord(DiagnosticPipError): + reference = "uninstall-no-record-file" + + def __init__(self, *, distribution: "BaseDistribution") -> None: + installer = distribution.installer + if not installer or installer == "pip": + dep = f"{distribution.raw_name}=={distribution.version}" + hint = Text.assemble( + "You might be able to recover from this via: ", + (f"pip install --force-reinstall --no-deps {dep}", "green"), + ) + else: + hint = Text( + f"The package was installed by {installer}. " + "You should check if it can uninstall the package." + ) + + super().__init__( + message=Text(f"Cannot uninstall {distribution}"), + context=( + "The package's contents are unknown: " + f"no RECORD file was found for {distribution.raw_name}." + ), + hint_stmt=hint, + ) + + +class LegacyDistutilsInstall(DiagnosticPipError): + reference = "uninstall-distutils-installed-package" + + def __init__(self, *, distribution: "BaseDistribution") -> None: + super().__init__( + message=Text(f"Cannot uninstall {distribution}"), + context=( + "It is a distutils installed project and thus we cannot accurately " + "determine which files belong to it which would lead to only a partial " + "uninstall." + ), + hint_stmt=None, + ) + + +class InvalidInstalledPackage(DiagnosticPipError): + reference = "invalid-installed-package" + + def __init__( + self, + *, + dist: "BaseDistribution", + invalid_exc: Union[InvalidRequirement, InvalidVersion], + ) -> None: + installed_location = dist.installed_location + + if isinstance(invalid_exc, InvalidRequirement): + invalid_type = "requirement" + else: + invalid_type = "version" + + super().__init__( + message=Text( + f"Cannot process installed package {dist} " + + (f"in {installed_location!r} " if installed_location else "") + + f"because it has an invalid {invalid_type}:\n{invalid_exc.args[0]}" + ), + context=( + "Starting with pip 24.1, packages with invalid " + f"{invalid_type}s can not be processed." + ), + hint_stmt="To proceed this package must be uninstalled.", + ) + + +class IncompleteDownloadError(DiagnosticPipError): + """Raised when the downloader receives fewer bytes than advertised + in the Content-Length header.""" + + reference = "incomplete-download" + + def __init__( + self, link: "Link", received: int, expected: int, *, retries: int + ) -> None: + # Dodge circular import. + from pip._internal.utils.misc import format_size + + download_status = f"{format_size(received)}/{format_size(expected)}" + if retries: + retry_status = f"after {retries} attempts " + hint = "Use --resume-retries to configure resume attempt limit." + else: + retry_status = "" + hint = "Consider using --resume-retries to enable download resumption." + message = Text( + f"Download failed {retry_status}because not enough bytes " + f"were received ({download_status})" + ) + + super().__init__( + message=message, + context=f"URL: {link.redacted_url}", + hint_stmt=hint, + note_stmt="This is an issue with network connectivity, not pip.", + ) + + +class ResolutionTooDeepError(DiagnosticPipError): + """Raised when the dependency resolver exceeds the maximum recursion depth.""" + + reference = "resolution-too-deep" + + def __init__(self) -> None: + super().__init__( + message="Dependency resolution exceeded maximum depth", + context=( + "Pip cannot resolve the current dependencies as the dependency graph " + "is too complex for pip to solve efficiently." + ), + hint_stmt=( + "Try adding lower bounds to constrain your dependencies, " + "for example: 'package>=2.0.0' instead of just 'package'. " + ), + link="https://pip.pypa.io/en/stable/topics/dependency-resolution/#handling-resolution-too-deep-errors", + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..197dd757de979bf116810a678a9c07baeaa7dba1 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/__init__.py @@ -0,0 +1 @@ +"""Index interaction code""" diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/collector.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/collector.py new file mode 100644 index 0000000000000000000000000000000000000000..5f8fdee3d46271652d498cbfc865a25c50f2cab0 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/collector.py @@ -0,0 +1,494 @@ +""" +The main purpose of this module is to expose LinkCollector.collect_sources(). +""" + +import collections +import email.message +import functools +import itertools +import json +import logging +import os +import urllib.parse +import urllib.request +from dataclasses import dataclass +from html.parser import HTMLParser +from optparse import Values +from typing import ( + Callable, + Dict, + Iterable, + List, + MutableMapping, + NamedTuple, + Optional, + Protocol, + Sequence, + Tuple, + Union, +) + +from pip._vendor import requests +from pip._vendor.requests import Response +from pip._vendor.requests.exceptions import RetryError, SSLError + +from pip._internal.exceptions import NetworkConnectionError +from pip._internal.models.link import Link +from pip._internal.models.search_scope import SearchScope +from pip._internal.network.session import PipSession +from pip._internal.network.utils import raise_for_status +from pip._internal.utils.filetypes import is_archive_file +from pip._internal.utils.misc import redact_auth_from_url +from pip._internal.vcs import vcs + +from .sources import CandidatesFromPage, LinkSource, build_source + +logger = logging.getLogger(__name__) + +ResponseHeaders = MutableMapping[str, str] + + +def _match_vcs_scheme(url: str) -> Optional[str]: + """Look for VCS schemes in the URL. + + Returns the matched VCS scheme, or None if there's no match. + """ + for scheme in vcs.schemes: + if url.lower().startswith(scheme) and url[len(scheme)] in "+:": + return scheme + return None + + +class _NotAPIContent(Exception): + def __init__(self, content_type: str, request_desc: str) -> None: + super().__init__(content_type, request_desc) + self.content_type = content_type + self.request_desc = request_desc + + +def _ensure_api_header(response: Response) -> None: + """ + Check the Content-Type header to ensure the response contains a Simple + API Response. + + Raises `_NotAPIContent` if the content type is not a valid content-type. + """ + content_type = response.headers.get("Content-Type", "Unknown") + + content_type_l = content_type.lower() + if content_type_l.startswith( + ( + "text/html", + "application/vnd.pypi.simple.v1+html", + "application/vnd.pypi.simple.v1+json", + ) + ): + return + + raise _NotAPIContent(content_type, response.request.method) + + +class _NotHTTP(Exception): + pass + + +def _ensure_api_response(url: str, session: PipSession) -> None: + """ + Send a HEAD request to the URL, and ensure the response contains a simple + API Response. + + Raises `_NotHTTP` if the URL is not available for a HEAD request, or + `_NotAPIContent` if the content type is not a valid content type. + """ + scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url) + if scheme not in {"http", "https"}: + raise _NotHTTP() + + resp = session.head(url, allow_redirects=True) + raise_for_status(resp) + + _ensure_api_header(resp) + + +def _get_simple_response(url: str, session: PipSession) -> Response: + """Access an Simple API response with GET, and return the response. + + This consists of three parts: + + 1. If the URL looks suspiciously like an archive, send a HEAD first to + check the Content-Type is HTML or Simple API, to avoid downloading a + large file. Raise `_NotHTTP` if the content type cannot be determined, or + `_NotAPIContent` if it is not HTML or a Simple API. + 2. Actually perform the request. Raise HTTP exceptions on network failures. + 3. Check the Content-Type header to make sure we got a Simple API response, + and raise `_NotAPIContent` otherwise. + """ + if is_archive_file(Link(url).filename): + _ensure_api_response(url, session=session) + + logger.debug("Getting page %s", redact_auth_from_url(url)) + + resp = session.get( + url, + headers={ + "Accept": ", ".join( + [ + "application/vnd.pypi.simple.v1+json", + "application/vnd.pypi.simple.v1+html; q=0.1", + "text/html; q=0.01", + ] + ), + # We don't want to blindly returned cached data for + # /simple/, because authors generally expecting that + # twine upload && pip install will function, but if + # they've done a pip install in the last ~10 minutes + # it won't. Thus by setting this to zero we will not + # blindly use any cached data, however the benefit of + # using max-age=0 instead of no-cache, is that we will + # still support conditional requests, so we will still + # minimize traffic sent in cases where the page hasn't + # changed at all, we will just always incur the round + # trip for the conditional GET now instead of only + # once per 10 minutes. + # For more information, please see pypa/pip#5670. + "Cache-Control": "max-age=0", + }, + ) + raise_for_status(resp) + + # The check for archives above only works if the url ends with + # something that looks like an archive. However that is not a + # requirement of an url. Unless we issue a HEAD request on every + # url we cannot know ahead of time for sure if something is a + # Simple API response or not. However we can check after we've + # downloaded it. + _ensure_api_header(resp) + + logger.debug( + "Fetched page %s as %s", + redact_auth_from_url(url), + resp.headers.get("Content-Type", "Unknown"), + ) + + return resp + + +def _get_encoding_from_headers(headers: ResponseHeaders) -> Optional[str]: + """Determine if we have any encoding information in our headers.""" + if headers and "Content-Type" in headers: + m = email.message.Message() + m["content-type"] = headers["Content-Type"] + charset = m.get_param("charset") + if charset: + return str(charset) + return None + + +class CacheablePageContent: + def __init__(self, page: "IndexContent") -> None: + assert page.cache_link_parsing + self.page = page + + def __eq__(self, other: object) -> bool: + return isinstance(other, type(self)) and self.page.url == other.page.url + + def __hash__(self) -> int: + return hash(self.page.url) + + +class ParseLinks(Protocol): + def __call__(self, page: "IndexContent") -> Iterable[Link]: ... + + +def with_cached_index_content(fn: ParseLinks) -> ParseLinks: + """ + Given a function that parses an Iterable[Link] from an IndexContent, cache the + function's result (keyed by CacheablePageContent), unless the IndexContent + `page` has `page.cache_link_parsing == False`. + """ + + @functools.lru_cache(maxsize=None) + def wrapper(cacheable_page: CacheablePageContent) -> List[Link]: + return list(fn(cacheable_page.page)) + + @functools.wraps(fn) + def wrapper_wrapper(page: "IndexContent") -> List[Link]: + if page.cache_link_parsing: + return wrapper(CacheablePageContent(page)) + return list(fn(page)) + + return wrapper_wrapper + + +@with_cached_index_content +def parse_links(page: "IndexContent") -> Iterable[Link]: + """ + Parse a Simple API's Index Content, and yield its anchor elements as Link objects. + """ + + content_type_l = page.content_type.lower() + if content_type_l.startswith("application/vnd.pypi.simple.v1+json"): + data = json.loads(page.content) + for file in data.get("files", []): + link = Link.from_json(file, page.url) + if link is None: + continue + yield link + return + + parser = HTMLLinkParser(page.url) + encoding = page.encoding or "utf-8" + parser.feed(page.content.decode(encoding)) + + url = page.url + base_url = parser.base_url or url + for anchor in parser.anchors: + link = Link.from_element(anchor, page_url=url, base_url=base_url) + if link is None: + continue + yield link + + +@dataclass(frozen=True) +class IndexContent: + """Represents one response (or page), along with its URL. + + :param encoding: the encoding to decode the given content. + :param url: the URL from which the HTML was downloaded. + :param cache_link_parsing: whether links parsed from this page's url + should be cached. PyPI index urls should + have this set to False, for example. + """ + + content: bytes + content_type: str + encoding: Optional[str] + url: str + cache_link_parsing: bool = True + + def __str__(self) -> str: + return redact_auth_from_url(self.url) + + +class HTMLLinkParser(HTMLParser): + """ + HTMLParser that keeps the first base HREF and a list of all anchor + elements' attributes. + """ + + def __init__(self, url: str) -> None: + super().__init__(convert_charrefs=True) + + self.url: str = url + self.base_url: Optional[str] = None + self.anchors: List[Dict[str, Optional[str]]] = [] + + def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: + if tag == "base" and self.base_url is None: + href = self.get_href(attrs) + if href is not None: + self.base_url = href + elif tag == "a": + self.anchors.append(dict(attrs)) + + def get_href(self, attrs: List[Tuple[str, Optional[str]]]) -> Optional[str]: + for name, value in attrs: + if name == "href": + return value + return None + + +def _handle_get_simple_fail( + link: Link, + reason: Union[str, Exception], + meth: Optional[Callable[..., None]] = None, +) -> None: + if meth is None: + meth = logger.debug + meth("Could not fetch URL %s: %s - skipping", link, reason) + + +def _make_index_content( + response: Response, cache_link_parsing: bool = True +) -> IndexContent: + encoding = _get_encoding_from_headers(response.headers) + return IndexContent( + response.content, + response.headers["Content-Type"], + encoding=encoding, + url=response.url, + cache_link_parsing=cache_link_parsing, + ) + + +def _get_index_content(link: Link, *, session: PipSession) -> Optional["IndexContent"]: + url = link.url.split("#", 1)[0] + + # Check for VCS schemes that do not support lookup as web pages. + vcs_scheme = _match_vcs_scheme(url) + if vcs_scheme: + logger.warning( + "Cannot look at %s URL %s because it does not support lookup as web pages.", + vcs_scheme, + link, + ) + return None + + # Tack index.html onto file:// URLs that point to directories + scheme, _, path, _, _, _ = urllib.parse.urlparse(url) + if scheme == "file" and os.path.isdir(urllib.request.url2pathname(path)): + # add trailing slash if not present so urljoin doesn't trim + # final segment + if not url.endswith("/"): + url += "/" + # TODO: In the future, it would be nice if pip supported PEP 691 + # style responses in the file:// URLs, however there's no + # standard file extension for application/vnd.pypi.simple.v1+json + # so we'll need to come up with something on our own. + url = urllib.parse.urljoin(url, "index.html") + logger.debug(" file: URL is directory, getting %s", url) + + try: + resp = _get_simple_response(url, session=session) + except _NotHTTP: + logger.warning( + "Skipping page %s because it looks like an archive, and cannot " + "be checked by a HTTP HEAD request.", + link, + ) + except _NotAPIContent as exc: + logger.warning( + "Skipping page %s because the %s request got Content-Type: %s. " + "The only supported Content-Types are application/vnd.pypi.simple.v1+json, " + "application/vnd.pypi.simple.v1+html, and text/html", + link, + exc.request_desc, + exc.content_type, + ) + except NetworkConnectionError as exc: + _handle_get_simple_fail(link, exc) + except RetryError as exc: + _handle_get_simple_fail(link, exc) + except SSLError as exc: + reason = "There was a problem confirming the ssl certificate: " + reason += str(exc) + _handle_get_simple_fail(link, reason, meth=logger.info) + except requests.ConnectionError as exc: + _handle_get_simple_fail(link, f"connection error: {exc}") + except requests.Timeout: + _handle_get_simple_fail(link, "timed out") + else: + return _make_index_content(resp, cache_link_parsing=link.cache_link_parsing) + return None + + +class CollectedSources(NamedTuple): + find_links: Sequence[Optional[LinkSource]] + index_urls: Sequence[Optional[LinkSource]] + + +class LinkCollector: + """ + Responsible for collecting Link objects from all configured locations, + making network requests as needed. + + The class's main method is its collect_sources() method. + """ + + def __init__( + self, + session: PipSession, + search_scope: SearchScope, + ) -> None: + self.search_scope = search_scope + self.session = session + + @classmethod + def create( + cls, + session: PipSession, + options: Values, + suppress_no_index: bool = False, + ) -> "LinkCollector": + """ + :param session: The Session to use to make requests. + :param suppress_no_index: Whether to ignore the --no-index option + when constructing the SearchScope object. + """ + index_urls = [options.index_url] + options.extra_index_urls + if options.no_index and not suppress_no_index: + logger.debug( + "Ignoring indexes: %s", + ",".join(redact_auth_from_url(url) for url in index_urls), + ) + index_urls = [] + + # Make sure find_links is a list before passing to create(). + find_links = options.find_links or [] + + search_scope = SearchScope.create( + find_links=find_links, + index_urls=index_urls, + no_index=options.no_index, + ) + link_collector = LinkCollector( + session=session, + search_scope=search_scope, + ) + return link_collector + + @property + def find_links(self) -> List[str]: + return self.search_scope.find_links + + def fetch_response(self, location: Link) -> Optional[IndexContent]: + """ + Fetch an HTML page containing package links. + """ + return _get_index_content(location, session=self.session) + + def collect_sources( + self, + project_name: str, + candidates_from_page: CandidatesFromPage, + ) -> CollectedSources: + # The OrderedDict calls deduplicate sources by URL. + index_url_sources = collections.OrderedDict( + build_source( + loc, + candidates_from_page=candidates_from_page, + page_validator=self.session.is_secure_origin, + expand_dir=False, + cache_link_parsing=False, + project_name=project_name, + ) + for loc in self.search_scope.get_index_urls_locations(project_name) + ).values() + find_links_sources = collections.OrderedDict( + build_source( + loc, + candidates_from_page=candidates_from_page, + page_validator=self.session.is_secure_origin, + expand_dir=True, + cache_link_parsing=True, + project_name=project_name, + ) + for loc in self.find_links + ).values() + + if logger.isEnabledFor(logging.DEBUG): + lines = [ + f"* {s.link}" + for s in itertools.chain(find_links_sources, index_url_sources) + if s is not None and s.link is not None + ] + lines = [ + f"{len(lines)} location(s) to search " + f"for versions of {project_name}:" + ] + lines + logger.debug("\n".join(lines)) + + return CollectedSources( + find_links=list(find_links_sources), + index_urls=list(index_url_sources), + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/package_finder.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/package_finder.py new file mode 100644 index 0000000000000000000000000000000000000000..6971e959d32658d33bc54f0735aa65a27f2bcf70 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/package_finder.py @@ -0,0 +1,1050 @@ +"""Routines related to PyPI, indexes""" + +import enum +import functools +import itertools +import logging +import re +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Dict, + FrozenSet, + Iterable, + List, + Optional, + Set, + Tuple, + Union, +) + +from pip._vendor.packaging import specifiers +from pip._vendor.packaging.tags import Tag +from pip._vendor.packaging.utils import canonicalize_name +from pip._vendor.packaging.version import InvalidVersion, _BaseVersion +from pip._vendor.packaging.version import parse as parse_version + +from pip._internal.exceptions import ( + BestVersionAlreadyInstalled, + DistributionNotFound, + InvalidWheelFilename, + UnsupportedWheel, +) +from pip._internal.index.collector import LinkCollector, parse_links +from pip._internal.models.candidate import InstallationCandidate +from pip._internal.models.format_control import FormatControl +from pip._internal.models.link import Link +from pip._internal.models.search_scope import SearchScope +from pip._internal.models.selection_prefs import SelectionPreferences +from pip._internal.models.target_python import TargetPython +from pip._internal.models.wheel import Wheel +from pip._internal.req import InstallRequirement +from pip._internal.utils._log import getLogger +from pip._internal.utils.filetypes import WHEEL_EXTENSION +from pip._internal.utils.hashes import Hashes +from pip._internal.utils.logging import indent_log +from pip._internal.utils.misc import build_netloc +from pip._internal.utils.packaging import check_requires_python +from pip._internal.utils.unpacking import SUPPORTED_EXTENSIONS + +if TYPE_CHECKING: + from pip._vendor.typing_extensions import TypeGuard + +__all__ = ["FormatControl", "BestCandidateResult", "PackageFinder"] + + +logger = getLogger(__name__) + +BuildTag = Union[Tuple[()], Tuple[int, str]] +CandidateSortingKey = Tuple[int, int, int, _BaseVersion, Optional[int], BuildTag] + + +def _check_link_requires_python( + link: Link, + version_info: Tuple[int, int, int], + ignore_requires_python: bool = False, +) -> bool: + """ + Return whether the given Python version is compatible with a link's + "Requires-Python" value. + + :param version_info: A 3-tuple of ints representing the Python + major-minor-micro version to check. + :param ignore_requires_python: Whether to ignore the "Requires-Python" + value if the given Python version isn't compatible. + """ + try: + is_compatible = check_requires_python( + link.requires_python, + version_info=version_info, + ) + except specifiers.InvalidSpecifier: + logger.debug( + "Ignoring invalid Requires-Python (%r) for link: %s", + link.requires_python, + link, + ) + else: + if not is_compatible: + version = ".".join(map(str, version_info)) + if not ignore_requires_python: + logger.verbose( + "Link requires a different Python (%s not in: %r): %s", + version, + link.requires_python, + link, + ) + return False + + logger.debug( + "Ignoring failed Requires-Python check (%s not in: %r) for link: %s", + version, + link.requires_python, + link, + ) + + return True + + +class LinkType(enum.Enum): + candidate = enum.auto() + different_project = enum.auto() + yanked = enum.auto() + format_unsupported = enum.auto() + format_invalid = enum.auto() + platform_mismatch = enum.auto() + requires_python_mismatch = enum.auto() + + +class LinkEvaluator: + """ + Responsible for evaluating links for a particular project. + """ + + _py_version_re = re.compile(r"-py([123]\.?[0-9]?)$") + + # Don't include an allow_yanked default value to make sure each call + # site considers whether yanked releases are allowed. This also causes + # that decision to be made explicit in the calling code, which helps + # people when reading the code. + def __init__( + self, + project_name: str, + canonical_name: str, + formats: FrozenSet[str], + target_python: TargetPython, + allow_yanked: bool, + ignore_requires_python: Optional[bool] = None, + ) -> None: + """ + :param project_name: The user supplied package name. + :param canonical_name: The canonical package name. + :param formats: The formats allowed for this package. Should be a set + with 'binary' or 'source' or both in it. + :param target_python: The target Python interpreter to use when + evaluating link compatibility. This is used, for example, to + check wheel compatibility, as well as when checking the Python + version, e.g. the Python version embedded in a link filename + (or egg fragment) and against an HTML link's optional PEP 503 + "data-requires-python" attribute. + :param allow_yanked: Whether files marked as yanked (in the sense + of PEP 592) are permitted to be candidates for install. + :param ignore_requires_python: Whether to ignore incompatible + PEP 503 "data-requires-python" values in HTML links. Defaults + to False. + """ + if ignore_requires_python is None: + ignore_requires_python = False + + self._allow_yanked = allow_yanked + self._canonical_name = canonical_name + self._ignore_requires_python = ignore_requires_python + self._formats = formats + self._target_python = target_python + + self.project_name = project_name + + def evaluate_link(self, link: Link) -> Tuple[LinkType, str]: + """ + Determine whether a link is a candidate for installation. + + :return: A tuple (result, detail), where *result* is an enum + representing whether the evaluation found a candidate, or the reason + why one is not found. If a candidate is found, *detail* will be the + candidate's version string; if one is not found, it contains the + reason the link fails to qualify. + """ + version = None + if link.is_yanked and not self._allow_yanked: + reason = link.yanked_reason or "" + return (LinkType.yanked, f"yanked for reason: {reason}") + + if link.egg_fragment: + egg_info = link.egg_fragment + ext = link.ext + else: + egg_info, ext = link.splitext() + if not ext: + return (LinkType.format_unsupported, "not a file") + if ext not in SUPPORTED_EXTENSIONS: + return ( + LinkType.format_unsupported, + f"unsupported archive format: {ext}", + ) + if "binary" not in self._formats and ext == WHEEL_EXTENSION: + reason = f"No binaries permitted for {self.project_name}" + return (LinkType.format_unsupported, reason) + if "macosx10" in link.path and ext == ".zip": + return (LinkType.format_unsupported, "macosx10 one") + if ext == WHEEL_EXTENSION: + try: + wheel = Wheel(link.filename) + except InvalidWheelFilename: + return ( + LinkType.format_invalid, + "invalid wheel filename", + ) + if canonicalize_name(wheel.name) != self._canonical_name: + reason = f"wrong project name (not {self.project_name})" + return (LinkType.different_project, reason) + + supported_tags = self._target_python.get_unsorted_tags() + if not wheel.supported(supported_tags): + # Include the wheel's tags in the reason string to + # simplify troubleshooting compatibility issues. + file_tags = ", ".join(wheel.get_formatted_file_tags()) + reason = ( + f"none of the wheel's tags ({file_tags}) are compatible " + f"(run pip debug --verbose to show compatible tags)" + ) + return (LinkType.platform_mismatch, reason) + + version = wheel.version + + # This should be up by the self.ok_binary check, but see issue 2700. + if "source" not in self._formats and ext != WHEEL_EXTENSION: + reason = f"No sources permitted for {self.project_name}" + return (LinkType.format_unsupported, reason) + + if not version: + version = _extract_version_from_fragment( + egg_info, + self._canonical_name, + ) + if not version: + reason = f"Missing project version for {self.project_name}" + return (LinkType.format_invalid, reason) + + match = self._py_version_re.search(version) + if match: + version = version[: match.start()] + py_version = match.group(1) + if py_version != self._target_python.py_version: + return ( + LinkType.platform_mismatch, + "Python version is incorrect", + ) + + supports_python = _check_link_requires_python( + link, + version_info=self._target_python.py_version_info, + ignore_requires_python=self._ignore_requires_python, + ) + if not supports_python: + reason = f"{version} Requires-Python {link.requires_python}" + return (LinkType.requires_python_mismatch, reason) + + logger.debug("Found link %s, version: %s", link, version) + + return (LinkType.candidate, version) + + +def filter_unallowed_hashes( + candidates: List[InstallationCandidate], + hashes: Optional[Hashes], + project_name: str, +) -> List[InstallationCandidate]: + """ + Filter out candidates whose hashes aren't allowed, and return a new + list of candidates. + + If at least one candidate has an allowed hash, then all candidates with + either an allowed hash or no hash specified are returned. Otherwise, + the given candidates are returned. + + Including the candidates with no hash specified when there is a match + allows a warning to be logged if there is a more preferred candidate + with no hash specified. Returning all candidates in the case of no + matches lets pip report the hash of the candidate that would otherwise + have been installed (e.g. permitting the user to more easily update + their requirements file with the desired hash). + """ + if not hashes: + logger.debug( + "Given no hashes to check %s links for project %r: " + "discarding no candidates", + len(candidates), + project_name, + ) + # Make sure we're not returning back the given value. + return list(candidates) + + matches_or_no_digest = [] + # Collect the non-matches for logging purposes. + non_matches = [] + match_count = 0 + for candidate in candidates: + link = candidate.link + if not link.has_hash: + pass + elif link.is_hash_allowed(hashes=hashes): + match_count += 1 + else: + non_matches.append(candidate) + continue + + matches_or_no_digest.append(candidate) + + if match_count: + filtered = matches_or_no_digest + else: + # Make sure we're not returning back the given value. + filtered = list(candidates) + + if len(filtered) == len(candidates): + discard_message = "discarding no candidates" + else: + discard_message = "discarding {} non-matches:\n {}".format( + len(non_matches), + "\n ".join(str(candidate.link) for candidate in non_matches), + ) + + logger.debug( + "Checked %s links for project %r against %s hashes " + "(%s matches, %s no digest): %s", + len(candidates), + project_name, + hashes.digest_count, + match_count, + len(matches_or_no_digest) - match_count, + discard_message, + ) + + return filtered + + +@dataclass +class CandidatePreferences: + """ + Encapsulates some of the preferences for filtering and sorting + InstallationCandidate objects. + """ + + prefer_binary: bool = False + allow_all_prereleases: bool = False + + +@dataclass(frozen=True) +class BestCandidateResult: + """A collection of candidates, returned by `PackageFinder.find_best_candidate`. + + This class is only intended to be instantiated by CandidateEvaluator's + `compute_best_candidate()` method. + + :param all_candidates: A sequence of all available candidates found. + :param applicable_candidates: The applicable candidates. + :param best_candidate: The most preferred candidate found, or None + if no applicable candidates were found. + """ + + all_candidates: List[InstallationCandidate] + applicable_candidates: List[InstallationCandidate] + best_candidate: Optional[InstallationCandidate] + + def __post_init__(self) -> None: + assert set(self.applicable_candidates) <= set(self.all_candidates) + + if self.best_candidate is None: + assert not self.applicable_candidates + else: + assert self.best_candidate in self.applicable_candidates + + +class CandidateEvaluator: + """ + Responsible for filtering and sorting candidates for installation based + on what tags are valid. + """ + + @classmethod + def create( + cls, + project_name: str, + target_python: Optional[TargetPython] = None, + prefer_binary: bool = False, + allow_all_prereleases: bool = False, + specifier: Optional[specifiers.BaseSpecifier] = None, + hashes: Optional[Hashes] = None, + ) -> "CandidateEvaluator": + """Create a CandidateEvaluator object. + + :param target_python: The target Python interpreter to use when + checking compatibility. If None (the default), a TargetPython + object will be constructed from the running Python. + :param specifier: An optional object implementing `filter` + (e.g. `packaging.specifiers.SpecifierSet`) to filter applicable + versions. + :param hashes: An optional collection of allowed hashes. + """ + if target_python is None: + target_python = TargetPython() + if specifier is None: + specifier = specifiers.SpecifierSet() + + supported_tags = target_python.get_sorted_tags() + + return cls( + project_name=project_name, + supported_tags=supported_tags, + specifier=specifier, + prefer_binary=prefer_binary, + allow_all_prereleases=allow_all_prereleases, + hashes=hashes, + ) + + def __init__( + self, + project_name: str, + supported_tags: List[Tag], + specifier: specifiers.BaseSpecifier, + prefer_binary: bool = False, + allow_all_prereleases: bool = False, + hashes: Optional[Hashes] = None, + ) -> None: + """ + :param supported_tags: The PEP 425 tags supported by the target + Python in order of preference (most preferred first). + """ + self._allow_all_prereleases = allow_all_prereleases + self._hashes = hashes + self._prefer_binary = prefer_binary + self._project_name = project_name + self._specifier = specifier + self._supported_tags = supported_tags + # Since the index of the tag in the _supported_tags list is used + # as a priority, precompute a map from tag to index/priority to be + # used in wheel.find_most_preferred_tag. + self._wheel_tag_preferences = { + tag: idx for idx, tag in enumerate(supported_tags) + } + + def get_applicable_candidates( + self, + candidates: List[InstallationCandidate], + ) -> List[InstallationCandidate]: + """ + Return the applicable candidates from a list of candidates. + """ + # Using None infers from the specifier instead. + allow_prereleases = self._allow_all_prereleases or None + specifier = self._specifier + + # We turn the version object into a str here because otherwise + # when we're debundled but setuptools isn't, Python will see + # packaging.version.Version and + # pkg_resources._vendor.packaging.version.Version as different + # types. This way we'll use a str as a common data interchange + # format. If we stop using the pkg_resources provided specifier + # and start using our own, we can drop the cast to str(). + candidates_and_versions = [(c, str(c.version)) for c in candidates] + versions = set( + specifier.filter( + (v for _, v in candidates_and_versions), + prereleases=allow_prereleases, + ) + ) + + applicable_candidates = [c for c, v in candidates_and_versions if v in versions] + filtered_applicable_candidates = filter_unallowed_hashes( + candidates=applicable_candidates, + hashes=self._hashes, + project_name=self._project_name, + ) + + return sorted(filtered_applicable_candidates, key=self._sort_key) + + def _sort_key(self, candidate: InstallationCandidate) -> CandidateSortingKey: + """ + Function to pass as the `key` argument to a call to sorted() to sort + InstallationCandidates by preference. + + Returns a tuple such that tuples sorting as greater using Python's + default comparison operator are more preferred. + + The preference is as follows: + + First and foremost, candidates with allowed (matching) hashes are + always preferred over candidates without matching hashes. This is + because e.g. if the only candidate with an allowed hash is yanked, + we still want to use that candidate. + + Second, excepting hash considerations, candidates that have been + yanked (in the sense of PEP 592) are always less preferred than + candidates that haven't been yanked. Then: + + If not finding wheels, they are sorted by version only. + If finding wheels, then the sort order is by version, then: + 1. existing installs + 2. wheels ordered via Wheel.support_index_min(self._supported_tags) + 3. source archives + If prefer_binary was set, then all wheels are sorted above sources. + + Note: it was considered to embed this logic into the Link + comparison operators, but then different sdist links + with the same version, would have to be considered equal + """ + valid_tags = self._supported_tags + support_num = len(valid_tags) + build_tag: BuildTag = () + binary_preference = 0 + link = candidate.link + if link.is_wheel: + # can raise InvalidWheelFilename + wheel = Wheel(link.filename) + try: + pri = -( + wheel.find_most_preferred_tag( + valid_tags, self._wheel_tag_preferences + ) + ) + except ValueError: + raise UnsupportedWheel( + f"{wheel.filename} is not a supported wheel for this platform. It " + "can't be sorted." + ) + if self._prefer_binary: + binary_preference = 1 + build_tag = wheel.build_tag + else: # sdist + pri = -(support_num) + has_allowed_hash = int(link.is_hash_allowed(self._hashes)) + yank_value = -1 * int(link.is_yanked) # -1 for yanked. + return ( + has_allowed_hash, + yank_value, + binary_preference, + candidate.version, + pri, + build_tag, + ) + + def sort_best_candidate( + self, + candidates: List[InstallationCandidate], + ) -> Optional[InstallationCandidate]: + """ + Return the best candidate per the instance's sort order, or None if + no candidate is acceptable. + """ + if not candidates: + return None + best_candidate = max(candidates, key=self._sort_key) + return best_candidate + + def compute_best_candidate( + self, + candidates: List[InstallationCandidate], + ) -> BestCandidateResult: + """ + Compute and return a `BestCandidateResult` instance. + """ + applicable_candidates = self.get_applicable_candidates(candidates) + + best_candidate = self.sort_best_candidate(applicable_candidates) + + return BestCandidateResult( + candidates, + applicable_candidates=applicable_candidates, + best_candidate=best_candidate, + ) + + +class PackageFinder: + """This finds packages. + + This is meant to match easy_install's technique for looking for + packages, by reading pages and looking for appropriate links. + """ + + def __init__( + self, + link_collector: LinkCollector, + target_python: TargetPython, + allow_yanked: bool, + format_control: Optional[FormatControl] = None, + candidate_prefs: Optional[CandidatePreferences] = None, + ignore_requires_python: Optional[bool] = None, + ) -> None: + """ + This constructor is primarily meant to be used by the create() class + method and from tests. + + :param format_control: A FormatControl object, used to control + the selection of source packages / binary packages when consulting + the index and links. + :param candidate_prefs: Options to use when creating a + CandidateEvaluator object. + """ + if candidate_prefs is None: + candidate_prefs = CandidatePreferences() + + format_control = format_control or FormatControl(set(), set()) + + self._allow_yanked = allow_yanked + self._candidate_prefs = candidate_prefs + self._ignore_requires_python = ignore_requires_python + self._link_collector = link_collector + self._target_python = target_python + + self.format_control = format_control + + # These are boring links that have already been logged somehow. + self._logged_links: Set[Tuple[Link, LinkType, str]] = set() + + # Cache of the result of finding candidates + self._all_candidates: Dict[str, List[InstallationCandidate]] = {} + self._best_candidates: Dict[ + Tuple[str, Optional[specifiers.BaseSpecifier], Optional[Hashes]], + BestCandidateResult, + ] = {} + + # Don't include an allow_yanked default value to make sure each call + # site considers whether yanked releases are allowed. This also causes + # that decision to be made explicit in the calling code, which helps + # people when reading the code. + @classmethod + def create( + cls, + link_collector: LinkCollector, + selection_prefs: SelectionPreferences, + target_python: Optional[TargetPython] = None, + ) -> "PackageFinder": + """Create a PackageFinder. + + :param selection_prefs: The candidate selection preferences, as a + SelectionPreferences object. + :param target_python: The target Python interpreter to use when + checking compatibility. If None (the default), a TargetPython + object will be constructed from the running Python. + """ + if target_python is None: + target_python = TargetPython() + + candidate_prefs = CandidatePreferences( + prefer_binary=selection_prefs.prefer_binary, + allow_all_prereleases=selection_prefs.allow_all_prereleases, + ) + + return cls( + candidate_prefs=candidate_prefs, + link_collector=link_collector, + target_python=target_python, + allow_yanked=selection_prefs.allow_yanked, + format_control=selection_prefs.format_control, + ignore_requires_python=selection_prefs.ignore_requires_python, + ) + + @property + def target_python(self) -> TargetPython: + return self._target_python + + @property + def search_scope(self) -> SearchScope: + return self._link_collector.search_scope + + @search_scope.setter + def search_scope(self, search_scope: SearchScope) -> None: + self._link_collector.search_scope = search_scope + + @property + def find_links(self) -> List[str]: + return self._link_collector.find_links + + @property + def index_urls(self) -> List[str]: + return self.search_scope.index_urls + + @property + def proxy(self) -> Optional[str]: + return self._link_collector.session.pip_proxy + + @property + def trusted_hosts(self) -> Iterable[str]: + for host_port in self._link_collector.session.pip_trusted_origins: + yield build_netloc(*host_port) + + @property + def custom_cert(self) -> Optional[str]: + # session.verify is either a boolean (use default bundle/no SSL + # verification) or a string path to a custom CA bundle to use. We only + # care about the latter. + verify = self._link_collector.session.verify + return verify if isinstance(verify, str) else None + + @property + def client_cert(self) -> Optional[str]: + cert = self._link_collector.session.cert + assert not isinstance(cert, tuple), "pip only supports PEM client certs" + return cert + + @property + def allow_all_prereleases(self) -> bool: + return self._candidate_prefs.allow_all_prereleases + + def set_allow_all_prereleases(self) -> None: + self._candidate_prefs.allow_all_prereleases = True + + @property + def prefer_binary(self) -> bool: + return self._candidate_prefs.prefer_binary + + def set_prefer_binary(self) -> None: + self._candidate_prefs.prefer_binary = True + + def requires_python_skipped_reasons(self) -> List[str]: + reasons = { + detail + for _, result, detail in self._logged_links + if result == LinkType.requires_python_mismatch + } + return sorted(reasons) + + def make_link_evaluator(self, project_name: str) -> LinkEvaluator: + canonical_name = canonicalize_name(project_name) + formats = self.format_control.get_allowed_formats(canonical_name) + + return LinkEvaluator( + project_name=project_name, + canonical_name=canonical_name, + formats=formats, + target_python=self._target_python, + allow_yanked=self._allow_yanked, + ignore_requires_python=self._ignore_requires_python, + ) + + def _sort_links(self, links: Iterable[Link]) -> List[Link]: + """ + Returns elements of links in order, non-egg links first, egg links + second, while eliminating duplicates + """ + eggs, no_eggs = [], [] + seen: Set[Link] = set() + for link in links: + if link not in seen: + seen.add(link) + if link.egg_fragment: + eggs.append(link) + else: + no_eggs.append(link) + return no_eggs + eggs + + def _log_skipped_link(self, link: Link, result: LinkType, detail: str) -> None: + entry = (link, result, detail) + if entry not in self._logged_links: + # Put the link at the end so the reason is more visible and because + # the link string is usually very long. + logger.debug("Skipping link: %s: %s", detail, link) + self._logged_links.add(entry) + + def get_install_candidate( + self, link_evaluator: LinkEvaluator, link: Link + ) -> Optional[InstallationCandidate]: + """ + If the link is a candidate for install, convert it to an + InstallationCandidate and return it. Otherwise, return None. + """ + result, detail = link_evaluator.evaluate_link(link) + if result != LinkType.candidate: + self._log_skipped_link(link, result, detail) + return None + + try: + return InstallationCandidate( + name=link_evaluator.project_name, + link=link, + version=detail, + ) + except InvalidVersion: + return None + + def evaluate_links( + self, link_evaluator: LinkEvaluator, links: Iterable[Link] + ) -> List[InstallationCandidate]: + """ + Convert links that are candidates to InstallationCandidate objects. + """ + candidates = [] + for link in self._sort_links(links): + candidate = self.get_install_candidate(link_evaluator, link) + if candidate is not None: + candidates.append(candidate) + + return candidates + + def process_project_url( + self, project_url: Link, link_evaluator: LinkEvaluator + ) -> List[InstallationCandidate]: + logger.debug( + "Fetching project page and analyzing links: %s", + project_url, + ) + index_response = self._link_collector.fetch_response(project_url) + if index_response is None: + return [] + + page_links = list(parse_links(index_response)) + + with indent_log(): + package_links = self.evaluate_links( + link_evaluator, + links=page_links, + ) + + return package_links + + def find_all_candidates(self, project_name: str) -> List[InstallationCandidate]: + """Find all available InstallationCandidate for project_name + + This checks index_urls and find_links. + All versions found are returned as an InstallationCandidate list. + + See LinkEvaluator.evaluate_link() for details on which files + are accepted. + """ + if project_name in self._all_candidates: + return self._all_candidates[project_name] + + link_evaluator = self.make_link_evaluator(project_name) + + collected_sources = self._link_collector.collect_sources( + project_name=project_name, + candidates_from_page=functools.partial( + self.process_project_url, + link_evaluator=link_evaluator, + ), + ) + + page_candidates_it = itertools.chain.from_iterable( + source.page_candidates() + for sources in collected_sources + for source in sources + if source is not None + ) + page_candidates = list(page_candidates_it) + + file_links_it = itertools.chain.from_iterable( + source.file_links() + for sources in collected_sources + for source in sources + if source is not None + ) + file_candidates = self.evaluate_links( + link_evaluator, + sorted(file_links_it, reverse=True), + ) + + if logger.isEnabledFor(logging.DEBUG) and file_candidates: + paths = [] + for candidate in file_candidates: + assert candidate.link.url # we need to have a URL + try: + paths.append(candidate.link.file_path) + except Exception: + paths.append(candidate.link.url) # it's not a local file + + logger.debug("Local files found: %s", ", ".join(paths)) + + # This is an intentional priority ordering + self._all_candidates[project_name] = file_candidates + page_candidates + + return self._all_candidates[project_name] + + def make_candidate_evaluator( + self, + project_name: str, + specifier: Optional[specifiers.BaseSpecifier] = None, + hashes: Optional[Hashes] = None, + ) -> CandidateEvaluator: + """Create a CandidateEvaluator object to use.""" + candidate_prefs = self._candidate_prefs + return CandidateEvaluator.create( + project_name=project_name, + target_python=self._target_python, + prefer_binary=candidate_prefs.prefer_binary, + allow_all_prereleases=candidate_prefs.allow_all_prereleases, + specifier=specifier, + hashes=hashes, + ) + + def find_best_candidate( + self, + project_name: str, + specifier: Optional[specifiers.BaseSpecifier] = None, + hashes: Optional[Hashes] = None, + ) -> BestCandidateResult: + """Find matches for the given project and specifier. + + :param specifier: An optional object implementing `filter` + (e.g. `packaging.specifiers.SpecifierSet`) to filter applicable + versions. + + :return: A `BestCandidateResult` instance. + """ + if (project_name, specifier, hashes) in self._best_candidates: + return self._best_candidates[project_name, specifier, hashes] + + candidates = self.find_all_candidates(project_name) + candidate_evaluator = self.make_candidate_evaluator( + project_name=project_name, + specifier=specifier, + hashes=hashes, + ) + self._best_candidates[project_name, specifier, hashes] = ( + candidate_evaluator.compute_best_candidate(candidates) + ) + + return self._best_candidates[project_name, specifier, hashes] + + def find_requirement( + self, req: InstallRequirement, upgrade: bool + ) -> Optional[InstallationCandidate]: + """Try to find a Link matching req + + Expects req, an InstallRequirement and upgrade, a boolean + Returns a InstallationCandidate if found, + Raises DistributionNotFound or BestVersionAlreadyInstalled otherwise + """ + name = req.name + assert name is not None, "find_requirement() called with no name" + + hashes = req.hashes(trust_internet=False) + best_candidate_result = self.find_best_candidate( + name, + specifier=req.specifier, + hashes=hashes, + ) + best_candidate = best_candidate_result.best_candidate + + installed_version: Optional[_BaseVersion] = None + if req.satisfied_by is not None: + installed_version = req.satisfied_by.version + + def _format_versions(cand_iter: Iterable[InstallationCandidate]) -> str: + # This repeated parse_version and str() conversion is needed to + # handle different vendoring sources from pip and pkg_resources. + # If we stop using the pkg_resources provided specifier and start + # using our own, we can drop the cast to str(). + return ( + ", ".join( + sorted( + {str(c.version) for c in cand_iter}, + key=parse_version, + ) + ) + or "none" + ) + + if installed_version is None and best_candidate is None: + logger.critical( + "Could not find a version that satisfies the requirement %s " + "(from versions: %s)", + req, + _format_versions(best_candidate_result.all_candidates), + ) + + raise DistributionNotFound(f"No matching distribution found for {req}") + + def _should_install_candidate( + candidate: Optional[InstallationCandidate], + ) -> "TypeGuard[InstallationCandidate]": + if installed_version is None: + return True + if best_candidate is None: + return False + return best_candidate.version > installed_version + + if not upgrade and installed_version is not None: + if _should_install_candidate(best_candidate): + logger.debug( + "Existing installed version (%s) satisfies requirement " + "(most up-to-date version is %s)", + installed_version, + best_candidate.version, + ) + else: + logger.debug( + "Existing installed version (%s) is most up-to-date and " + "satisfies requirement", + installed_version, + ) + return None + + if _should_install_candidate(best_candidate): + logger.debug( + "Using version %s (newest of versions: %s)", + best_candidate.version, + _format_versions(best_candidate_result.applicable_candidates), + ) + return best_candidate + + # We have an existing version, and its the best version + logger.debug( + "Installed version (%s) is most up-to-date (past versions: %s)", + installed_version, + _format_versions(best_candidate_result.applicable_candidates), + ) + raise BestVersionAlreadyInstalled + + +def _find_name_version_sep(fragment: str, canonical_name: str) -> int: + """Find the separator's index based on the package's canonical name. + + :param fragment: A + filename "fragment" (stem) or + egg fragment. + :param canonical_name: The package's canonical name. + + This function is needed since the canonicalized name does not necessarily + have the same length as the egg info's name part. An example:: + + >>> fragment = 'foo__bar-1.0' + >>> canonical_name = 'foo-bar' + >>> _find_name_version_sep(fragment, canonical_name) + 8 + """ + # Project name and version must be separated by one single dash. Find all + # occurrences of dashes; if the string in front of it matches the canonical + # name, this is the one separating the name and version parts. + for i, c in enumerate(fragment): + if c != "-": + continue + if canonicalize_name(fragment[:i]) == canonical_name: + return i + raise ValueError(f"{fragment} does not match {canonical_name}") + + +def _extract_version_from_fragment(fragment: str, canonical_name: str) -> Optional[str]: + """Parse the version string from a + filename + "fragment" (stem) or egg fragment. + + :param fragment: The string to parse. E.g. foo-2.1 + :param canonical_name: The canonicalized name of the package this + belongs to. + """ + try: + version_start = _find_name_version_sep(fragment, canonical_name) + 1 + except ValueError: + return None + version = fragment[version_start:] + if not version: + return None + return version diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/sources.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/sources.py new file mode 100644 index 0000000000000000000000000000000000000000..3dafb30e6eb843ac56315dd5d0ab223bf4f740b8 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/index/sources.py @@ -0,0 +1,284 @@ +import logging +import mimetypes +import os +from collections import defaultdict +from typing import Callable, Dict, Iterable, List, Optional, Tuple + +from pip._vendor.packaging.utils import ( + InvalidSdistFilename, + InvalidWheelFilename, + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, +) + +from pip._internal.models.candidate import InstallationCandidate +from pip._internal.models.link import Link +from pip._internal.utils.urls import path_to_url, url_to_path +from pip._internal.vcs import is_url + +logger = logging.getLogger(__name__) + +FoundCandidates = Iterable[InstallationCandidate] +FoundLinks = Iterable[Link] +CandidatesFromPage = Callable[[Link], Iterable[InstallationCandidate]] +PageValidator = Callable[[Link], bool] + + +class LinkSource: + @property + def link(self) -> Optional[Link]: + """Returns the underlying link, if there's one.""" + raise NotImplementedError() + + def page_candidates(self) -> FoundCandidates: + """Candidates found by parsing an archive listing HTML file.""" + raise NotImplementedError() + + def file_links(self) -> FoundLinks: + """Links found by specifying archives directly.""" + raise NotImplementedError() + + +def _is_html_file(file_url: str) -> bool: + return mimetypes.guess_type(file_url, strict=False)[0] == "text/html" + + +class _FlatDirectoryToUrls: + """Scans directory and caches results""" + + def __init__(self, path: str) -> None: + self._path = path + self._page_candidates: List[str] = [] + self._project_name_to_urls: Dict[str, List[str]] = defaultdict(list) + self._scanned_directory = False + + def _scan_directory(self) -> None: + """Scans directory once and populates both page_candidates + and project_name_to_urls at the same time + """ + for entry in os.scandir(self._path): + url = path_to_url(entry.path) + if _is_html_file(url): + self._page_candidates.append(url) + continue + + # File must have a valid wheel or sdist name, + # otherwise not worth considering as a package + try: + project_filename = parse_wheel_filename(entry.name)[0] + except InvalidWheelFilename: + try: + project_filename = parse_sdist_filename(entry.name)[0] + except InvalidSdistFilename: + continue + + self._project_name_to_urls[project_filename].append(url) + self._scanned_directory = True + + @property + def page_candidates(self) -> List[str]: + if not self._scanned_directory: + self._scan_directory() + + return self._page_candidates + + @property + def project_name_to_urls(self) -> Dict[str, List[str]]: + if not self._scanned_directory: + self._scan_directory() + + return self._project_name_to_urls + + +class _FlatDirectorySource(LinkSource): + """Link source specified by ``--find-links=``. + + This looks the content of the directory, and returns: + + * ``page_candidates``: Links listed on each HTML file in the directory. + * ``file_candidates``: Archives in the directory. + """ + + _paths_to_urls: Dict[str, _FlatDirectoryToUrls] = {} + + def __init__( + self, + candidates_from_page: CandidatesFromPage, + path: str, + project_name: str, + ) -> None: + self._candidates_from_page = candidates_from_page + self._project_name = canonicalize_name(project_name) + + # Get existing instance of _FlatDirectoryToUrls if it exists + if path in self._paths_to_urls: + self._path_to_urls = self._paths_to_urls[path] + else: + self._path_to_urls = _FlatDirectoryToUrls(path=path) + self._paths_to_urls[path] = self._path_to_urls + + @property + def link(self) -> Optional[Link]: + return None + + def page_candidates(self) -> FoundCandidates: + for url in self._path_to_urls.page_candidates: + yield from self._candidates_from_page(Link(url)) + + def file_links(self) -> FoundLinks: + for url in self._path_to_urls.project_name_to_urls[self._project_name]: + yield Link(url) + + +class _LocalFileSource(LinkSource): + """``--find-links=`` or ``--[extra-]index-url=``. + + If a URL is supplied, it must be a ``file:`` URL. If a path is supplied to + the option, it is converted to a URL first. This returns: + + * ``page_candidates``: Links listed on an HTML file. + * ``file_candidates``: The non-HTML file. + """ + + def __init__( + self, + candidates_from_page: CandidatesFromPage, + link: Link, + ) -> None: + self._candidates_from_page = candidates_from_page + self._link = link + + @property + def link(self) -> Optional[Link]: + return self._link + + def page_candidates(self) -> FoundCandidates: + if not _is_html_file(self._link.url): + return + yield from self._candidates_from_page(self._link) + + def file_links(self) -> FoundLinks: + if _is_html_file(self._link.url): + return + yield self._link + + +class _RemoteFileSource(LinkSource): + """``--find-links=`` or ``--[extra-]index-url=``. + + This returns: + + * ``page_candidates``: Links listed on an HTML file. + * ``file_candidates``: The non-HTML file. + """ + + def __init__( + self, + candidates_from_page: CandidatesFromPage, + page_validator: PageValidator, + link: Link, + ) -> None: + self._candidates_from_page = candidates_from_page + self._page_validator = page_validator + self._link = link + + @property + def link(self) -> Optional[Link]: + return self._link + + def page_candidates(self) -> FoundCandidates: + if not self._page_validator(self._link): + return + yield from self._candidates_from_page(self._link) + + def file_links(self) -> FoundLinks: + yield self._link + + +class _IndexDirectorySource(LinkSource): + """``--[extra-]index-url=``. + + This is treated like a remote URL; ``candidates_from_page`` contains logic + for this by appending ``index.html`` to the link. + """ + + def __init__( + self, + candidates_from_page: CandidatesFromPage, + link: Link, + ) -> None: + self._candidates_from_page = candidates_from_page + self._link = link + + @property + def link(self) -> Optional[Link]: + return self._link + + def page_candidates(self) -> FoundCandidates: + yield from self._candidates_from_page(self._link) + + def file_links(self) -> FoundLinks: + return () + + +def build_source( + location: str, + *, + candidates_from_page: CandidatesFromPage, + page_validator: PageValidator, + expand_dir: bool, + cache_link_parsing: bool, + project_name: str, +) -> Tuple[Optional[str], Optional[LinkSource]]: + path: Optional[str] = None + url: Optional[str] = None + if os.path.exists(location): # Is a local path. + url = path_to_url(location) + path = location + elif location.startswith("file:"): # A file: URL. + url = location + path = url_to_path(location) + elif is_url(location): + url = location + + if url is None: + msg = ( + "Location '%s' is ignored: " + "it is either a non-existing path or lacks a specific scheme." + ) + logger.warning(msg, location) + return (None, None) + + if path is None: + source: LinkSource = _RemoteFileSource( + candidates_from_page=candidates_from_page, + page_validator=page_validator, + link=Link(url, cache_link_parsing=cache_link_parsing), + ) + return (url, source) + + if os.path.isdir(path): + if expand_dir: + source = _FlatDirectorySource( + candidates_from_page=candidates_from_page, + path=path, + project_name=project_name, + ) + else: + source = _IndexDirectorySource( + candidates_from_page=candidates_from_page, + link=Link(url, cache_link_parsing=cache_link_parsing), + ) + return (url, source) + elif os.path.isfile(path): + source = _LocalFileSource( + candidates_from_page=candidates_from_page, + link=Link(url, cache_link_parsing=cache_link_parsing), + ) + return (url, source) + logger.warning( + "Location '%s' is ignored: it is neither a file nor a directory.", + location, + ) + return (url, None) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dfb5dd360660a07b22eda334631fda69e0e47fd7 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/__init__.py @@ -0,0 +1,439 @@ +import functools +import logging +import os +import pathlib +import sys +import sysconfig +from typing import Any, Dict, Optional + +from pip._internal.models.scheme import SCHEME_KEYS, Scheme +from pip._internal.utils.compat import WINDOWS +from pip._internal.utils.deprecation import deprecated +from pip._internal.utils.virtualenv import running_under_virtualenv + +from . import _sysconfig +from .base import ( + USER_CACHE_DIR, + get_major_minor_version, + get_src_prefix, + is_osx_framework, + site_packages, + user_site, +) + +__all__ = [ + "USER_CACHE_DIR", + "get_bin_prefix", + "get_bin_user", + "get_major_minor_version", + "get_platlib", + "get_purelib", + "get_scheme", + "get_src_prefix", + "site_packages", + "user_site", +] + + +logger = logging.getLogger(__name__) + + +_PLATLIBDIR: str = getattr(sys, "platlibdir", "lib") + +_USE_SYSCONFIG_DEFAULT = sys.version_info >= (3, 10) + + +def _should_use_sysconfig() -> bool: + """This function determines the value of _USE_SYSCONFIG. + + By default, pip uses sysconfig on Python 3.10+. + But Python distributors can override this decision by setting: + sysconfig._PIP_USE_SYSCONFIG = True / False + Rationale in https://github.com/pypa/pip/issues/10647 + + This is a function for testability, but should be constant during any one + run. + """ + return bool(getattr(sysconfig, "_PIP_USE_SYSCONFIG", _USE_SYSCONFIG_DEFAULT)) + + +_USE_SYSCONFIG = _should_use_sysconfig() + +if not _USE_SYSCONFIG: + # Import distutils lazily to avoid deprecation warnings, + # but import it soon enough that it is in memory and available during + # a pip reinstall. + from . import _distutils + +# Be noisy about incompatibilities if this platforms "should" be using +# sysconfig, but is explicitly opting out and using distutils instead. +if _USE_SYSCONFIG_DEFAULT and not _USE_SYSCONFIG: + _MISMATCH_LEVEL = logging.WARNING +else: + _MISMATCH_LEVEL = logging.DEBUG + + +def _looks_like_bpo_44860() -> bool: + """The resolution to bpo-44860 will change this incorrect platlib. + + See . + """ + from distutils.command.install import INSTALL_SCHEMES + + try: + unix_user_platlib = INSTALL_SCHEMES["unix_user"]["platlib"] + except KeyError: + return False + return unix_user_platlib == "$usersite" + + +def _looks_like_red_hat_patched_platlib_purelib(scheme: Dict[str, str]) -> bool: + platlib = scheme["platlib"] + if "/$platlibdir/" in platlib: + platlib = platlib.replace("/$platlibdir/", f"/{_PLATLIBDIR}/") + if "/lib64/" not in platlib: + return False + unpatched = platlib.replace("/lib64/", "/lib/") + return unpatched.replace("$platbase/", "$base/") == scheme["purelib"] + + +@functools.lru_cache(maxsize=None) +def _looks_like_red_hat_lib() -> bool: + """Red Hat patches platlib in unix_prefix and unix_home, but not purelib. + + This is the only way I can see to tell a Red Hat-patched Python. + """ + from distutils.command.install import INSTALL_SCHEMES + + return all( + k in INSTALL_SCHEMES + and _looks_like_red_hat_patched_platlib_purelib(INSTALL_SCHEMES[k]) + for k in ("unix_prefix", "unix_home") + ) + + +@functools.lru_cache(maxsize=None) +def _looks_like_debian_scheme() -> bool: + """Debian adds two additional schemes.""" + from distutils.command.install import INSTALL_SCHEMES + + return "deb_system" in INSTALL_SCHEMES and "unix_local" in INSTALL_SCHEMES + + +@functools.lru_cache(maxsize=None) +def _looks_like_red_hat_scheme() -> bool: + """Red Hat patches ``sys.prefix`` and ``sys.exec_prefix``. + + Red Hat's ``00251-change-user-install-location.patch`` changes the install + command's ``prefix`` and ``exec_prefix`` to append ``"/local"``. This is + (fortunately?) done quite unconditionally, so we create a default command + object without any configuration to detect this. + """ + from distutils.command.install import install + from distutils.dist import Distribution + + cmd: Any = install(Distribution()) + cmd.finalize_options() + return ( + cmd.exec_prefix == f"{os.path.normpath(sys.exec_prefix)}/local" + and cmd.prefix == f"{os.path.normpath(sys.prefix)}/local" + ) + + +@functools.lru_cache(maxsize=None) +def _looks_like_slackware_scheme() -> bool: + """Slackware patches sysconfig but fails to patch distutils and site. + + Slackware changes sysconfig's user scheme to use ``"lib64"`` for the lib + path, but does not do the same to the site module. + """ + if user_site is None: # User-site not available. + return False + try: + paths = sysconfig.get_paths(scheme="posix_user", expand=False) + except KeyError: # User-site not available. + return False + return "/lib64/" in paths["purelib"] and "/lib64/" not in user_site + + +@functools.lru_cache(maxsize=None) +def _looks_like_msys2_mingw_scheme() -> bool: + """MSYS2 patches distutils and sysconfig to use a UNIX-like scheme. + + However, MSYS2 incorrectly patches sysconfig ``nt`` scheme. The fix is + likely going to be included in their 3.10 release, so we ignore the warning. + See msys2/MINGW-packages#9319. + + MSYS2 MINGW's patch uses lowercase ``"lib"`` instead of the usual uppercase, + and is missing the final ``"site-packages"``. + """ + paths = sysconfig.get_paths("nt", expand=False) + return all( + "Lib" not in p and "lib" in p and not p.endswith("site-packages") + for p in (paths[key] for key in ("platlib", "purelib")) + ) + + +@functools.lru_cache(maxsize=None) +def _warn_mismatched(old: pathlib.Path, new: pathlib.Path, *, key: str) -> None: + issue_url = "https://github.com/pypa/pip/issues/10151" + message = ( + "Value for %s does not match. Please report this to <%s>" + "\ndistutils: %s" + "\nsysconfig: %s" + ) + logger.log(_MISMATCH_LEVEL, message, key, issue_url, old, new) + + +def _warn_if_mismatch(old: pathlib.Path, new: pathlib.Path, *, key: str) -> bool: + if old == new: + return False + _warn_mismatched(old, new, key=key) + return True + + +@functools.lru_cache(maxsize=None) +def _log_context( + *, + user: bool = False, + home: Optional[str] = None, + root: Optional[str] = None, + prefix: Optional[str] = None, +) -> None: + parts = [ + "Additional context:", + "user = %r", + "home = %r", + "root = %r", + "prefix = %r", + ] + + logger.log(_MISMATCH_LEVEL, "\n".join(parts), user, home, root, prefix) + + +def get_scheme( + dist_name: str, + user: bool = False, + home: Optional[str] = None, + root: Optional[str] = None, + isolated: bool = False, + prefix: Optional[str] = None, +) -> Scheme: + new = _sysconfig.get_scheme( + dist_name, + user=user, + home=home, + root=root, + isolated=isolated, + prefix=prefix, + ) + if _USE_SYSCONFIG: + return new + + old = _distutils.get_scheme( + dist_name, + user=user, + home=home, + root=root, + isolated=isolated, + prefix=prefix, + ) + + warning_contexts = [] + for k in SCHEME_KEYS: + old_v = pathlib.Path(getattr(old, k)) + new_v = pathlib.Path(getattr(new, k)) + + if old_v == new_v: + continue + + # distutils incorrectly put PyPy packages under ``site-packages/python`` + # in the ``posix_home`` scheme, but PyPy devs said they expect the + # directory name to be ``pypy`` instead. So we treat this as a bug fix + # and not warn about it. See bpo-43307 and python/cpython#24628. + skip_pypy_special_case = ( + sys.implementation.name == "pypy" + and home is not None + and k in ("platlib", "purelib") + and old_v.parent == new_v.parent + and old_v.name.startswith("python") + and new_v.name.startswith("pypy") + ) + if skip_pypy_special_case: + continue + + # sysconfig's ``osx_framework_user`` does not include ``pythonX.Y`` in + # the ``include`` value, but distutils's ``headers`` does. We'll let + # CPython decide whether this is a bug or feature. See bpo-43948. + skip_osx_framework_user_special_case = ( + user + and is_osx_framework() + and k == "headers" + and old_v.parent.parent == new_v.parent + and old_v.parent.name.startswith("python") + ) + if skip_osx_framework_user_special_case: + continue + + # On Red Hat and derived Linux distributions, distutils is patched to + # use "lib64" instead of "lib" for platlib. + if k == "platlib" and _looks_like_red_hat_lib(): + continue + + # On Python 3.9+, sysconfig's posix_user scheme sets platlib against + # sys.platlibdir, but distutils's unix_user incorrectly coninutes + # using the same $usersite for both platlib and purelib. This creates a + # mismatch when sys.platlibdir is not "lib". + skip_bpo_44860 = ( + user + and k == "platlib" + and not WINDOWS + and _PLATLIBDIR != "lib" + and _looks_like_bpo_44860() + ) + if skip_bpo_44860: + continue + + # Slackware incorrectly patches posix_user to use lib64 instead of lib, + # but not usersite to match the location. + skip_slackware_user_scheme = ( + user + and k in ("platlib", "purelib") + and not WINDOWS + and _looks_like_slackware_scheme() + ) + if skip_slackware_user_scheme: + continue + + # Both Debian and Red Hat patch Python to place the system site under + # /usr/local instead of /usr. Debian also places lib in dist-packages + # instead of site-packages, but the /usr/local check should cover it. + skip_linux_system_special_case = ( + not (user or home or prefix or running_under_virtualenv()) + and old_v.parts[1:3] == ("usr", "local") + and len(new_v.parts) > 1 + and new_v.parts[1] == "usr" + and (len(new_v.parts) < 3 or new_v.parts[2] != "local") + and (_looks_like_red_hat_scheme() or _looks_like_debian_scheme()) + ) + if skip_linux_system_special_case: + continue + + # MSYS2 MINGW's sysconfig patch does not include the "site-packages" + # part of the path. This is incorrect and will be fixed in MSYS. + skip_msys2_mingw_bug = ( + WINDOWS and k in ("platlib", "purelib") and _looks_like_msys2_mingw_scheme() + ) + if skip_msys2_mingw_bug: + continue + + # CPython's POSIX install script invokes pip (via ensurepip) against the + # interpreter located in the source tree, not the install site. This + # triggers special logic in sysconfig that's not present in distutils. + # https://github.com/python/cpython/blob/8c21941ddaf/Lib/sysconfig.py#L178-L194 + skip_cpython_build = ( + sysconfig.is_python_build(check_home=True) + and not WINDOWS + and k in ("headers", "include", "platinclude") + ) + if skip_cpython_build: + continue + + warning_contexts.append((old_v, new_v, f"scheme.{k}")) + + if not warning_contexts: + return old + + # Check if this path mismatch is caused by distutils config files. Those + # files will no longer work once we switch to sysconfig, so this raises a + # deprecation message for them. + default_old = _distutils.distutils_scheme( + dist_name, + user, + home, + root, + isolated, + prefix, + ignore_config_files=True, + ) + if any(default_old[k] != getattr(old, k) for k in SCHEME_KEYS): + deprecated( + reason=( + "Configuring installation scheme with distutils config files " + "is deprecated and will no longer work in the near future. If you " + "are using a Homebrew or Linuxbrew Python, please see discussion " + "at https://github.com/Homebrew/homebrew-core/issues/76621" + ), + replacement=None, + gone_in=None, + ) + return old + + # Post warnings about this mismatch so user can report them back. + for old_v, new_v, key in warning_contexts: + _warn_mismatched(old_v, new_v, key=key) + _log_context(user=user, home=home, root=root, prefix=prefix) + + return old + + +def get_bin_prefix() -> str: + new = _sysconfig.get_bin_prefix() + if _USE_SYSCONFIG: + return new + + old = _distutils.get_bin_prefix() + if _warn_if_mismatch(pathlib.Path(old), pathlib.Path(new), key="bin_prefix"): + _log_context() + return old + + +def get_bin_user() -> str: + return _sysconfig.get_scheme("", user=True).scripts + + +def _looks_like_deb_system_dist_packages(value: str) -> bool: + """Check if the value is Debian's APT-controlled dist-packages. + + Debian's ``distutils.sysconfig.get_python_lib()`` implementation returns the + default package path controlled by APT, but does not patch ``sysconfig`` to + do the same. This is similar to the bug worked around in ``get_scheme()``, + but here the default is ``deb_system`` instead of ``unix_local``. Ultimately + we can't do anything about this Debian bug, and this detection allows us to + skip the warning when needed. + """ + if not _looks_like_debian_scheme(): + return False + if value == "/usr/lib/python3/dist-packages": + return True + return False + + +def get_purelib() -> str: + """Return the default pure-Python lib location.""" + new = _sysconfig.get_purelib() + if _USE_SYSCONFIG: + return new + + old = _distutils.get_purelib() + if _looks_like_deb_system_dist_packages(old): + return old + if _warn_if_mismatch(pathlib.Path(old), pathlib.Path(new), key="purelib"): + _log_context() + return old + + +def get_platlib() -> str: + """Return the default platform-shared lib location.""" + new = _sysconfig.get_platlib() + if _USE_SYSCONFIG: + return new + + from . import _distutils + + old = _distutils.get_platlib() + if _looks_like_deb_system_dist_packages(old): + return old + if _warn_if_mismatch(pathlib.Path(old), pathlib.Path(new), key="platlib"): + _log_context() + return old diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/_distutils.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/_distutils.py new file mode 100644 index 0000000000000000000000000000000000000000..3d856256986f68b1bc38d012cfc96f8075268493 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/_distutils.py @@ -0,0 +1,172 @@ +"""Locations where we look for configs, install stuff, etc""" + +# The following comment should be removed at some point in the future. +# mypy: strict-optional=False + +# If pip's going to use distutils, it should not be using the copy that setuptools +# might have injected into the environment. This is done by removing the injected +# shim, if it's injected. +# +# See https://github.com/pypa/pip/issues/8761 for the original discussion and +# rationale for why this is done within pip. +try: + __import__("_distutils_hack").remove_shim() +except (ImportError, AttributeError): + pass + +import logging +import os +import sys +from distutils.cmd import Command as DistutilsCommand +from distutils.command.install import SCHEME_KEYS +from distutils.command.install import install as distutils_install_command +from distutils.sysconfig import get_python_lib +from typing import Dict, List, Optional, Union + +from pip._internal.models.scheme import Scheme +from pip._internal.utils.compat import WINDOWS +from pip._internal.utils.virtualenv import running_under_virtualenv + +from .base import get_major_minor_version + +logger = logging.getLogger(__name__) + + +def distutils_scheme( + dist_name: str, + user: bool = False, + home: Optional[str] = None, + root: Optional[str] = None, + isolated: bool = False, + prefix: Optional[str] = None, + *, + ignore_config_files: bool = False, +) -> Dict[str, str]: + """ + Return a distutils install scheme + """ + from distutils.dist import Distribution + + dist_args: Dict[str, Union[str, List[str]]] = {"name": dist_name} + if isolated: + dist_args["script_args"] = ["--no-user-cfg"] + + d = Distribution(dist_args) + if not ignore_config_files: + try: + d.parse_config_files() + except UnicodeDecodeError: + paths = d.find_config_files() + logger.warning( + "Ignore distutils configs in %s due to encoding errors.", + ", ".join(os.path.basename(p) for p in paths), + ) + obj: Optional[DistutilsCommand] = None + obj = d.get_command_obj("install", create=True) + assert obj is not None + i: distutils_install_command = obj + # NOTE: setting user or home has the side-effect of creating the home dir + # or user base for installations during finalize_options() + # ideally, we'd prefer a scheme class that has no side-effects. + assert not (user and prefix), f"user={user} prefix={prefix}" + assert not (home and prefix), f"home={home} prefix={prefix}" + i.user = user or i.user + if user or home: + i.prefix = "" + i.prefix = prefix or i.prefix + i.home = home or i.home + i.root = root or i.root + i.finalize_options() + + scheme: Dict[str, str] = {} + for key in SCHEME_KEYS: + scheme[key] = getattr(i, "install_" + key) + + # install_lib specified in setup.cfg should install *everything* + # into there (i.e. it takes precedence over both purelib and + # platlib). Note, i.install_lib is *always* set after + # finalize_options(); we only want to override here if the user + # has explicitly requested it hence going back to the config + if "install_lib" in d.get_option_dict("install"): + scheme.update({"purelib": i.install_lib, "platlib": i.install_lib}) + + if running_under_virtualenv(): + if home: + prefix = home + elif user: + prefix = i.install_userbase + else: + prefix = i.prefix + scheme["headers"] = os.path.join( + prefix, + "include", + "site", + f"python{get_major_minor_version()}", + dist_name, + ) + + if root is not None: + path_no_drive = os.path.splitdrive(os.path.abspath(scheme["headers"]))[1] + scheme["headers"] = os.path.join(root, path_no_drive[1:]) + + return scheme + + +def get_scheme( + dist_name: str, + user: bool = False, + home: Optional[str] = None, + root: Optional[str] = None, + isolated: bool = False, + prefix: Optional[str] = None, +) -> Scheme: + """ + Get the "scheme" corresponding to the input parameters. The distutils + documentation provides the context for the available schemes: + https://docs.python.org/3/install/index.html#alternate-installation + + :param dist_name: the name of the package to retrieve the scheme for, used + in the headers scheme path + :param user: indicates to use the "user" scheme + :param home: indicates to use the "home" scheme and provides the base + directory for the same + :param root: root under which other directories are re-based + :param isolated: equivalent to --no-user-cfg, i.e. do not consider + ~/.pydistutils.cfg (posix) or ~/pydistutils.cfg (non-posix) for + scheme paths + :param prefix: indicates to use the "prefix" scheme and provides the + base directory for the same + """ + scheme = distutils_scheme(dist_name, user, home, root, isolated, prefix) + return Scheme( + platlib=scheme["platlib"], + purelib=scheme["purelib"], + headers=scheme["headers"], + scripts=scheme["scripts"], + data=scheme["data"], + ) + + +def get_bin_prefix() -> str: + # XXX: In old virtualenv versions, sys.prefix can contain '..' components, + # so we need to call normpath to eliminate them. + prefix = os.path.normpath(sys.prefix) + if WINDOWS: + bin_py = os.path.join(prefix, "Scripts") + # buildout uses 'bin' on Windows too? + if not os.path.exists(bin_py): + bin_py = os.path.join(prefix, "bin") + return bin_py + # Forcing to use /usr/local/bin for standard macOS framework installs + # Also log to ~/Library/Logs/ for use with the Console.app log viewer + if sys.platform[:6] == "darwin" and prefix[:16] == "/System/Library/": + return "/usr/local/bin" + return os.path.join(prefix, "bin") + + +def get_purelib() -> str: + return get_python_lib(plat_specific=False) + + +def get_platlib() -> str: + return get_python_lib(plat_specific=True) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/_sysconfig.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/_sysconfig.py new file mode 100644 index 0000000000000000000000000000000000000000..ca860ea562c2c0c30982ba6cff654355e9f21c8a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/_sysconfig.py @@ -0,0 +1,214 @@ +import logging +import os +import sys +import sysconfig +import typing + +from pip._internal.exceptions import InvalidSchemeCombination, UserInstallationInvalid +from pip._internal.models.scheme import SCHEME_KEYS, Scheme +from pip._internal.utils.virtualenv import running_under_virtualenv + +from .base import change_root, get_major_minor_version, is_osx_framework + +logger = logging.getLogger(__name__) + + +# Notes on _infer_* functions. +# Unfortunately ``get_default_scheme()`` didn't exist before 3.10, so there's no +# way to ask things like "what is the '_prefix' scheme on this platform". These +# functions try to answer that with some heuristics while accounting for ad-hoc +# platforms not covered by CPython's default sysconfig implementation. If the +# ad-hoc implementation does not fully implement sysconfig, we'll fall back to +# a POSIX scheme. + +_AVAILABLE_SCHEMES = set(sysconfig.get_scheme_names()) + +_PREFERRED_SCHEME_API = getattr(sysconfig, "get_preferred_scheme", None) + + +def _should_use_osx_framework_prefix() -> bool: + """Check for Apple's ``osx_framework_library`` scheme. + + Python distributed by Apple's Command Line Tools has this special scheme + that's used when: + + * This is a framework build. + * We are installing into the system prefix. + + This does not account for ``pip install --prefix`` (also means we're not + installing to the system prefix), which should use ``posix_prefix``, but + logic here means ``_infer_prefix()`` outputs ``osx_framework_library``. But + since ``prefix`` is not available for ``sysconfig.get_default_scheme()``, + which is the stdlib replacement for ``_infer_prefix()``, presumably Apple + wouldn't be able to magically switch between ``osx_framework_library`` and + ``posix_prefix``. ``_infer_prefix()`` returning ``osx_framework_library`` + means its behavior is consistent whether we use the stdlib implementation + or our own, and we deal with this special case in ``get_scheme()`` instead. + """ + return ( + "osx_framework_library" in _AVAILABLE_SCHEMES + and not running_under_virtualenv() + and is_osx_framework() + ) + + +def _infer_prefix() -> str: + """Try to find a prefix scheme for the current platform. + + This tries: + + * A special ``osx_framework_library`` for Python distributed by Apple's + Command Line Tools, when not running in a virtual environment. + * Implementation + OS, used by PyPy on Windows (``pypy_nt``). + * Implementation without OS, used by PyPy on POSIX (``pypy``). + * OS + "prefix", used by CPython on POSIX (``posix_prefix``). + * Just the OS name, used by CPython on Windows (``nt``). + + If none of the above works, fall back to ``posix_prefix``. + """ + if _PREFERRED_SCHEME_API: + return _PREFERRED_SCHEME_API("prefix") + if _should_use_osx_framework_prefix(): + return "osx_framework_library" + implementation_suffixed = f"{sys.implementation.name}_{os.name}" + if implementation_suffixed in _AVAILABLE_SCHEMES: + return implementation_suffixed + if sys.implementation.name in _AVAILABLE_SCHEMES: + return sys.implementation.name + suffixed = f"{os.name}_prefix" + if suffixed in _AVAILABLE_SCHEMES: + return suffixed + if os.name in _AVAILABLE_SCHEMES: # On Windows, prefx is just called "nt". + return os.name + return "posix_prefix" + + +def _infer_user() -> str: + """Try to find a user scheme for the current platform.""" + if _PREFERRED_SCHEME_API: + return _PREFERRED_SCHEME_API("user") + if is_osx_framework() and not running_under_virtualenv(): + suffixed = "osx_framework_user" + else: + suffixed = f"{os.name}_user" + if suffixed in _AVAILABLE_SCHEMES: + return suffixed + if "posix_user" not in _AVAILABLE_SCHEMES: # User scheme unavailable. + raise UserInstallationInvalid() + return "posix_user" + + +def _infer_home() -> str: + """Try to find a home for the current platform.""" + if _PREFERRED_SCHEME_API: + return _PREFERRED_SCHEME_API("home") + suffixed = f"{os.name}_home" + if suffixed in _AVAILABLE_SCHEMES: + return suffixed + return "posix_home" + + +# Update these keys if the user sets a custom home. +_HOME_KEYS = [ + "installed_base", + "base", + "installed_platbase", + "platbase", + "prefix", + "exec_prefix", +] +if sysconfig.get_config_var("userbase") is not None: + _HOME_KEYS.append("userbase") + + +def get_scheme( + dist_name: str, + user: bool = False, + home: typing.Optional[str] = None, + root: typing.Optional[str] = None, + isolated: bool = False, + prefix: typing.Optional[str] = None, +) -> Scheme: + """ + Get the "scheme" corresponding to the input parameters. + + :param dist_name: the name of the package to retrieve the scheme for, used + in the headers scheme path + :param user: indicates to use the "user" scheme + :param home: indicates to use the "home" scheme + :param root: root under which other directories are re-based + :param isolated: ignored, but kept for distutils compatibility (where + this controls whether the user-site pydistutils.cfg is honored) + :param prefix: indicates to use the "prefix" scheme and provides the + base directory for the same + """ + if user and prefix: + raise InvalidSchemeCombination("--user", "--prefix") + if home and prefix: + raise InvalidSchemeCombination("--home", "--prefix") + + if home is not None: + scheme_name = _infer_home() + elif user: + scheme_name = _infer_user() + else: + scheme_name = _infer_prefix() + + # Special case: When installing into a custom prefix, use posix_prefix + # instead of osx_framework_library. See _should_use_osx_framework_prefix() + # docstring for details. + if prefix is not None and scheme_name == "osx_framework_library": + scheme_name = "posix_prefix" + + if home is not None: + variables = {k: home for k in _HOME_KEYS} + elif prefix is not None: + variables = {k: prefix for k in _HOME_KEYS} + else: + variables = {} + + paths = sysconfig.get_paths(scheme=scheme_name, vars=variables) + + # Logic here is very arbitrary, we're doing it for compatibility, don't ask. + # 1. Pip historically uses a special header path in virtual environments. + # 2. If the distribution name is not known, distutils uses 'UNKNOWN'. We + # only do the same when not running in a virtual environment because + # pip's historical header path logic (see point 1) did not do this. + if running_under_virtualenv(): + if user: + base = variables.get("userbase", sys.prefix) + else: + base = variables.get("base", sys.prefix) + python_xy = f"python{get_major_minor_version()}" + paths["include"] = os.path.join(base, "include", "site", python_xy) + elif not dist_name: + dist_name = "UNKNOWN" + + scheme = Scheme( + platlib=paths["platlib"], + purelib=paths["purelib"], + headers=os.path.join(paths["include"], dist_name), + scripts=paths["scripts"], + data=paths["data"], + ) + if root is not None: + converted_keys = {} + for key in SCHEME_KEYS: + converted_keys[key] = change_root(root, getattr(scheme, key)) + scheme = Scheme(**converted_keys) + return scheme + + +def get_bin_prefix() -> str: + # Forcing to use /usr/local/bin for standard macOS framework installs. + if sys.platform[:6] == "darwin" and sys.prefix[:16] == "/System/Library/": + return "/usr/local/bin" + return sysconfig.get_paths()["scripts"] + + +def get_purelib() -> str: + return sysconfig.get_paths()["purelib"] + + +def get_platlib() -> str: + return sysconfig.get_paths()["platlib"] diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/base.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/base.py new file mode 100644 index 0000000000000000000000000000000000000000..3f9f896e632e929a63e9724ab80ecdfc9761b795 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/locations/base.py @@ -0,0 +1,81 @@ +import functools +import os +import site +import sys +import sysconfig +import typing + +from pip._internal.exceptions import InstallationError +from pip._internal.utils import appdirs +from pip._internal.utils.virtualenv import running_under_virtualenv + +# Application Directories +USER_CACHE_DIR = appdirs.user_cache_dir("pip") + +# FIXME doesn't account for venv linked to global site-packages +site_packages: str = sysconfig.get_path("purelib") + + +def get_major_minor_version() -> str: + """ + Return the major-minor version of the current Python as a string, e.g. + "3.7" or "3.10". + """ + return "{}.{}".format(*sys.version_info) + + +def change_root(new_root: str, pathname: str) -> str: + """Return 'pathname' with 'new_root' prepended. + + If 'pathname' is relative, this is equivalent to os.path.join(new_root, pathname). + Otherwise, it requires making 'pathname' relative and then joining the + two, which is tricky on DOS/Windows and Mac OS. + + This is borrowed from Python's standard library's distutils module. + """ + if os.name == "posix": + if not os.path.isabs(pathname): + return os.path.join(new_root, pathname) + else: + return os.path.join(new_root, pathname[1:]) + + elif os.name == "nt": + (drive, path) = os.path.splitdrive(pathname) + if path[0] == "\\": + path = path[1:] + return os.path.join(new_root, path) + + else: + raise InstallationError( + f"Unknown platform: {os.name}\n" + "Can not change root path prefix on unknown platform." + ) + + +def get_src_prefix() -> str: + if running_under_virtualenv(): + src_prefix = os.path.join(sys.prefix, "src") + else: + # FIXME: keep src in cwd for now (it is not a temporary folder) + try: + src_prefix = os.path.join(os.getcwd(), "src") + except OSError: + # In case the current working directory has been renamed or deleted + sys.exit("The folder you are executing pip from can no longer be found.") + + # under macOS + virtualenv sys.prefix is not properly resolved + # it is something like /path/to/python/bin/.. + return os.path.abspath(src_prefix) + + +try: + # Use getusersitepackages if this is present, as it ensures that the + # value is initialised properly. + user_site: typing.Optional[str] = site.getusersitepackages() +except AttributeError: + user_site = site.USER_SITE + + +@functools.lru_cache(maxsize=None) +def is_osx_framework() -> bool: + return bool(sysconfig.get_config_var("PYTHONFRAMEWORK")) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/main.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/main.py new file mode 100644 index 0000000000000000000000000000000000000000..33c6d24cd85b55a9fb1b1e6ab784f471e2b135f0 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/main.py @@ -0,0 +1,12 @@ +from typing import List, Optional + + +def main(args: Optional[List[str]] = None) -> int: + """This is preserved for old console scripts that may still be referencing + it. + + For additional details, see https://github.com/pypa/pip/issues/7498. + """ + from pip._internal.utils.entrypoints import _wrapper + + return _wrapper(args) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..60b62b956bdfb070432a3d2fff976b104496752c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/__init__.py @@ -0,0 +1,162 @@ +import contextlib +import functools +import os +import sys +from typing import List, Literal, Optional, Protocol, Type, cast + +from pip._internal.utils.deprecation import deprecated +from pip._internal.utils.misc import strtobool + +from .base import BaseDistribution, BaseEnvironment, FilesystemWheel, MemoryWheel, Wheel + +__all__ = [ + "BaseDistribution", + "BaseEnvironment", + "FilesystemWheel", + "MemoryWheel", + "Wheel", + "get_default_environment", + "get_environment", + "get_wheel_distribution", + "select_backend", +] + + +def _should_use_importlib_metadata() -> bool: + """Whether to use the ``importlib.metadata`` or ``pkg_resources`` backend. + + By default, pip uses ``importlib.metadata`` on Python 3.11+, and + ``pkg_resources`` otherwise. Up to Python 3.13, This can be + overridden by a couple of ways: + + * If environment variable ``_PIP_USE_IMPORTLIB_METADATA`` is set, it + dictates whether ``importlib.metadata`` is used, for Python <3.14. + * On Python 3.11, 3.12 and 3.13, Python distributors can patch + ``importlib.metadata`` to add a global constant + ``_PIP_USE_IMPORTLIB_METADATA = False``. This makes pip use + ``pkg_resources`` (unless the user set the aforementioned environment + variable to *True*). + + On Python 3.14+, the ``pkg_resources`` backend cannot be used. + """ + if sys.version_info >= (3, 14): + # On Python >=3.14 we only support importlib.metadata. + return True + with contextlib.suppress(KeyError, ValueError): + # On Python <3.14, if the environment variable is set, we obey what it says. + return bool(strtobool(os.environ["_PIP_USE_IMPORTLIB_METADATA"])) + if sys.version_info < (3, 11): + # On Python <3.11, we always use pkg_resources, unless the environment + # variable was set. + return False + # On Python 3.11, 3.12 and 3.13, we check if the global constant is set. + import importlib.metadata + + return bool(getattr(importlib.metadata, "_PIP_USE_IMPORTLIB_METADATA", True)) + + +def _emit_pkg_resources_deprecation_if_needed() -> None: + if sys.version_info < (3, 11): + # All pip versions supporting Python<=3.11 will support pkg_resources, + # and pkg_resources is the default for these, so let's not bother users. + return + + import importlib.metadata + + if hasattr(importlib.metadata, "_PIP_USE_IMPORTLIB_METADATA"): + # The Python distributor has set the global constant, so we don't + # warn, since it is not a user decision. + return + + # The user has decided to use pkg_resources, so we warn. + deprecated( + reason="Using the pkg_resources metadata backend is deprecated.", + replacement=( + "to use the default importlib.metadata backend, " + "by unsetting the _PIP_USE_IMPORTLIB_METADATA environment variable" + ), + gone_in="26.3", + issue=13317, + ) + + +class Backend(Protocol): + NAME: 'Literal["importlib", "pkg_resources"]' + Distribution: Type[BaseDistribution] + Environment: Type[BaseEnvironment] + + +@functools.lru_cache(maxsize=None) +def select_backend() -> Backend: + if _should_use_importlib_metadata(): + from . import importlib + + return cast(Backend, importlib) + + _emit_pkg_resources_deprecation_if_needed() + + from . import pkg_resources + + return cast(Backend, pkg_resources) + + +def get_default_environment() -> BaseEnvironment: + """Get the default representation for the current environment. + + This returns an Environment instance from the chosen backend. The default + Environment instance should be built from ``sys.path`` and may use caching + to share instance state across calls. + """ + return select_backend().Environment.default() + + +def get_environment(paths: Optional[List[str]]) -> BaseEnvironment: + """Get a representation of the environment specified by ``paths``. + + This returns an Environment instance from the chosen backend based on the + given import paths. The backend must build a fresh instance representing + the state of installed distributions when this function is called. + """ + return select_backend().Environment.from_paths(paths) + + +def get_directory_distribution(directory: str) -> BaseDistribution: + """Get the distribution metadata representation in the specified directory. + + This returns a Distribution instance from the chosen backend based on + the given on-disk ``.dist-info`` directory. + """ + return select_backend().Distribution.from_directory(directory) + + +def get_wheel_distribution(wheel: Wheel, canonical_name: str) -> BaseDistribution: + """Get the representation of the specified wheel's distribution metadata. + + This returns a Distribution instance from the chosen backend based on + the given wheel's ``.dist-info`` directory. + + :param canonical_name: Normalized project name of the given wheel. + """ + return select_backend().Distribution.from_wheel(wheel, canonical_name) + + +def get_metadata_distribution( + metadata_contents: bytes, + filename: str, + canonical_name: str, +) -> BaseDistribution: + """Get the dist representation of the specified METADATA file contents. + + This returns a Distribution instance from the chosen backend sourced from the data + in `metadata_contents`. + + :param metadata_contents: Contents of a METADATA file within a dist, or one served + via PEP 658. + :param filename: Filename for the dist this metadata represents. + :param canonical_name: Normalized project name of the given dist. + """ + return select_backend().Distribution.from_metadata_file_contents( + metadata_contents, + filename, + canonical_name, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/_json.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/_json.py new file mode 100644 index 0000000000000000000000000000000000000000..f3aeab3225ffc8f6a63f5f40d351e04be4e3c647 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/_json.py @@ -0,0 +1,86 @@ +# Extracted from https://github.com/pfmoore/pkg_metadata + +from email.header import Header, decode_header, make_header +from email.message import Message +from typing import Any, Dict, List, Union, cast + +METADATA_FIELDS = [ + # Name, Multiple-Use + ("Metadata-Version", False), + ("Name", False), + ("Version", False), + ("Dynamic", True), + ("Platform", True), + ("Supported-Platform", True), + ("Summary", False), + ("Description", False), + ("Description-Content-Type", False), + ("Keywords", False), + ("Home-page", False), + ("Download-URL", False), + ("Author", False), + ("Author-email", False), + ("Maintainer", False), + ("Maintainer-email", False), + ("License", False), + ("License-Expression", False), + ("License-File", True), + ("Classifier", True), + ("Requires-Dist", True), + ("Requires-Python", False), + ("Requires-External", True), + ("Project-URL", True), + ("Provides-Extra", True), + ("Provides-Dist", True), + ("Obsoletes-Dist", True), +] + + +def json_name(field: str) -> str: + return field.lower().replace("-", "_") + + +def msg_to_json(msg: Message) -> Dict[str, Any]: + """Convert a Message object into a JSON-compatible dictionary.""" + + def sanitise_header(h: Union[Header, str]) -> str: + if isinstance(h, Header): + chunks = [] + for bytes, encoding in decode_header(h): + if encoding == "unknown-8bit": + try: + # See if UTF-8 works + bytes.decode("utf-8") + encoding = "utf-8" + except UnicodeDecodeError: + # If not, latin1 at least won't fail + encoding = "latin1" + chunks.append((bytes, encoding)) + return str(make_header(chunks)) + return str(h) + + result = {} + for field, multi in METADATA_FIELDS: + if field not in msg: + continue + key = json_name(field) + if multi: + value: Union[str, List[str]] = [ + sanitise_header(v) for v in msg.get_all(field) # type: ignore + ] + else: + value = sanitise_header(msg.get(field)) # type: ignore + if key == "keywords": + # Accept both comma-separated and space-separated + # forms, for better compatibility with old data. + if "," in value: + value = [v.strip() for v in value.split(",")] + else: + value = value.split() + result[key] = value + + payload = cast(str, msg.get_payload()) + if payload: + result["description"] = payload + + return result diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/base.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/base.py new file mode 100644 index 0000000000000000000000000000000000000000..ea5a0756dd7c6cf6a40d429108263d0b51c4eb36 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/base.py @@ -0,0 +1,690 @@ +import csv +import email.message +import functools +import json +import logging +import pathlib +import re +import zipfile +from typing import ( + IO, + Any, + Collection, + Container, + Dict, + Iterable, + Iterator, + List, + NamedTuple, + Optional, + Protocol, + Tuple, + Union, +) + +from pip._vendor.packaging.requirements import Requirement +from pip._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet +from pip._vendor.packaging.utils import NormalizedName, canonicalize_name +from pip._vendor.packaging.version import Version + +from pip._internal.exceptions import NoneMetadataError +from pip._internal.locations import site_packages, user_site +from pip._internal.models.direct_url import ( + DIRECT_URL_METADATA_NAME, + DirectUrl, + DirectUrlValidationError, +) +from pip._internal.utils.compat import stdlib_pkgs # TODO: Move definition here. +from pip._internal.utils.egg_link import egg_link_path_from_sys_path +from pip._internal.utils.misc import is_local, normalize_path +from pip._internal.utils.urls import url_to_path + +from ._json import msg_to_json + +InfoPath = Union[str, pathlib.PurePath] + +logger = logging.getLogger(__name__) + + +class BaseEntryPoint(Protocol): + @property + def name(self) -> str: + raise NotImplementedError() + + @property + def value(self) -> str: + raise NotImplementedError() + + @property + def group(self) -> str: + raise NotImplementedError() + + +def _convert_installed_files_path( + entry: Tuple[str, ...], + info: Tuple[str, ...], +) -> str: + """Convert a legacy installed-files.txt path into modern RECORD path. + + The legacy format stores paths relative to the info directory, while the + modern format stores paths relative to the package root, e.g. the + site-packages directory. + + :param entry: Path parts of the installed-files.txt entry. + :param info: Path parts of the egg-info directory relative to package root. + :returns: The converted entry. + + For best compatibility with symlinks, this does not use ``abspath()`` or + ``Path.resolve()``, but tries to work with path parts: + + 1. While ``entry`` starts with ``..``, remove the equal amounts of parts + from ``info``; if ``info`` is empty, start appending ``..`` instead. + 2. Join the two directly. + """ + while entry and entry[0] == "..": + if not info or info[-1] == "..": + info += ("..",) + else: + info = info[:-1] + entry = entry[1:] + return str(pathlib.Path(*info, *entry)) + + +class RequiresEntry(NamedTuple): + requirement: str + extra: str + marker: str + + +class BaseDistribution(Protocol): + @classmethod + def from_directory(cls, directory: str) -> "BaseDistribution": + """Load the distribution from a metadata directory. + + :param directory: Path to a metadata directory, e.g. ``.dist-info``. + """ + raise NotImplementedError() + + @classmethod + def from_metadata_file_contents( + cls, + metadata_contents: bytes, + filename: str, + project_name: str, + ) -> "BaseDistribution": + """Load the distribution from the contents of a METADATA file. + + This is used to implement PEP 658 by generating a "shallow" dist object that can + be used for resolution without downloading or building the actual dist yet. + + :param metadata_contents: The contents of a METADATA file. + :param filename: File name for the dist with this metadata. + :param project_name: Name of the project this dist represents. + """ + raise NotImplementedError() + + @classmethod + def from_wheel(cls, wheel: "Wheel", name: str) -> "BaseDistribution": + """Load the distribution from a given wheel. + + :param wheel: A concrete wheel definition. + :param name: File name of the wheel. + + :raises InvalidWheel: Whenever loading of the wheel causes a + :py:exc:`zipfile.BadZipFile` exception to be thrown. + :raises UnsupportedWheel: If the wheel is a valid zip, but malformed + internally. + """ + raise NotImplementedError() + + def __repr__(self) -> str: + return f"{self.raw_name} {self.raw_version} ({self.location})" + + def __str__(self) -> str: + return f"{self.raw_name} {self.raw_version}" + + @property + def location(self) -> Optional[str]: + """Where the distribution is loaded from. + + A string value is not necessarily a filesystem path, since distributions + can be loaded from other sources, e.g. arbitrary zip archives. ``None`` + means the distribution is created in-memory. + + Do not canonicalize this value with e.g. ``pathlib.Path.resolve()``. If + this is a symbolic link, we want to preserve the relative path between + it and files in the distribution. + """ + raise NotImplementedError() + + @property + def editable_project_location(self) -> Optional[str]: + """The project location for editable distributions. + + This is the directory where pyproject.toml or setup.py is located. + None if the distribution is not installed in editable mode. + """ + # TODO: this property is relatively costly to compute, memoize it ? + direct_url = self.direct_url + if direct_url: + if direct_url.is_local_editable(): + return url_to_path(direct_url.url) + else: + # Search for an .egg-link file by walking sys.path, as it was + # done before by dist_is_editable(). + egg_link_path = egg_link_path_from_sys_path(self.raw_name) + if egg_link_path: + # TODO: get project location from second line of egg_link file + # (https://github.com/pypa/pip/issues/10243) + return self.location + return None + + @property + def installed_location(self) -> Optional[str]: + """The distribution's "installed" location. + + This should generally be a ``site-packages`` directory. This is + usually ``dist.location``, except for legacy develop-installed packages, + where ``dist.location`` is the source code location, and this is where + the ``.egg-link`` file is. + + The returned location is normalized (in particular, with symlinks removed). + """ + raise NotImplementedError() + + @property + def info_location(self) -> Optional[str]: + """Location of the .[egg|dist]-info directory or file. + + Similarly to ``location``, a string value is not necessarily a + filesystem path. ``None`` means the distribution is created in-memory. + + For a modern .dist-info installation on disk, this should be something + like ``{location}/{raw_name}-{version}.dist-info``. + + Do not canonicalize this value with e.g. ``pathlib.Path.resolve()``. If + this is a symbolic link, we want to preserve the relative path between + it and other files in the distribution. + """ + raise NotImplementedError() + + @property + def installed_by_distutils(self) -> bool: + """Whether this distribution is installed with legacy distutils format. + + A distribution installed with "raw" distutils not patched by setuptools + uses one single file at ``info_location`` to store metadata. We need to + treat this specially on uninstallation. + """ + info_location = self.info_location + if not info_location: + return False + return pathlib.Path(info_location).is_file() + + @property + def installed_as_egg(self) -> bool: + """Whether this distribution is installed as an egg. + + This usually indicates the distribution was installed by (older versions + of) easy_install. + """ + location = self.location + if not location: + return False + # XXX if the distribution is a zipped egg, location has a trailing / + # so we resort to pathlib.Path to check the suffix in a reliable way. + return pathlib.Path(location).suffix == ".egg" + + @property + def installed_with_setuptools_egg_info(self) -> bool: + """Whether this distribution is installed with the ``.egg-info`` format. + + This usually indicates the distribution was installed with setuptools + with an old pip version or with ``single-version-externally-managed``. + + Note that this ensure the metadata store is a directory. distutils can + also installs an ``.egg-info``, but as a file, not a directory. This + property is *False* for that case. Also see ``installed_by_distutils``. + """ + info_location = self.info_location + if not info_location: + return False + if not info_location.endswith(".egg-info"): + return False + return pathlib.Path(info_location).is_dir() + + @property + def installed_with_dist_info(self) -> bool: + """Whether this distribution is installed with the "modern format". + + This indicates a "modern" installation, e.g. storing metadata in the + ``.dist-info`` directory. This applies to installations made by + setuptools (but through pip, not directly), or anything using the + standardized build backend interface (PEP 517). + """ + info_location = self.info_location + if not info_location: + return False + if not info_location.endswith(".dist-info"): + return False + return pathlib.Path(info_location).is_dir() + + @property + def canonical_name(self) -> NormalizedName: + raise NotImplementedError() + + @property + def version(self) -> Version: + raise NotImplementedError() + + @property + def raw_version(self) -> str: + raise NotImplementedError() + + @property + def setuptools_filename(self) -> str: + """Convert a project name to its setuptools-compatible filename. + + This is a copy of ``pkg_resources.to_filename()`` for compatibility. + """ + return self.raw_name.replace("-", "_") + + @property + def direct_url(self) -> Optional[DirectUrl]: + """Obtain a DirectUrl from this distribution. + + Returns None if the distribution has no `direct_url.json` metadata, + or if `direct_url.json` is invalid. + """ + try: + content = self.read_text(DIRECT_URL_METADATA_NAME) + except FileNotFoundError: + return None + try: + return DirectUrl.from_json(content) + except ( + UnicodeDecodeError, + json.JSONDecodeError, + DirectUrlValidationError, + ) as e: + logger.warning( + "Error parsing %s for %s: %s", + DIRECT_URL_METADATA_NAME, + self.canonical_name, + e, + ) + return None + + @property + def installer(self) -> str: + try: + installer_text = self.read_text("INSTALLER") + except (OSError, ValueError, NoneMetadataError): + return "" # Fail silently if the installer file cannot be read. + for line in installer_text.splitlines(): + cleaned_line = line.strip() + if cleaned_line: + return cleaned_line + return "" + + @property + def requested(self) -> bool: + return self.is_file("REQUESTED") + + @property + def editable(self) -> bool: + return bool(self.editable_project_location) + + @property + def local(self) -> bool: + """If distribution is installed in the current virtual environment. + + Always True if we're not in a virtualenv. + """ + if self.installed_location is None: + return False + return is_local(self.installed_location) + + @property + def in_usersite(self) -> bool: + if self.installed_location is None or user_site is None: + return False + return self.installed_location.startswith(normalize_path(user_site)) + + @property + def in_site_packages(self) -> bool: + if self.installed_location is None or site_packages is None: + return False + return self.installed_location.startswith(normalize_path(site_packages)) + + def is_file(self, path: InfoPath) -> bool: + """Check whether an entry in the info directory is a file.""" + raise NotImplementedError() + + def iter_distutils_script_names(self) -> Iterator[str]: + """Find distutils 'scripts' entries metadata. + + If 'scripts' is supplied in ``setup.py``, distutils records those in the + installed distribution's ``scripts`` directory, a file for each script. + """ + raise NotImplementedError() + + def read_text(self, path: InfoPath) -> str: + """Read a file in the info directory. + + :raise FileNotFoundError: If ``path`` does not exist in the directory. + :raise NoneMetadataError: If ``path`` exists in the info directory, but + cannot be read. + """ + raise NotImplementedError() + + def iter_entry_points(self) -> Iterable[BaseEntryPoint]: + raise NotImplementedError() + + def _metadata_impl(self) -> email.message.Message: + raise NotImplementedError() + + @functools.cached_property + def metadata(self) -> email.message.Message: + """Metadata of distribution parsed from e.g. METADATA or PKG-INFO. + + This should return an empty message if the metadata file is unavailable. + + :raises NoneMetadataError: If the metadata file is available, but does + not contain valid metadata. + """ + metadata = self._metadata_impl() + self._add_egg_info_requires(metadata) + return metadata + + @property + def metadata_dict(self) -> Dict[str, Any]: + """PEP 566 compliant JSON-serializable representation of METADATA or PKG-INFO. + + This should return an empty dict if the metadata file is unavailable. + + :raises NoneMetadataError: If the metadata file is available, but does + not contain valid metadata. + """ + return msg_to_json(self.metadata) + + @property + def metadata_version(self) -> Optional[str]: + """Value of "Metadata-Version:" in distribution metadata, if available.""" + return self.metadata.get("Metadata-Version") + + @property + def raw_name(self) -> str: + """Value of "Name:" in distribution metadata.""" + # The metadata should NEVER be missing the Name: key, but if it somehow + # does, fall back to the known canonical name. + return self.metadata.get("Name", self.canonical_name) + + @property + def requires_python(self) -> SpecifierSet: + """Value of "Requires-Python:" in distribution metadata. + + If the key does not exist or contains an invalid value, an empty + SpecifierSet should be returned. + """ + value = self.metadata.get("Requires-Python") + if value is None: + return SpecifierSet() + try: + # Convert to str to satisfy the type checker; this can be a Header object. + spec = SpecifierSet(str(value)) + except InvalidSpecifier as e: + message = "Package %r has an invalid Requires-Python: %s" + logger.warning(message, self.raw_name, e) + return SpecifierSet() + return spec + + def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]: + """Dependencies of this distribution. + + For modern .dist-info distributions, this is the collection of + "Requires-Dist:" entries in distribution metadata. + """ + raise NotImplementedError() + + def iter_raw_dependencies(self) -> Iterable[str]: + """Raw Requires-Dist metadata.""" + return self.metadata.get_all("Requires-Dist", []) + + def iter_provided_extras(self) -> Iterable[NormalizedName]: + """Extras provided by this distribution. + + For modern .dist-info distributions, this is the collection of + "Provides-Extra:" entries in distribution metadata. + + The return value of this function is expected to be normalised names, + per PEP 685, with the returned value being handled appropriately by + `iter_dependencies`. + """ + raise NotImplementedError() + + def _iter_declared_entries_from_record(self) -> Optional[Iterator[str]]: + try: + text = self.read_text("RECORD") + except FileNotFoundError: + return None + # This extra Path-str cast normalizes entries. + return (str(pathlib.Path(row[0])) for row in csv.reader(text.splitlines())) + + def _iter_declared_entries_from_legacy(self) -> Optional[Iterator[str]]: + try: + text = self.read_text("installed-files.txt") + except FileNotFoundError: + return None + paths = (p for p in text.splitlines(keepends=False) if p) + root = self.location + info = self.info_location + if root is None or info is None: + return paths + try: + info_rel = pathlib.Path(info).relative_to(root) + except ValueError: # info is not relative to root. + return paths + if not info_rel.parts: # info *is* root. + return paths + return ( + _convert_installed_files_path(pathlib.Path(p).parts, info_rel.parts) + for p in paths + ) + + def iter_declared_entries(self) -> Optional[Iterator[str]]: + """Iterate through file entries declared in this distribution. + + For modern .dist-info distributions, this is the files listed in the + ``RECORD`` metadata file. For legacy setuptools distributions, this + comes from ``installed-files.txt``, with entries normalized to be + compatible with the format used by ``RECORD``. + + :return: An iterator for listed entries, or None if the distribution + contains neither ``RECORD`` nor ``installed-files.txt``. + """ + return ( + self._iter_declared_entries_from_record() + or self._iter_declared_entries_from_legacy() + ) + + def _iter_requires_txt_entries(self) -> Iterator[RequiresEntry]: + """Parse a ``requires.txt`` in an egg-info directory. + + This is an INI-ish format where an egg-info stores dependencies. A + section name describes extra other environment markers, while each entry + is an arbitrary string (not a key-value pair) representing a dependency + as a requirement string (no markers). + + There is a construct in ``importlib.metadata`` called ``Sectioned`` that + does mostly the same, but the format is currently considered private. + """ + try: + content = self.read_text("requires.txt") + except FileNotFoundError: + return + extra = marker = "" # Section-less entries don't have markers. + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#"): # Comment; ignored. + continue + if line.startswith("[") and line.endswith("]"): # A section header. + extra, _, marker = line.strip("[]").partition(":") + continue + yield RequiresEntry(requirement=line, extra=extra, marker=marker) + + def _iter_egg_info_extras(self) -> Iterable[str]: + """Get extras from the egg-info directory.""" + known_extras = {""} + for entry in self._iter_requires_txt_entries(): + extra = canonicalize_name(entry.extra) + if extra in known_extras: + continue + known_extras.add(extra) + yield extra + + def _iter_egg_info_dependencies(self) -> Iterable[str]: + """Get distribution dependencies from the egg-info directory. + + To ease parsing, this converts a legacy dependency entry into a PEP 508 + requirement string. Like ``_iter_requires_txt_entries()``, there is code + in ``importlib.metadata`` that does mostly the same, but not do exactly + what we need. + + Namely, ``importlib.metadata`` does not normalize the extra name before + putting it into the requirement string, which causes marker comparison + to fail because the dist-info format do normalize. This is consistent in + all currently available PEP 517 backends, although not standardized. + """ + for entry in self._iter_requires_txt_entries(): + extra = canonicalize_name(entry.extra) + if extra and entry.marker: + marker = f'({entry.marker}) and extra == "{extra}"' + elif extra: + marker = f'extra == "{extra}"' + elif entry.marker: + marker = entry.marker + else: + marker = "" + if marker: + yield f"{entry.requirement} ; {marker}" + else: + yield entry.requirement + + def _add_egg_info_requires(self, metadata: email.message.Message) -> None: + """Add egg-info requires.txt information to the metadata.""" + if not metadata.get_all("Requires-Dist"): + for dep in self._iter_egg_info_dependencies(): + metadata["Requires-Dist"] = dep + if not metadata.get_all("Provides-Extra"): + for extra in self._iter_egg_info_extras(): + metadata["Provides-Extra"] = extra + + +class BaseEnvironment: + """An environment containing distributions to introspect.""" + + @classmethod + def default(cls) -> "BaseEnvironment": + raise NotImplementedError() + + @classmethod + def from_paths(cls, paths: Optional[List[str]]) -> "BaseEnvironment": + raise NotImplementedError() + + def get_distribution(self, name: str) -> Optional["BaseDistribution"]: + """Given a requirement name, return the installed distributions. + + The name may not be normalized. The implementation must canonicalize + it for lookup. + """ + raise NotImplementedError() + + def _iter_distributions(self) -> Iterator["BaseDistribution"]: + """Iterate through installed distributions. + + This function should be implemented by subclass, but never called + directly. Use the public ``iter_distribution()`` instead, which + implements additional logic to make sure the distributions are valid. + """ + raise NotImplementedError() + + def iter_all_distributions(self) -> Iterator[BaseDistribution]: + """Iterate through all installed distributions without any filtering.""" + for dist in self._iter_distributions(): + # Make sure the distribution actually comes from a valid Python + # packaging distribution. Pip's AdjacentTempDirectory leaves folders + # e.g. ``~atplotlib.dist-info`` if cleanup was interrupted. The + # valid project name pattern is taken from PEP 508. + project_name_valid = re.match( + r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", + dist.canonical_name, + flags=re.IGNORECASE, + ) + if not project_name_valid: + logger.warning( + "Ignoring invalid distribution %s (%s)", + dist.canonical_name, + dist.location, + ) + continue + yield dist + + def iter_installed_distributions( + self, + local_only: bool = True, + skip: Container[str] = stdlib_pkgs, + include_editables: bool = True, + editables_only: bool = False, + user_only: bool = False, + ) -> Iterator[BaseDistribution]: + """Return a list of installed distributions. + + This is based on ``iter_all_distributions()`` with additional filtering + options. Note that ``iter_installed_distributions()`` without arguments + is *not* equal to ``iter_all_distributions()``, since some of the + configurations exclude packages by default. + + :param local_only: If True (default), only return installations + local to the current virtualenv, if in a virtualenv. + :param skip: An iterable of canonicalized project names to ignore; + defaults to ``stdlib_pkgs``. + :param include_editables: If False, don't report editables. + :param editables_only: If True, only report editables. + :param user_only: If True, only report installations in the user + site directory. + """ + it = self.iter_all_distributions() + if local_only: + it = (d for d in it if d.local) + if not include_editables: + it = (d for d in it if not d.editable) + if editables_only: + it = (d for d in it if d.editable) + if user_only: + it = (d for d in it if d.in_usersite) + return (d for d in it if d.canonical_name not in skip) + + +class Wheel(Protocol): + location: str + + def as_zipfile(self) -> zipfile.ZipFile: + raise NotImplementedError() + + +class FilesystemWheel(Wheel): + def __init__(self, location: str) -> None: + self.location = location + + def as_zipfile(self) -> zipfile.ZipFile: + return zipfile.ZipFile(self.location, allowZip64=True) + + +class MemoryWheel(Wheel): + def __init__(self, location: str, stream: IO[bytes]) -> None: + self.location = location + self.stream = stream + + def as_zipfile(self) -> zipfile.ZipFile: + return zipfile.ZipFile(self.stream, allowZip64=True) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a779138db1040d3903c2bb66ecb2f52a46879dae --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/__init__.py @@ -0,0 +1,6 @@ +from ._dists import Distribution +from ._envs import Environment + +__all__ = ["NAME", "Distribution", "Environment"] + +NAME = "importlib" diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_compat.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_compat.py new file mode 100644 index 0000000000000000000000000000000000000000..ec1e815cdbdf517ec8b3565d7dafb7e67ce1705c --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_compat.py @@ -0,0 +1,85 @@ +import importlib.metadata +import os +from typing import Any, Optional, Protocol, Tuple, cast + +from pip._vendor.packaging.utils import NormalizedName, canonicalize_name + + +class BadMetadata(ValueError): + def __init__(self, dist: importlib.metadata.Distribution, *, reason: str) -> None: + self.dist = dist + self.reason = reason + + def __str__(self) -> str: + return f"Bad metadata in {self.dist} ({self.reason})" + + +class BasePath(Protocol): + """A protocol that various path objects conform. + + This exists because importlib.metadata uses both ``pathlib.Path`` and + ``zipfile.Path``, and we need a common base for type hints (Union does not + work well since ``zipfile.Path`` is too new for our linter setup). + + This does not mean to be exhaustive, but only contains things that present + in both classes *that we need*. + """ + + @property + def name(self) -> str: + raise NotImplementedError() + + @property + def parent(self) -> "BasePath": + raise NotImplementedError() + + +def get_info_location(d: importlib.metadata.Distribution) -> Optional[BasePath]: + """Find the path to the distribution's metadata directory. + + HACK: This relies on importlib.metadata's private ``_path`` attribute. Not + all distributions exist on disk, so importlib.metadata is correct to not + expose the attribute as public. But pip's code base is old and not as clean, + so we do this to avoid having to rewrite too many things. Hopefully we can + eliminate this some day. + """ + return getattr(d, "_path", None) + + +def parse_name_and_version_from_info_directory( + dist: importlib.metadata.Distribution, +) -> Tuple[Optional[str], Optional[str]]: + """Get a name and version from the metadata directory name. + + This is much faster than reading distribution metadata. + """ + info_location = get_info_location(dist) + if info_location is None: + return None, None + + stem, suffix = os.path.splitext(info_location.name) + if suffix == ".dist-info": + name, sep, version = stem.partition("-") + if sep: + return name, version + + if suffix == ".egg-info": + name = stem.split("-", 1)[0] + return name, None + + return None, None + + +def get_dist_canonical_name(dist: importlib.metadata.Distribution) -> NormalizedName: + """Get the distribution's normalized name. + + The ``name`` attribute is only available in Python 3.10 or later. We are + targeting exactly that, but Mypy does not know this. + """ + if name := parse_name_and_version_from_info_directory(dist)[0]: + return canonicalize_name(name) + + name = cast(Any, dist).name + if not isinstance(name, str): + raise BadMetadata(dist, reason="invalid metadata entry 'name'") + return canonicalize_name(name) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_dists.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_dists.py new file mode 100644 index 0000000000000000000000000000000000000000..d220b616e2860407336cfaed32c6d5972e7688dd --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_dists.py @@ -0,0 +1,228 @@ +import email.message +import importlib.metadata +import pathlib +import zipfile +from os import PathLike +from typing import ( + Collection, + Dict, + Iterable, + Iterator, + Mapping, + Optional, + Sequence, + Union, + cast, +) + +from pip._vendor.packaging.requirements import Requirement +from pip._vendor.packaging.utils import NormalizedName, canonicalize_name +from pip._vendor.packaging.version import Version +from pip._vendor.packaging.version import parse as parse_version + +from pip._internal.exceptions import InvalidWheel, UnsupportedWheel +from pip._internal.metadata.base import ( + BaseDistribution, + BaseEntryPoint, + InfoPath, + Wheel, +) +from pip._internal.utils.misc import normalize_path +from pip._internal.utils.packaging import get_requirement +from pip._internal.utils.temp_dir import TempDirectory +from pip._internal.utils.wheel import parse_wheel, read_wheel_metadata_file + +from ._compat import ( + BasePath, + get_dist_canonical_name, + parse_name_and_version_from_info_directory, +) + + +class WheelDistribution(importlib.metadata.Distribution): + """An ``importlib.metadata.Distribution`` read from a wheel. + + Although ``importlib.metadata.PathDistribution`` accepts ``zipfile.Path``, + its implementation is too "lazy" for pip's needs (we can't keep the ZipFile + handle open for the entire lifetime of the distribution object). + + This implementation eagerly reads the entire metadata directory into the + memory instead, and operates from that. + """ + + def __init__( + self, + files: Mapping[pathlib.PurePosixPath, bytes], + info_location: pathlib.PurePosixPath, + ) -> None: + self._files = files + self.info_location = info_location + + @classmethod + def from_zipfile( + cls, + zf: zipfile.ZipFile, + name: str, + location: str, + ) -> "WheelDistribution": + info_dir, _ = parse_wheel(zf, name) + paths = ( + (name, pathlib.PurePosixPath(name.split("/", 1)[-1])) + for name in zf.namelist() + if name.startswith(f"{info_dir}/") + ) + files = { + relpath: read_wheel_metadata_file(zf, fullpath) + for fullpath, relpath in paths + } + info_location = pathlib.PurePosixPath(location, info_dir) + return cls(files, info_location) + + def iterdir(self, path: InfoPath) -> Iterator[pathlib.PurePosixPath]: + # Only allow iterating through the metadata directory. + if pathlib.PurePosixPath(str(path)) in self._files: + return iter(self._files) + raise FileNotFoundError(path) + + def read_text(self, filename: str) -> Optional[str]: + try: + data = self._files[pathlib.PurePosixPath(filename)] + except KeyError: + return None + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + wheel = self.info_location.parent + error = f"Error decoding metadata for {wheel}: {e} in {filename} file" + raise UnsupportedWheel(error) + return text + + def locate_file(self, path: Union[str, "PathLike[str]"]) -> pathlib.Path: + # This method doesn't make sense for our in-memory wheel, but the API + # requires us to define it. + raise NotImplementedError + + +class Distribution(BaseDistribution): + def __init__( + self, + dist: importlib.metadata.Distribution, + info_location: Optional[BasePath], + installed_location: Optional[BasePath], + ) -> None: + self._dist = dist + self._info_location = info_location + self._installed_location = installed_location + + @classmethod + def from_directory(cls, directory: str) -> BaseDistribution: + info_location = pathlib.Path(directory) + dist = importlib.metadata.Distribution.at(info_location) + return cls(dist, info_location, info_location.parent) + + @classmethod + def from_metadata_file_contents( + cls, + metadata_contents: bytes, + filename: str, + project_name: str, + ) -> BaseDistribution: + # Generate temp dir to contain the metadata file, and write the file contents. + temp_dir = pathlib.Path( + TempDirectory(kind="metadata", globally_managed=True).path + ) + metadata_path = temp_dir / "METADATA" + metadata_path.write_bytes(metadata_contents) + # Construct dist pointing to the newly created directory. + dist = importlib.metadata.Distribution.at(metadata_path.parent) + return cls(dist, metadata_path.parent, None) + + @classmethod + def from_wheel(cls, wheel: Wheel, name: str) -> BaseDistribution: + try: + with wheel.as_zipfile() as zf: + dist = WheelDistribution.from_zipfile(zf, name, wheel.location) + except zipfile.BadZipFile as e: + raise InvalidWheel(wheel.location, name) from e + return cls(dist, dist.info_location, pathlib.PurePosixPath(wheel.location)) + + @property + def location(self) -> Optional[str]: + if self._info_location is None: + return None + return str(self._info_location.parent) + + @property + def info_location(self) -> Optional[str]: + if self._info_location is None: + return None + return str(self._info_location) + + @property + def installed_location(self) -> Optional[str]: + if self._installed_location is None: + return None + return normalize_path(str(self._installed_location)) + + @property + def canonical_name(self) -> NormalizedName: + return get_dist_canonical_name(self._dist) + + @property + def version(self) -> Version: + if version := parse_name_and_version_from_info_directory(self._dist)[1]: + return parse_version(version) + return parse_version(self._dist.version) + + @property + def raw_version(self) -> str: + return self._dist.version + + def is_file(self, path: InfoPath) -> bool: + return self._dist.read_text(str(path)) is not None + + def iter_distutils_script_names(self) -> Iterator[str]: + # A distutils installation is always "flat" (not in e.g. egg form), so + # if this distribution's info location is NOT a pathlib.Path (but e.g. + # zipfile.Path), it can never contain any distutils scripts. + if not isinstance(self._info_location, pathlib.Path): + return + for child in self._info_location.joinpath("scripts").iterdir(): + yield child.name + + def read_text(self, path: InfoPath) -> str: + content = self._dist.read_text(str(path)) + if content is None: + raise FileNotFoundError(path) + return content + + def iter_entry_points(self) -> Iterable[BaseEntryPoint]: + # importlib.metadata's EntryPoint structure satisfies BaseEntryPoint. + return self._dist.entry_points + + def _metadata_impl(self) -> email.message.Message: + # From Python 3.10+, importlib.metadata declares PackageMetadata as the + # return type. This protocol is unfortunately a disaster now and misses + # a ton of fields that we need, including get() and get_payload(). We + # rely on the implementation that the object is actually a Message now, + # until upstream can improve the protocol. (python/cpython#94952) + return cast(email.message.Message, self._dist.metadata) + + def iter_provided_extras(self) -> Iterable[NormalizedName]: + return [ + canonicalize_name(extra) + for extra in self.metadata.get_all("Provides-Extra", []) + ] + + def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]: + contexts: Sequence[Dict[str, str]] = [{"extra": e} for e in extras] + for req_string in self.metadata.get_all("Requires-Dist", []): + # strip() because email.message.Message.get_all() may return a leading \n + # in case a long header was wrapped. + req = get_requirement(req_string.strip()) + if not req.marker: + yield req + elif not extras and req.marker.evaluate({"extra": ""}): + yield req + elif any(req.marker.evaluate(context) for context in contexts): + yield req diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_envs.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_envs.py new file mode 100644 index 0000000000000000000000000000000000000000..314e75e673198808f22377ac5eb759254dcfd61b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/importlib/_envs.py @@ -0,0 +1,140 @@ +import importlib.metadata +import logging +import os +import pathlib +import sys +import zipfile +from typing import Iterator, List, Optional, Sequence, Set, Tuple + +from pip._vendor.packaging.utils import ( + InvalidWheelFilename, + NormalizedName, + canonicalize_name, + parse_wheel_filename, +) + +from pip._internal.metadata.base import BaseDistribution, BaseEnvironment +from pip._internal.utils.filetypes import WHEEL_EXTENSION + +from ._compat import BadMetadata, BasePath, get_dist_canonical_name, get_info_location +from ._dists import Distribution + +logger = logging.getLogger(__name__) + + +def _looks_like_wheel(location: str) -> bool: + if not location.endswith(WHEEL_EXTENSION): + return False + if not os.path.isfile(location): + return False + try: + parse_wheel_filename(os.path.basename(location)) + except InvalidWheelFilename: + return False + return zipfile.is_zipfile(location) + + +class _DistributionFinder: + """Finder to locate distributions. + + The main purpose of this class is to memoize found distributions' names, so + only one distribution is returned for each package name. At lot of pip code + assumes this (because it is setuptools's behavior), and not doing the same + can potentially cause a distribution in lower precedence path to override a + higher precedence one if the caller is not careful. + + Eventually we probably want to make it possible to see lower precedence + installations as well. It's useful feature, after all. + """ + + FoundResult = Tuple[importlib.metadata.Distribution, Optional[BasePath]] + + def __init__(self) -> None: + self._found_names: Set[NormalizedName] = set() + + def _find_impl(self, location: str) -> Iterator[FoundResult]: + """Find distributions in a location.""" + # Skip looking inside a wheel. Since a package inside a wheel is not + # always valid (due to .data directories etc.), its .dist-info entry + # should not be considered an installed distribution. + if _looks_like_wheel(location): + return + # To know exactly where we find a distribution, we have to feed in the + # paths one by one, instead of dumping the list to importlib.metadata. + for dist in importlib.metadata.distributions(path=[location]): + info_location = get_info_location(dist) + try: + name = get_dist_canonical_name(dist) + except BadMetadata as e: + logger.warning("Skipping %s due to %s", info_location, e.reason) + continue + if name in self._found_names: + continue + self._found_names.add(name) + yield dist, info_location + + def find(self, location: str) -> Iterator[BaseDistribution]: + """Find distributions in a location. + + The path can be either a directory, or a ZIP archive. + """ + for dist, info_location in self._find_impl(location): + if info_location is None: + installed_location: Optional[BasePath] = None + else: + installed_location = info_location.parent + yield Distribution(dist, info_location, installed_location) + + def find_legacy_editables(self, location: str) -> Iterator[BaseDistribution]: + """Read location in egg-link files and return distributions in there. + + The path should be a directory; otherwise this returns nothing. This + follows how setuptools does this for compatibility. The first non-empty + line in the egg-link is read as a path (resolved against the egg-link's + containing directory if relative). Distributions found at that linked + location are returned. + """ + path = pathlib.Path(location) + if not path.is_dir(): + return + for child in path.iterdir(): + if child.suffix != ".egg-link": + continue + with child.open() as f: + lines = (line.strip() for line in f) + target_rel = next((line for line in lines if line), "") + if not target_rel: + continue + target_location = str(path.joinpath(target_rel)) + for dist, info_location in self._find_impl(target_location): + yield Distribution(dist, info_location, path) + + +class Environment(BaseEnvironment): + def __init__(self, paths: Sequence[str]) -> None: + self._paths = paths + + @classmethod + def default(cls) -> BaseEnvironment: + return cls(sys.path) + + @classmethod + def from_paths(cls, paths: Optional[List[str]]) -> BaseEnvironment: + if paths is None: + return cls(sys.path) + return cls(paths) + + def _iter_distributions(self) -> Iterator[BaseDistribution]: + finder = _DistributionFinder() + for location in self._paths: + yield from finder.find(location) + yield from finder.find_legacy_editables(location) + + def get_distribution(self, name: str) -> Optional[BaseDistribution]: + canonical_name = canonicalize_name(name) + matches = ( + distribution + for distribution in self.iter_all_distributions() + if distribution.canonical_name == canonical_name + ) + return next(matches, None) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/pkg_resources.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/pkg_resources.py new file mode 100644 index 0000000000000000000000000000000000000000..4ea84f93a6fb8f2d04230d70491eac7809672031 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/metadata/pkg_resources.py @@ -0,0 +1,301 @@ +import email.message +import email.parser +import logging +import os +import zipfile +from typing import ( + Collection, + Iterable, + Iterator, + List, + Mapping, + NamedTuple, + Optional, +) + +from pip._vendor import pkg_resources +from pip._vendor.packaging.requirements import Requirement +from pip._vendor.packaging.utils import NormalizedName, canonicalize_name +from pip._vendor.packaging.version import Version +from pip._vendor.packaging.version import parse as parse_version + +from pip._internal.exceptions import InvalidWheel, NoneMetadataError, UnsupportedWheel +from pip._internal.utils.egg_link import egg_link_path_from_location +from pip._internal.utils.misc import display_path, normalize_path +from pip._internal.utils.wheel import parse_wheel, read_wheel_metadata_file + +from .base import ( + BaseDistribution, + BaseEntryPoint, + BaseEnvironment, + InfoPath, + Wheel, +) + +__all__ = ["NAME", "Distribution", "Environment"] + +logger = logging.getLogger(__name__) + +NAME = "pkg_resources" + + +class EntryPoint(NamedTuple): + name: str + value: str + group: str + + +class InMemoryMetadata: + """IMetadataProvider that reads metadata files from a dictionary. + + This also maps metadata decoding exceptions to our internal exception type. + """ + + def __init__(self, metadata: Mapping[str, bytes], wheel_name: str) -> None: + self._metadata = metadata + self._wheel_name = wheel_name + + def has_metadata(self, name: str) -> bool: + return name in self._metadata + + def get_metadata(self, name: str) -> str: + try: + return self._metadata[name].decode() + except UnicodeDecodeError as e: + # Augment the default error with the origin of the file. + raise UnsupportedWheel( + f"Error decoding metadata for {self._wheel_name}: {e} in {name} file" + ) + + def get_metadata_lines(self, name: str) -> Iterable[str]: + return pkg_resources.yield_lines(self.get_metadata(name)) + + def metadata_isdir(self, name: str) -> bool: + return False + + def metadata_listdir(self, name: str) -> List[str]: + return [] + + def run_script(self, script_name: str, namespace: str) -> None: + pass + + +class Distribution(BaseDistribution): + def __init__(self, dist: pkg_resources.Distribution) -> None: + self._dist = dist + # This is populated lazily, to avoid loading metadata for all possible + # distributions eagerly. + self.__extra_mapping: Optional[Mapping[NormalizedName, str]] = None + + @property + def _extra_mapping(self) -> Mapping[NormalizedName, str]: + if self.__extra_mapping is None: + self.__extra_mapping = { + canonicalize_name(extra): extra for extra in self._dist.extras + } + + return self.__extra_mapping + + @classmethod + def from_directory(cls, directory: str) -> BaseDistribution: + dist_dir = directory.rstrip(os.sep) + + # Build a PathMetadata object, from path to metadata. :wink: + base_dir, dist_dir_name = os.path.split(dist_dir) + metadata = pkg_resources.PathMetadata(base_dir, dist_dir) + + # Determine the correct Distribution object type. + if dist_dir.endswith(".egg-info"): + dist_cls = pkg_resources.Distribution + dist_name = os.path.splitext(dist_dir_name)[0] + else: + assert dist_dir.endswith(".dist-info") + dist_cls = pkg_resources.DistInfoDistribution + dist_name = os.path.splitext(dist_dir_name)[0].split("-")[0] + + dist = dist_cls(base_dir, project_name=dist_name, metadata=metadata) + return cls(dist) + + @classmethod + def from_metadata_file_contents( + cls, + metadata_contents: bytes, + filename: str, + project_name: str, + ) -> BaseDistribution: + metadata_dict = { + "METADATA": metadata_contents, + } + dist = pkg_resources.DistInfoDistribution( + location=filename, + metadata=InMemoryMetadata(metadata_dict, filename), + project_name=project_name, + ) + return cls(dist) + + @classmethod + def from_wheel(cls, wheel: Wheel, name: str) -> BaseDistribution: + try: + with wheel.as_zipfile() as zf: + info_dir, _ = parse_wheel(zf, name) + metadata_dict = { + path.split("/", 1)[-1]: read_wheel_metadata_file(zf, path) + for path in zf.namelist() + if path.startswith(f"{info_dir}/") + } + except zipfile.BadZipFile as e: + raise InvalidWheel(wheel.location, name) from e + except UnsupportedWheel as e: + raise UnsupportedWheel(f"{name} has an invalid wheel, {e}") + dist = pkg_resources.DistInfoDistribution( + location=wheel.location, + metadata=InMemoryMetadata(metadata_dict, wheel.location), + project_name=name, + ) + return cls(dist) + + @property + def location(self) -> Optional[str]: + return self._dist.location + + @property + def installed_location(self) -> Optional[str]: + egg_link = egg_link_path_from_location(self.raw_name) + if egg_link: + location = egg_link + elif self.location: + location = self.location + else: + return None + return normalize_path(location) + + @property + def info_location(self) -> Optional[str]: + return self._dist.egg_info + + @property + def installed_by_distutils(self) -> bool: + # A distutils-installed distribution is provided by FileMetadata. This + # provider has a "path" attribute not present anywhere else. Not the + # best introspection logic, but pip has been doing this for a long time. + try: + return bool(self._dist._provider.path) + except AttributeError: + return False + + @property + def canonical_name(self) -> NormalizedName: + return canonicalize_name(self._dist.project_name) + + @property + def version(self) -> Version: + return parse_version(self._dist.version) + + @property + def raw_version(self) -> str: + return self._dist.version + + def is_file(self, path: InfoPath) -> bool: + return self._dist.has_metadata(str(path)) + + def iter_distutils_script_names(self) -> Iterator[str]: + yield from self._dist.metadata_listdir("scripts") + + def read_text(self, path: InfoPath) -> str: + name = str(path) + if not self._dist.has_metadata(name): + raise FileNotFoundError(name) + content = self._dist.get_metadata(name) + if content is None: + raise NoneMetadataError(self, name) + return content + + def iter_entry_points(self) -> Iterable[BaseEntryPoint]: + for group, entries in self._dist.get_entry_map().items(): + for name, entry_point in entries.items(): + name, _, value = str(entry_point).partition("=") + yield EntryPoint(name=name.strip(), value=value.strip(), group=group) + + def _metadata_impl(self) -> email.message.Message: + """ + :raises NoneMetadataError: if the distribution reports `has_metadata()` + True but `get_metadata()` returns None. + """ + if isinstance(self._dist, pkg_resources.DistInfoDistribution): + metadata_name = "METADATA" + else: + metadata_name = "PKG-INFO" + try: + metadata = self.read_text(metadata_name) + except FileNotFoundError: + if self.location: + displaying_path = display_path(self.location) + else: + displaying_path = repr(self.location) + logger.warning("No metadata found in %s", displaying_path) + metadata = "" + feed_parser = email.parser.FeedParser() + feed_parser.feed(metadata) + return feed_parser.close() + + def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]: + if extras: + relevant_extras = set(self._extra_mapping) & set( + map(canonicalize_name, extras) + ) + extras = [self._extra_mapping[extra] for extra in relevant_extras] + return self._dist.requires(extras) + + def iter_provided_extras(self) -> Iterable[NormalizedName]: + return self._extra_mapping.keys() + + +class Environment(BaseEnvironment): + def __init__(self, ws: pkg_resources.WorkingSet) -> None: + self._ws = ws + + @classmethod + def default(cls) -> BaseEnvironment: + return cls(pkg_resources.working_set) + + @classmethod + def from_paths(cls, paths: Optional[List[str]]) -> BaseEnvironment: + return cls(pkg_resources.WorkingSet(paths)) + + def _iter_distributions(self) -> Iterator[BaseDistribution]: + for dist in self._ws: + yield Distribution(dist) + + def _search_distribution(self, name: str) -> Optional[BaseDistribution]: + """Find a distribution matching the ``name`` in the environment. + + This searches from *all* distributions available in the environment, to + match the behavior of ``pkg_resources.get_distribution()``. + """ + canonical_name = canonicalize_name(name) + for dist in self.iter_all_distributions(): + if dist.canonical_name == canonical_name: + return dist + return None + + def get_distribution(self, name: str) -> Optional[BaseDistribution]: + # Search the distribution by looking through the working set. + dist = self._search_distribution(name) + if dist: + return dist + + # If distribution could not be found, call working_set.require to + # update the working set, and try to find the distribution again. + # This might happen for e.g. when you install a package twice, once + # using setup.py develop and again using setup.py install. Now when + # running pip uninstall twice, the package gets removed from the + # working set in the first uninstall, so we have to populate the + # working set again so that pip knows about it and the packages gets + # picked up and is successfully uninstalled the second time too. + try: + # We didn't pass in any version specifiers, so this can never + # raise pkg_resources.VersionConflict. + self._ws.require(name) + except pkg_resources.DistributionNotFound: + return None + return self._search_distribution(name) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/__init__.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7b1fc2950326463fb5bf1cc460e5ca0ac3de3e9a --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/__init__.py @@ -0,0 +1 @@ +"""A package that contains models that represent entities.""" diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/candidate.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/candidate.py new file mode 100644 index 0000000000000000000000000000000000000000..f27f283154ac5aa55d52ccac754138b36341ff6b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/candidate.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass + +from pip._vendor.packaging.version import Version +from pip._vendor.packaging.version import parse as parse_version + +from pip._internal.models.link import Link + + +@dataclass(frozen=True) +class InstallationCandidate: + """Represents a potential "candidate" for installation.""" + + __slots__ = ["name", "version", "link"] + + name: str + version: Version + link: Link + + def __init__(self, name: str, version: str, link: Link) -> None: + object.__setattr__(self, "name", name) + object.__setattr__(self, "version", parse_version(version)) + object.__setattr__(self, "link", link) + + def __str__(self) -> str: + return f"{self.name!r} candidate (version {self.version} at {self.link})" diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/direct_url.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/direct_url.py new file mode 100644 index 0000000000000000000000000000000000000000..8f990dd0ca17877ea49e39b2c39d15a27af273ff --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/direct_url.py @@ -0,0 +1,224 @@ +"""PEP 610""" + +import json +import re +import urllib.parse +from dataclasses import dataclass +from typing import Any, ClassVar, Dict, Iterable, Optional, Type, TypeVar, Union + +__all__ = [ + "DirectUrl", + "DirectUrlValidationError", + "DirInfo", + "ArchiveInfo", + "VcsInfo", +] + +T = TypeVar("T") + +DIRECT_URL_METADATA_NAME = "direct_url.json" +ENV_VAR_RE = re.compile(r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$") + + +class DirectUrlValidationError(Exception): + pass + + +def _get( + d: Dict[str, Any], expected_type: Type[T], key: str, default: Optional[T] = None +) -> Optional[T]: + """Get value from dictionary and verify expected type.""" + if key not in d: + return default + value = d[key] + if not isinstance(value, expected_type): + raise DirectUrlValidationError( + f"{value!r} has unexpected type for {key} (expected {expected_type})" + ) + return value + + +def _get_required( + d: Dict[str, Any], expected_type: Type[T], key: str, default: Optional[T] = None +) -> T: + value = _get(d, expected_type, key, default) + if value is None: + raise DirectUrlValidationError(f"{key} must have a value") + return value + + +def _exactly_one_of(infos: Iterable[Optional["InfoType"]]) -> "InfoType": + infos = [info for info in infos if info is not None] + if not infos: + raise DirectUrlValidationError( + "missing one of archive_info, dir_info, vcs_info" + ) + if len(infos) > 1: + raise DirectUrlValidationError( + "more than one of archive_info, dir_info, vcs_info" + ) + assert infos[0] is not None + return infos[0] + + +def _filter_none(**kwargs: Any) -> Dict[str, Any]: + """Make dict excluding None values.""" + return {k: v for k, v in kwargs.items() if v is not None} + + +@dataclass +class VcsInfo: + name: ClassVar = "vcs_info" + + vcs: str + commit_id: str + requested_revision: Optional[str] = None + + @classmethod + def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["VcsInfo"]: + if d is None: + return None + return cls( + vcs=_get_required(d, str, "vcs"), + commit_id=_get_required(d, str, "commit_id"), + requested_revision=_get(d, str, "requested_revision"), + ) + + def _to_dict(self) -> Dict[str, Any]: + return _filter_none( + vcs=self.vcs, + requested_revision=self.requested_revision, + commit_id=self.commit_id, + ) + + +class ArchiveInfo: + name = "archive_info" + + def __init__( + self, + hash: Optional[str] = None, + hashes: Optional[Dict[str, str]] = None, + ) -> None: + # set hashes before hash, since the hash setter will further populate hashes + self.hashes = hashes + self.hash = hash + + @property + def hash(self) -> Optional[str]: + return self._hash + + @hash.setter + def hash(self, value: Optional[str]) -> None: + if value is not None: + # Auto-populate the hashes key to upgrade to the new format automatically. + # We don't back-populate the legacy hash key from hashes. + try: + hash_name, hash_value = value.split("=", 1) + except ValueError: + raise DirectUrlValidationError( + f"invalid archive_info.hash format: {value!r}" + ) + if self.hashes is None: + self.hashes = {hash_name: hash_value} + elif hash_name not in self.hashes: + self.hashes = self.hashes.copy() + self.hashes[hash_name] = hash_value + self._hash = value + + @classmethod + def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["ArchiveInfo"]: + if d is None: + return None + return cls(hash=_get(d, str, "hash"), hashes=_get(d, dict, "hashes")) + + def _to_dict(self) -> Dict[str, Any]: + return _filter_none(hash=self.hash, hashes=self.hashes) + + +@dataclass +class DirInfo: + name: ClassVar = "dir_info" + + editable: bool = False + + @classmethod + def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["DirInfo"]: + if d is None: + return None + return cls(editable=_get_required(d, bool, "editable", default=False)) + + def _to_dict(self) -> Dict[str, Any]: + return _filter_none(editable=self.editable or None) + + +InfoType = Union[ArchiveInfo, DirInfo, VcsInfo] + + +@dataclass +class DirectUrl: + url: str + info: InfoType + subdirectory: Optional[str] = None + + def _remove_auth_from_netloc(self, netloc: str) -> str: + if "@" not in netloc: + return netloc + user_pass, netloc_no_user_pass = netloc.split("@", 1) + if ( + isinstance(self.info, VcsInfo) + and self.info.vcs == "git" + and user_pass == "git" + ): + return netloc + if ENV_VAR_RE.match(user_pass): + return netloc + return netloc_no_user_pass + + @property + def redacted_url(self) -> str: + """url with user:password part removed unless it is formed with + environment variables as specified in PEP 610, or it is ``git`` + in the case of a git URL. + """ + purl = urllib.parse.urlsplit(self.url) + netloc = self._remove_auth_from_netloc(purl.netloc) + surl = urllib.parse.urlunsplit( + (purl.scheme, netloc, purl.path, purl.query, purl.fragment) + ) + return surl + + def validate(self) -> None: + self.from_dict(self.to_dict()) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "DirectUrl": + return DirectUrl( + url=_get_required(d, str, "url"), + subdirectory=_get(d, str, "subdirectory"), + info=_exactly_one_of( + [ + ArchiveInfo._from_dict(_get(d, dict, "archive_info")), + DirInfo._from_dict(_get(d, dict, "dir_info")), + VcsInfo._from_dict(_get(d, dict, "vcs_info")), + ] + ), + ) + + def to_dict(self) -> Dict[str, Any]: + res = _filter_none( + url=self.redacted_url, + subdirectory=self.subdirectory, + ) + res[self.info.name] = self.info._to_dict() + return res + + @classmethod + def from_json(cls, s: str) -> "DirectUrl": + return cls.from_dict(json.loads(s)) + + def to_json(self) -> str: + return json.dumps(self.to_dict(), sort_keys=True) + + def is_local_editable(self) -> bool: + return isinstance(self.info, DirInfo) and self.info.editable diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/format_control.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/format_control.py new file mode 100644 index 0000000000000000000000000000000000000000..ccd11272c030c2d067e1bb6d90fc744c7379a923 --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/format_control.py @@ -0,0 +1,78 @@ +from typing import FrozenSet, Optional, Set + +from pip._vendor.packaging.utils import canonicalize_name + +from pip._internal.exceptions import CommandError + + +class FormatControl: + """Helper for managing formats from which a package can be installed.""" + + __slots__ = ["no_binary", "only_binary"] + + def __init__( + self, + no_binary: Optional[Set[str]] = None, + only_binary: Optional[Set[str]] = None, + ) -> None: + if no_binary is None: + no_binary = set() + if only_binary is None: + only_binary = set() + + self.no_binary = no_binary + self.only_binary = only_binary + + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return NotImplemented + + if self.__slots__ != other.__slots__: + return False + + return all(getattr(self, k) == getattr(other, k) for k in self.__slots__) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.no_binary}, {self.only_binary})" + + @staticmethod + def handle_mutual_excludes(value: str, target: Set[str], other: Set[str]) -> None: + if value.startswith("-"): + raise CommandError( + "--no-binary / --only-binary option requires 1 argument." + ) + new = value.split(",") + while ":all:" in new: + other.clear() + target.clear() + target.add(":all:") + del new[: new.index(":all:") + 1] + # Without a none, we want to discard everything as :all: covers it + if ":none:" not in new: + return + for name in new: + if name == ":none:": + target.clear() + continue + name = canonicalize_name(name) + other.discard(name) + target.add(name) + + def get_allowed_formats(self, canonical_name: str) -> FrozenSet[str]: + result = {"binary", "source"} + if canonical_name in self.only_binary: + result.discard("source") + elif canonical_name in self.no_binary: + result.discard("binary") + elif ":all:" in self.only_binary: + result.discard("source") + elif ":all:" in self.no_binary: + result.discard("binary") + return frozenset(result) + + def disallow_binaries(self) -> None: + self.handle_mutual_excludes( + ":all:", + self.no_binary, + self.only_binary, + ) diff --git a/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/index.py b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/index.py new file mode 100644 index 0000000000000000000000000000000000000000..b94c32511f0cda2363bfc4f29c9c8bfcc7101f9b --- /dev/null +++ b/.mcp-venv/lib/python3.13/site-packages/pip/_internal/models/index.py @@ -0,0 +1,28 @@ +import urllib.parse + + +class PackageIndex: + """Represents a Package Index and provides easier access to endpoints""" + + __slots__ = ["url", "netloc", "simple_url", "pypi_url", "file_storage_domain"] + + def __init__(self, url: str, file_storage_domain: str) -> None: + super().__init__() + self.url = url + self.netloc = urllib.parse.urlsplit(url).netloc + self.simple_url = self._url_for_path("simple") + self.pypi_url = self._url_for_path("pypi") + + # This is part of a temporary hack used to block installs of PyPI + # packages which depend on external urls only necessary until PyPI can + # block such packages themselves + self.file_storage_domain = file_storage_domain + + def _url_for_path(self, path: str) -> str: + return urllib.parse.urljoin(self.url, path) + + +PyPI = PackageIndex("https://pypi.org/", file_storage_domain="files.pythonhosted.org") +TestPyPI = PackageIndex( + "https://test.pypi.org/", file_storage_domain="test-files.pythonhosted.org" +)