diff --git a/README.md b/README.md index 8fedb32..2902fa5 100644 --- a/README.md +++ b/README.md @@ -258,8 +258,8 @@ minor in your own `pyproject.toml` if you would rather take those deliberately. **[docs/CONSUMING.md](./docs/CONSUMING.md)** covers the rest: the plugin contract, serving toolsets, wiring up UI views and the npm bridge, wiring -session state into your own agent, serving that agent over HTTP, and migrating -off the in-repo workspace. +session state into your own agent, serving that agent over HTTP, migrating +off the in-repo workspace, and upgrading across a breaking release. ## Develop diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index 0e6d5ba..3dc1a70 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -83,7 +83,8 @@ document — is what makes your builds reproducible. **Upgrade** with `uv lock The package is pre-1.0, where a minor release may break. If you'd rather take those deliberately, bound the dependency at the next minor in your own -`pyproject.toml`. +`pyproject.toml`. What 0.11 breaks, and what to check, is in +[7](#7-upgrading-from-010-to-011-mcp-2). Available console scripts: `mcp-serve`, `mcp-serve-local`, `mcp-index` (base); `mcp-cli` (base); `mcp-agent` (needs `[agent]`). @@ -196,7 +197,11 @@ TOOLS = [search] # required: non-empty list of tools tool that can fail in a way the model should see and act on, and return it rather than raising. Two exceptions stay exceptions: a missing credential (`MissingCredentialError`, which tells the *caller* to send a header) and a - bug in the tool. + bug in the tool. The model sees the first's message and not the second's: a + raised exception reaches it as `Error executing tool ` and nothing + more, with the traceback in the server log. A failure the model should read + is a returned `ToolError`, or a raised + `mcp.server.mcpserver.exceptions.ToolError`, which keeps its text. - **`CREDENTIAL_HEADERS`** — names of headers the tools read off the transport. The runtime derives the server `instructions` from these so the model is told a credential rides the connection and it shouldn't ask the user for it. The @@ -252,8 +257,8 @@ mcp-serve-local # from your repo root Each toolset is mounted at `/`, so `//mcp` and `//health` are the paths production serves, and `/` returns the same -directory document `mcp-index` returns. `mcp-cli`, `mcp-agent` and a plain -`MultiServerMCPClient` all consume that already: +directory document `mcp-index` returns. `mcp-cli`, `mcp-agent` and any MCP +client all consume that already: ```bash mcp-cli call forecast city=Lisbon --url http://localhost:8000/weather/mcp @@ -354,7 +359,7 @@ user's call: | --- | --- | | the module imports, and exports a non-empty `TOOLS` | `load_tools` | | every entry is a LangChain `BaseTool` | `load_tools` | -| every tool returns a `ToolResult`, with a required `message` | `to_fastmcp` | +| every tool returns a `ToolResult`, with a required `message` | `to_mcp_tool` | | `CREDENTIAL_HEADERS` is a list of header names | `load_credential_headers` | | every `VIEWS` entry names a real tool, with a built bundle on disk | `load_views` | | every `NotAuthored` sits on a parameter that exists | `with_state_meta` | @@ -596,12 +601,14 @@ Three things it does that a loop of your own would have to get right: ### 4b. Wiring it into your own agent -Only needed if you are *not* using the bundled agent. Needs the `[state]` extra. +Only needed if you are *not* using the bundled agent. Needs the `[agent]` extra: +`with_credential_support` comes from `mcp_agent`, and it is what builds one +client per connection. Three pieces, all three required: ```python from langchain.agents import create_agent -from langchain_mcp_adapters.client import MultiServerMCPClient +from mcp_agent.main import list_tools, with_credential_support from mcp_state import ( SESSION_STATE_PROMPT, StateCaptureMiddleware, @@ -614,13 +621,15 @@ from mcp_state import ( ) # Loaded per server, so each tool records where it came from. The adapter -# takes a `server_name` and stamps it nowhere; without this an undeclared -# capture cannot be keyed `//` like a declared one. -client = MultiServerMCPClient(connections) +# stamps the serving server on metadata, but not in this form; without this an +# undeclared capture cannot be keyed `//` like a declared +# one. `with_credential_support` builds one client per connection, and +# `list_tools` gives each call a session of its own — through a shared one, a +# call that overlaps another user's would carry that user's credentials. tools = [ with_server_name(tool, server) - for server in connections - for tool in await client.get_tools(server_name=server) + for server, client in with_credential_support(connections, None).items() + for tool in await list_tools(client) ] published = publications(tools) @@ -1452,3 +1461,75 @@ If your repo currently vendors `packages/mcp-runtime`, `packages/mcp-cli`, Imports don't change, so application code is untouched — this is a dependency and build-wiring change only. + +--- + +## 7. Upgrading from 0.10 to 0.11 (mcp 2) + +0.11 moves to **mcp 2** and off `langchain-mcp-adapters`, which was retired in +September. Its successor, `langchain[mcp]`, needs fastmcp 4, which needs mcp 2, +so the two move together. What that touches depends on which persona you are. + +```bash +uv lock --upgrade-package mcp-toolsets-runtime +``` + +A `mcp<2` bound of your own blocks that resolve; the runtime now pins +`mcp>=2.3,<3`. Drop `langchain-mcp-adapters` from your dependencies if you had +it directly. + +**Everyone: a raised exception no longer reaches the model.** mcp 1 sent a +tool's exception text to the client. mcp 2 sends `Error executing tool ` +and keeps the text in the server log. A tool that *raises* to tell the model +something — a store that is down, a quota exceeded — now tells it nothing. +Return a `ToolError` arm instead ([2](#2-author-a-toolset-the-plugin-contract)), +or raise `mcp.server.mcpserver.exceptions.ToolError`, which keeps its message. +`MissingCredentialError` already does. Grep your tools for `raise` on paths the +model is meant to read. + +**Everyone: a loopback server checks the `Host` header.** mcp 2 turns on +DNS-rebinding protection for a server built for `127.0.0.1` or `localhost`: +a request addressed by any other name gets a 421. `mcp-serve` and +`mcp-serve-local` default to `127.0.0.1`, so a container port-forward or a +`host.docker.internal` URL that worked before now needs `HOST=0.0.0.0`. The +Helm chart sets that already. `build_local_app` takes `host` for the same +reason: pass the address you serve on. + +**Persona 3, your own agent:** `MultiServerMCPClient` is gone. The replacement +is one fastmcp `Client` per connection from `with_credential_support`, with +tools loaded through `list_tools`, which opens a session per call so overlapping +users keep their own credentials; the code is in [4b](#4b-wiring-it-into-your-own-agent). +Three things it changes around that: + +- A tool's `_meta` is nested under `metadata["mcp"]["tool"]`. Read it with + `mcp_state.tool_meta(tool)`, never `metadata["_meta"]`: the old + read finds nothing and fails nothing, which turns off `NotAuthored`, + publications and views silently. +- Outbound MCP calls go over **httpx2**, mcp 2's HTTP stack. An event hook, a + mock (`respx`) or a timeout typed against `httpx` does not see them. + `credential_client_factory` builds an `httpx2.AsyncClient`, and a factory of + your own must accept `**kwargs`: fastmcp passes `follow_redirects`. +- `McpError` is `MCPError`. Loading MCP tools needs the `[agent]` extra, + which brings `langchain[mcp]`, `fastmcp` and `httpx2`; the old adapter came + with the base package. + +**Serving a built server yourself:** `build_server` returns a `ToolsetServer`, +an `MCPServer` that remembers its host, path and stateless transport, so +`streamable_http_app()` and `run()` give what they gave before. A raw `FastMCP` +of your own is `mcp.server.mcpserver.MCPServer`, and the SDK's wire fields are +snake_case now (`input_schema`, `output_schema`, `is_error`, +`structured_content`, `mime_type`). `mcp_runtime.fastmcp_output.to_fastmcp` is +`mcp_runtime.bridge.to_mcp_tool`. + +**Toolset authors:** nothing to rewrite. Tools, `ToolResult`, `VIEWS`, +`CREDENTIAL_HEADERS` and `NotAuthored` are written the same way; only the +exception point above applies. + +A grep that finds the rest: + +```bash +grep -rn 'langchain_mcp_adapters\|MultiServerMCPClient\|McpError\|mcp.server.fastmcp\|fastmcp_output\|\["_meta"\]' --include='*.py' . +``` + +Then run the contract tests ([2](#testing-the-contract)) and smoke-test a +toolset server and the agent. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index ddd3e47..3fd6f98 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -388,7 +388,7 @@ handle. The model spent about ten tokens on the key. ## B. Nothing declared anywhere — a third-party server -A raw FastMCP server, no `mcp_runtime`, no `_meta`. Its `describe_geometry` +A raw `MCPServer`, no `mcp_runtime`, no `_meta`. Its `describe_geometry` takes a structured parameter; the client offers the handle branch alongside the literal one, and the model points it at the same stored value. Its `elevation_profile` returns a 54 kB array nobody declared, captured on size @@ -468,9 +468,9 @@ not write the key answers with what it *did* hold, and one the thread never had says how many it has. **Undeclared captures need the host's help to be keyed consistently.** -`langchain_mcp_adapters` accepts a `server_name` and records it nowhere on the -tool it builds, so a host that wants three-part keys for third-party servers -stamps it itself — `with_server_name` at load, `owners(tools)` into the +`langchain.mcp` stamps the serving server under its own `mcp.server` +metadata, not in the form these keys are built from, so a host that wants +three-part keys for third-party servers stamps it itself — `with_server_name` at load, `owners(tools)` into the middleware. Without that they fall back to `/`. **Nothing knows what a value means.** Only its name says. A tool handed a diff --git a/examples/agui-events/README.md b/examples/agui-events/README.md index 124972a..e25cec5 100644 --- a/examples/agui-events/README.md +++ b/examples/agui-events/README.md @@ -314,7 +314,7 @@ sees one origin. CORS belongs to `mcp_agent_api.app`, not to the router. The other two servers are [`examples/session-state`](../session-state)'s, imported off the path: `dataset-search`, which publishes a 38 kB area of -interest, and `terrain`, a raw FastMCP server that declares nothing at all. +interest, and `terrain`, a raw `MCPServer` that declares nothing at all. Everything collapses: a tool call opens to its arguments and full result, an activity to its structured content, and `mcp.view` to the bundle itself in an diff --git a/examples/agui-events/service/agent.py b/examples/agui-events/service/agent.py index c389334..6a98e5a 100644 --- a/examples/agui-events/service/agent.py +++ b/examples/agui-events/service/agent.py @@ -13,10 +13,14 @@ import logging -from langchain_mcp_adapters.client import MultiServerMCPClient from langgraph.checkpoint.memory import InMemorySaver -from mcp_agent.main import BuiltAgent, with_session_state +from mcp_agent.main import ( + BuiltAgent, + list_tools, + with_credential_support, + with_session_state, +) from mcp_state import with_server_name from service import model, servers @@ -33,13 +37,12 @@ async def build() -> BuiltAgent: logger.info("connected %d MCP server(s)", len(connections)) # Loaded per server so each tool records where it came from: the adapter - # takes a `server_name` and stamps it nowhere, and an undeclared capture - # needs it to be keyed // like a declared one. - client = MultiServerMCPClient(connections) + # stamps the serving server on metadata, not in the form an undeclared + # capture needs — keyed // like a declared one. tools = [ with_server_name(tool, server) - for server in connections - for tool in await client.get_tools(server_name=server) + for server, client in with_credential_support(connections, None).items() + for tool in await list_tools(client) ] chat, named = model.build() diff --git a/examples/agui-events/service/app.py b/examples/agui-events/service/app.py index 24382ef..fae9b9b 100644 --- a/examples/agui-events/service/app.py +++ b/examples/agui-events/service/app.py @@ -27,7 +27,7 @@ from service.settings import get_settings logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s") -for noisy in ("mcp", "httpx", "uvicorn.access", "sse_starlette", "langchain"): +for noisy in ("mcp", "httpx", "httpx2", "uvicorn.access", "sse_starlette", "langchain"): logging.getLogger(noisy).setLevel(logging.WARNING) settings = get_settings() diff --git a/examples/agui-events/service/servers.py b/examples/agui-events/service/servers.py index cea2f66..cc11417 100644 --- a/examples/agui-events/service/servers.py +++ b/examples/agui-events/service/servers.py @@ -16,7 +16,7 @@ ``sketch-ops`` leaves a structured parameter open, so the model writes the value itself ``terrain`` - a raw FastMCP server that declares nothing at all + a raw MCPServer that declares nothing at all """ import asyncio @@ -63,7 +63,7 @@ async def _wait_for(port: int, attempts: int = 80) -> None: async def start() -> dict[str, dict[str, str]]: - """Start all four and return connections, in `MultiServerMCPClient`'s shape. + """Start all four and return connections, in the index's shape. The return value is what a deployment would have got from its index, so everything downstream of here is what a deployment does. diff --git a/examples/session-state/README.md b/examples/session-state/README.md index fcdce04..7430538 100644 --- a/examples/session-state/README.md +++ b/examples/session-state/README.md @@ -19,7 +19,7 @@ travelling as `_meta`. | `demo.py` | Starts the servers, drives the agent, prints seven sections | | `toolsets/dataset_search/tools.py` | Publishes a 38 kB `area_of_interest` data key | | `toolsets/raster_ops/tools.py` | Three tools that differ only in who may write their parameter | -| `foreign_server.py` | **Raw FastMCP. No `ToolResult`, no import from `mcp_runtime`.** | +| `foreign_server.py` | **Raw `MCPServer`. No `ToolResult`, no import from `mcp_runtime`.** | `dataset_search` and `raster_ops` are separate packages on separate servers. Neither imports the other, and neither names the other. The only thing they diff --git a/examples/session-state/demo.py b/examples/session-state/demo.py index 9db969b..46a590a 100644 --- a/examples/session-state/demo.py +++ b/examples/session-state/demo.py @@ -1,7 +1,7 @@ """Run the whole session-state contract against three real MCP servers. Two are built with this runtime and declare what they publish; the third -(``foreign_server.py``) is raw FastMCP and declares nothing at all. The demo +(``foreign_server.py``) is a raw ``MCPServer`` and declares nothing at all. The demo connects one agent to all three and drives a conversation in which a 2000-vertex geometry moves between two servers that share no code, no imports and no vocabulary — only a **name**. @@ -39,9 +39,12 @@ from langchain_core.language_models import GenericFakeChatModel from langchain_core.messages import AIMessage, HumanMessage from langchain_core.utils.function_calling import convert_to_openai_tool -from langchain_mcp_adapters.client import MultiServerMCPClient +from mcp_agent.main import list_tools, with_credential_support -from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, PRODUCES_META_KEY +from mcp_runtime.declarations import ( + NOT_AUTHORED_META_KEY, + PRODUCES_META_KEY, +) from mcp_runtime.server import build_server from mcp_state import ( StateCaptureMiddleware, @@ -52,6 +55,7 @@ owners, publications, state_keys, + tool_meta, with_server_name, ) @@ -63,7 +67,8 @@ from foreign_server import mcp as foreign_mcp # noqa: E402 (needs the path above) # The MCP SDK and httpx log every request at INFO, which buries the report. -for noisy in ("mcp", "httpx", "uvicorn", "sse_starlette"): +# `httpx2` is the client mcp 2 uses, and logs under its own name. +for noisy in ("mcp", "httpx", "httpx2", "uvicorn", "sse_starlette"): logging.getLogger(noisy).setLevel(logging.WARNING) logging.basicConfig(level=logging.WARNING) @@ -223,20 +228,19 @@ async def main() -> None: for port in ports.values(): await wait_for(port) - client = MultiServerMCPClient(connections) # Stamped with where each came from, so an undeclared capture is keyed the - # same three-part way a declared one is. The adapter takes a `server_name` - # and records it nowhere, so a host that wants it does this itself. + # same three-part way a declared one is. The adapter records the serving + # server on the tool's metadata, not in that form, so a host does it here. tools = [ with_server_name(tool, server) - for server in connections - for tool in await client.get_tools(server_name=server) + for server, client in with_credential_support(connections, None).items() + for tool in await list_tools(client) ] published = publications(tools) rule("1. What each server declared, over the wire") for tool in sorted(tools, key=lambda t: t.name): - meta = (tool.metadata or {}).get("_meta") or {} + meta = tool_meta(tool) label = tool.name for declaration in meta.get(PRODUCES_META_KEY, []): print(f" {label:19} publishes {declaration['stateKey']}") diff --git a/examples/session-state/foreign_server.py b/examples/session-state/foreign_server.py index 5e90cb0..0d11fac 100644 --- a/examples/session-state/foreign_server.py +++ b/examples/session-state/foreign_server.py @@ -1,6 +1,6 @@ """A third-party MCP server that has never heard of this project. -Deliberately built on raw ``FastMCP`` rather than ``build_server``: no +Deliberately built on a raw ``MCPServer`` rather than ``build_server``: no ``ToolResult``, no ``Kind``, no ``_meta``, no import from ``mcp_runtime`` at all. This is what somebody else's MCP server looks like, and the point of the example is that session state still works across it. @@ -16,10 +16,10 @@ payload passing through the transcript in either direction. """ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from pydantic import BaseModel -mcp = FastMCP("terrain") +mcp = MCPServer("terrain") class ElevationProfile(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 19de287..946d92b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,7 @@ requires-python = ">=3.12,<3.14" license = { file = "LICENSE" } dependencies = [ # mcp_runtime - "mcp<2.0.0,>=1.27.2", - "langchain-mcp-adapters<0.4.0,>=0.3.0", + "mcp<3.0.0,>=2.3.0", "langchain-core<2.0.0,>=1.4.6", "pydantic-settings<3.0.0,>=2.12.0", "fastapi>=0.140.11,<1.0.0", @@ -42,7 +41,19 @@ state = [ # mcp_agent.main (build_agent, run_turn) and mcp_agent.host, neither of which # imports a UI framework. An API or a host of your own needs this and no more. -agent = ["mcp-toolsets-runtime[state]"] +# +# The MCP client lives in `langchain[mcp]` (it was `langchain-mcp-adapters`, +# retired in September) and reaches servers through fastmcp's transports, which +# `mcp_agent.main` constructs directly to inject per-user credential headers. +agent = [ + "mcp-toolsets-runtime[state]", + "langchain[mcp]<2.0.0,>=1.4.3", + "fastmcp<5.0.0,>=4.0.11", + # mcp_agent.main types the credential client factory against httpx2, the + # HTTP stack mcp 2 moved to. Declared for the same reason httpx is above: + # a package this imports is not one it should get by accident. + "httpx2<3.0.0,>=2.13.0", +] # mcp_agent_api: the agent over HTTP, on top of [agent]. # @@ -132,7 +143,7 @@ warn_redundant_casts = true check_untyped_defs = true [[tool.mypy.overrides]] -module = ["langchain_mcp_adapters.*"] +module = ["fastmcp.*"] ignore_missing_imports = true # boto3 ships no types and the typed stubs are a large optional dependency for diff --git a/src/mcp_agent/host.py b/src/mcp_agent/host.py index 782f1c9..6022ed4 100644 --- a/src/mcp_agent/host.py +++ b/src/mcp_agent/host.py @@ -16,10 +16,11 @@ from langchain_core.messages import AIMessage, BaseMessage, ToolMessage from langchain_core.tools import BaseTool -from langchain_mcp_adapters.client import MultiServerMCPClient + from mcp_agent.main import with_credential_support from mcp_state import ( + tool_meta, Receipt, describe, receipts_of, @@ -58,22 +59,26 @@ async def view_bundles( resource and stamps the owning tool's ``_meta`` with that URI. A tool with no view has no such resource, so it renders as text exactly as before. """ - client = MultiServerMCPClient(with_credential_support(connections, required)) - try: - blobs = await client.get_resources() - except Exception: # noqa: BLE001 - views are optional; degrade to text - return {} - return { - uri: blob.as_string() - for blob in blobs - if (uri := str(blob.metadata.get("uri", ""))).startswith("ui://") - } + bundles: dict[str, str] = {} + for client in with_credential_support(connections, required).values(): + try: + async with client: + for resource in await client.list_resources(): + uri = str(resource.uri) + if not uri.startswith("ui://"): + continue + blocks = await client.read_resource(uri) + bundles[uri] = "".join( + getattr(block, "text", "") for block in blocks + ) + except Exception: # noqa: BLE001, S112 - views are optional; degrade to text + continue + return bundles def view_uri_for(tool: BaseTool | None) -> str | None: """The ``ui://`` resource a tool declares via its ``_meta``, if any.""" - meta = (getattr(tool, "metadata", None) or {}).get("_meta") or {} - ui = meta.get(VIEW_META_KEY) + ui = tool_meta(tool).get(VIEW_META_KEY) return ui.get("resourceUri") if isinstance(ui, dict) else None diff --git a/src/mcp_agent/main.py b/src/mcp_agent/main.py index 7b779a0..41e4c5d 100644 --- a/src/mcp_agent/main.py +++ b/src/mcp_agent/main.py @@ -1,7 +1,7 @@ """Chat with every toolset behind an mcp-toolsets index URL. Point ``mcp-agent`` at an index root (anything serving a ``connections`` map -shaped for ``MultiServerMCPClient``) or directly at a single MCP endpoint; +shaped like the index's) or directly at a single MCP endpoint; it loads every server's tools and lets a chat model drive them in an interactive chat. @@ -41,16 +41,20 @@ from typing import Annotated, Any, NamedTuple, cast import httpx +import httpx2 import typer +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport from langchain.agents import create_agent from langchain.chat_models import init_chat_model +from langchain.mcp import as_langchain_tool +from langchain_core.messages import BaseMessage, HumanMessage, ToolMessage +from langchain_core.tools import BaseTool from langgraph.checkpoint.base import BaseCheckpointSaver from langgraph.checkpoint.memory import InMemorySaver from mcp.client.streamable_http import create_mcp_http_client -from mcp.shared.exceptions import McpError -from langchain_core.messages import BaseMessage, HumanMessage, ToolMessage -from langchain_core.tools import BaseTool -from langchain_mcp_adapters.client import MultiServerMCPClient +from mcp.shared.exceptions import MCPError +from mcp.types import Tool as MCPToolSpec from pydantic import Field, SecretStr, ValidationError, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from rich.console import Console @@ -415,7 +419,7 @@ def credential_headers_from(payload: Any) -> dict[str, list[str]] | None: # Connection failures an agent should report rather than crash on. -CONNECT_ERRORS = (httpx.HTTPError, OSError, McpError) +CONNECT_ERRORS = (httpx.HTTPError, httpx2.HTTPError, OSError, MCPError) def first_leaf(error: BaseException) -> BaseException: @@ -425,6 +429,24 @@ def first_leaf(error: BaseException) -> BaseException: return error +def connect_failure(error: BaseException) -> BaseException | None: + """The connection failure behind ``error``, or ``None`` if it is not one. + + fastmcp reports a connection that never came up as a bare ``RuntimeError`` + (``Client failed to connect: ...``) with the transport's own exception as + its ``__cause__`` — anything that is not an HTTP status or an MCP error + gets that wrapping, so a refused port arrives as ``RuntimeError`` and no + ``except`` on the transport's types sees it. Read the cause, and report + that: it is the one naming the port. + """ + leaf = first_leaf(error) + if isinstance(leaf, CONNECT_ERRORS): + return leaf + if isinstance(leaf, RuntimeError) and isinstance(leaf.__cause__, CONNECT_ERRORS): + return leaf.__cause__ + return None + + def connect_error_hint(url: str) -> str: """A nudge for the most common misconfiguration: a missing /mcp path.""" if url.rstrip("/").endswith("/mcp"): @@ -465,7 +487,7 @@ async def single_server_credential_headers( async def fetch_connections( url: str, ) -> tuple[dict[str, Any], dict[str, list[str]] | None]: - """Resolve a URL to a MultiServerMCPClient config plus credential needs.""" + """Resolve a URL to a connections map plus credential needs.""" async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client: try: payload = (await client.get(url)).json() @@ -512,9 +534,20 @@ def credential_client_factory(allowed: list[str] | None) -> Any: def factory( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + **transport_options: Any, + ) -> httpx2.AsyncClient: + """Build the client for one connection attempt. + + ``**transport_options`` absorbs what the caller sets on the client + itself rather than on the MCP session — fastmcp passes + ``follow_redirects``. ``create_mcp_http_client`` takes none of them, + and the SDK stopped reading ``follow_redirects`` off a supplied client + in 2.2.0 (it follows same-origin redirects and no others regardless), + so dropping them changes nothing about the requests that go out. The + parameter is here so a new one does not break the connection. + """ provided = _credentials.get() or {} send = { header: value @@ -530,19 +563,63 @@ def factory( def with_credential_support( connections: dict[str, Any], required: dict[str, list[str]] | None -) -> dict[str, Any]: - """Wire each connection to inject per-user credentials at call time.""" +) -> dict[str, Client]: + """A client per connection, injecting per-user credentials at call time. + + The transport is constructed here rather than left to ``Client(url)`` + because the credential factory has to be handed to it: that factory is + what turns the credentials a caller supplied for this turn into headers on + the outbound request, for the headers a toolset declared and no others. + + List tools from these with :func:`list_tools`, not ``MCPAdapter``: see + there for why a tool must not call through a shared client. + """ return { - name: { - **connection, - "httpx_client_factory": credential_client_factory( - None if required is None else required.get(name, []) - ), - } + name: Client( + StreamableHttpTransport( + connection["url"], + headers=connection.get("headers"), + httpx_client_factory=credential_client_factory( + None if required is None else required.get(name, []) + ), + ) + ) for name, connection in connections.items() } +def _on_its_own_session(tool: BaseTool, spec: MCPToolSpec, client: Client) -> BaseTool: + """``tool`` with each call made through a fresh client of ``client``'s shape.""" + + async def call(**arguments: Any) -> Any: + fresh = await as_langchain_tool(spec, client.new()) + return await cast(Any, fresh).coroutine(**arguments) + + return tool.model_copy(update={"coroutine": call}) + + +async def list_tools(client: Client) -> list[BaseTool]: + """The server's tools as LangChain tools, each call on a session of its own. + + ``MCPAdapter(client).list_tools()`` builds tools that call through the one + client they were listed from, and a fastmcp client is reentrant: a call + that starts while another is in flight joins that session instead of + opening its own. The session's HTTP client was built by + :func:`credential_client_factory` when the session connected, with the + credentials of whoever connected it — so a second user's call, overlapping + the first's, would go out carrying the first user's headers. Each call + here opens a client of its own (``Client.new``: the same transport, no + session), so its headers are read for its own turn. + """ + async with client: + listed = await client.list_tools() + tools = [await as_langchain_tool(spec, client) for spec in listed] + return [ + _on_its_own_session(tool, spec, client) + for tool, spec in zip(tools, listed, strict=True) + ] + + def credential_env_var(header: str) -> str: """Env var a credential header falls back to (``x-demo-token`` -> ``X_DEMO_TOKEN``).""" return header.upper().replace("-", "_") @@ -770,15 +847,15 @@ async def build_agent( if default_prompt: system_prompt = with_interrupt_gate_prompt(system_prompt, interrupt_gate) connections, required = await fetch_connections(url) - # Loaded per server rather than in one call, so each tool can be stamped - # with where it came from: `langchain_mcp_adapters` takes a `server_name` - # and records it nowhere on the tool it builds, and an undeclared capture - # needs it to key a value the same way a declared one is keyed. - client = MultiServerMCPClient(with_credential_support(connections, required)) + # Adapted per server rather than through one `ClientGroup`, so each tool + # can be stamped with where it came from: the adapter records the serving + # server on the tool's metadata, not in a form an undeclared capture can + # key a value by the same way a declared one is keyed. + clients = with_credential_support(connections, required) tools = [ with_server_name(tool, server) - for server in connections - for tool in await client.get_tools(server_name=server) + for server, client in clients.items() + for tool in await list_tools(client) ] chat_model = init_chat_model(model, api_key=api_key.get_secret_value()) if not session_state: @@ -994,10 +1071,12 @@ async def _chat_loop( ) -> None: try: built = await build_agent(url, model, api_key, checkpointer=checkpointer) - except* CONNECT_ERRORS as group: + except* (*CONNECT_ERRORS, RuntimeError) as group: + failure = connect_failure(group) + if failure is None: + raise console.print( - f"[red]Could not reach the MCP server(s) behind {url}: " - f"{first_leaf(group)}[/red]" + f"[red]Could not reach the MCP server(s) behind {url}: {failure}[/red]" ) if hint := connect_error_hint(url): console.print(f"[yellow]{hint.strip()}[/yellow]") diff --git a/src/mcp_agent_api/app.py b/src/mcp_agent_api/app.py index cff31ef..699962d 100644 --- a/src/mcp_agent_api/app.py +++ b/src/mcp_agent_api/app.py @@ -42,6 +42,7 @@ import os from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from contextlib import asynccontextmanager +from importlib.metadata import version from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -53,6 +54,18 @@ from mcp_agent_api.ui import available as ui_available from mcp_agent_api.ui import mount_ui +#: The overview at the top of ``/docs``. Public, so a host building its own +#: ``FastAPI`` around :func:`~mcp_agent_api.routes.create_router` can use it too. +API_DESCRIPTION = """\ +An agent over the connected MCP toolsets. + +1. `GET /connections` lists the toolsets and the credentials each one needs. +2. `POST /runs` runs one turn, streamed as AG-UI events. Send each credential + whose `supplied` is `false` as the HTTP header that `/connections` names. +3. Send the `threadId` from `RUN_STARTED` on the next run to continue the + conversation. `GET /threads/{thread_id}` reads it back. +""" + #: Comma-separated origins a browser client may call this API from. Empty (the #: default) adds no CORS middleware at all, which is right for an API behind #: the same origin as its UI, or reached through a dev server's proxy. @@ -163,7 +176,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: finally: app.state.built = None - app = FastAPI(title="mcp-agent-api", lifespan=lifespan) + app = FastAPI( + title="mcp-agent-api", + description=API_DESCRIPTION, + version=version("mcp-toolsets-runtime"), + lifespan=lifespan, + ) if allowed := (cors_origins() if origins is None else list(origins)): app.add_middleware( CORSMiddleware, diff --git a/src/mcp_agent_api/routes.py b/src/mcp_agent_api/routes.py index 702bf77..3b0847b 100644 --- a/src/mcp_agent_api/routes.py +++ b/src/mcp_agent_api/routes.py @@ -439,7 +439,10 @@ class StateValueResponse(BaseModel): class CredentialInfo(BaseModel): """One credential header a connected toolset asks callers for.""" - header: str + header: str = Field( + description="The name of the HTTP header to send this credential in, " + "on `POST /runs`." + ) supplied: bool = Field( description="Whether a run from this caller would have a value for the " "header without the client sending one: from the server's environment, " @@ -899,6 +902,10 @@ async def create_run(body: RunRequest, request: Request) -> StreamingResponse: run_in_progress`` in the detail. The thread is claimed before it is read, and released when the response ends. + Some toolsets need a credential. Send each one as an HTTP header on + this request. Get the header names from ``GET /connections``, and send + only the ones where ``supplied`` is ``false``. + \f The agent is resolved before the response begins so that "not ready" is a status code. Once the stream is open the only way to report a failure is ``RUN_ERROR``, which :func:`~mcp_agent_api.events.agui_events` @@ -1235,6 +1242,12 @@ async def read_view(toolset: str, view: str) -> HTMLResponse: async def read_connections(request: Request) -> dict[str, Any]: """The connected toolsets, their credential headers, and every tool. + Where a client finds which credentials to send. Each one goes as an + HTTP header on ``POST /runs``, named by ``header``. ``supplied`` is true + when this caller already has a value, from the server or the + deployment, so a client asks the user only for the rest. Values are + never returned, only whether there is one. + \f Read from the built agent rather than re-fetched from the index: this is what the agent actually connected to, which is the thing a client is asking about. diff --git a/src/mcp_cli/main.py b/src/mcp_cli/main.py index f65bb15..bcff209 100644 --- a/src/mcp_cli/main.py +++ b/src/mcp_cli/main.py @@ -12,8 +12,8 @@ from typing import Annotated, Any import typer -from mcp import ClientSession, types -from mcp.client.streamable_http import streamablehttp_client +from mcp import Client, types +from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client from rich.console import Console from rich.table import Table @@ -63,20 +63,23 @@ def parse_tool_args(pairs: list[str]) -> dict[str, Any]: async def _with_session[T]( url: str, - action: Callable[[ClientSession], Awaitable[T]], + action: Callable[[Client], Awaitable[T]], headers: dict[str, str] | None = None, ) -> T: - async with streamablehttp_client(url, headers=headers) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - return await action(session) + # Headers ride the HTTP client: the transport takes one rather than a + # header map, and `Client` handles the handshake that used to be ours. + transport = streamable_http_client( + url, http_client=create_mcp_http_client(headers=headers) + ) + async with Client(transport) as session: + return await action(session) def _tools_table(tools: list[types.Tool]) -> Table: table = Table("Tool", "Description", "Arguments") for tool in tools: - properties = tool.inputSchema.get("properties", {}) - required = set(tool.inputSchema.get("required", [])) + properties = tool.input_schema.get("properties", {}) + required = set(tool.input_schema.get("required", [])) arguments = ", ".join( f"{name}: {spec.get('type', 'any')}" + ("" if name in required else " (optional)") @@ -87,13 +90,13 @@ def _tools_table(tools: list[types.Tool]) -> Table: def _print_result(result: types.CallToolResult) -> None: - if result.isError: + if result.is_error: console.print("[bold red]Tool error[/bold red]") for block in result.content: if isinstance(block, types.TextContent): console.print(block.text) - if result.structuredContent is not None: - console.print_json(json.dumps(result.structuredContent)) + if result.structured_content is not None: + console.print_json(json.dumps(result.structured_content)) def _parse_headers_or_exit(header: list[str] | None) -> dict[str, str] | None: @@ -108,7 +111,7 @@ def list_tools(url: UrlOption = DEFAULT_URL, header: HeaderOption = None) -> Non """List the server's tools with their argument schemas.""" headers = _parse_headers_or_exit(header) - async def action(session: ClientSession) -> list[types.Tool]: + async def action(session: Client) -> list[types.Tool]: return (await session.list_tools()).tools console.print(_tools_table(asyncio.run(_with_session(url, action, headers)))) @@ -130,7 +133,7 @@ def call( except ValueError as error: raise typer.BadParameter(str(error)) from error - async def action(session: ClientSession) -> types.CallToolResult: + async def action(session: Client) -> types.CallToolResult: return await session.call_tool(tool, arguments) _print_result(asyncio.run(_with_session(url, action, headers))) @@ -141,7 +144,7 @@ def repl(url: UrlOption = DEFAULT_URL, header: HeaderOption = None) -> None: """Interactive loop on a single session: `list`, ` key=value ...`, `quit`.""" headers = _parse_headers_or_exit(header) - async def action(session: ClientSession) -> None: + async def action(session: Client) -> None: tools = (await session.list_tools()).tools console.print(f"Connected to [bold]{url}[/bold]") console.print("Commands: list | key=value ... | quit") diff --git a/src/mcp_runtime/bridge/__init__.py b/src/mcp_runtime/bridge/__init__.py new file mode 100644 index 0000000..7273f7f --- /dev/null +++ b/src/mcp_runtime/bridge/__init__.py @@ -0,0 +1,83 @@ +"""A LangChain tool as an MCP tool, with an output schema from its return. + +The SDK builds a tool from a *function*, reading name, description and schema +off its signature. A LangChain tool carries those as data instead, so this +package assembles :class:`mcp.server.mcpserver.tools.Tool` directly: arguments +from ``tool_call_schema``, the body from ``ainvoke``. + +The output model comes from the return annotation — a TypedDict/BaseModel, or +a union of them, with a required str ``message`` on at least one arm (the +:mod:`mcp_runtime.tool_result` contract) — so results travel as +``structuredContent`` and not JSON text alone. Any other annotation raises at +conversion, aborting ``build_server``: the SDK would wrap such values in +``{"result": ...}`` or the schema would guarantee nothing. + +The server validates every returned dict against the model; undeclared keys +are dropped, so the annotation is the complete list of keys a client can see. + +Arguments are made to match. The argument model forbids extras, so an +undeclared *input* is an error naming the parameter rather than an argument +silently dropped, and ``additionalProperties: false`` publishes that rule. + +This was ``langchain-mcp-adapters``' ``to_fastmcp`` wrapped in our own +output-schema derivation. That package is retired and its successor +(``langchain.mcp``) converts in the other direction only, so the conversion +is ours now. It is the only code that touches the SDK's internals. The half +we already owned, the output schema and its gate, is +:func:`mcp_runtime.tool_result.output_schema`. +""" + +from typing import Any + +from langchain_core.tools import BaseTool +from mcp.server.mcpserver.tools import Tool as MCPTool + +# Internal to the SDK, and imported only in this package: a minor release can +# change it, and test_the_sdk_internals_we_build_on_keep_their_shape says so. +from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata +from pydantic import BaseModel + +from mcp_runtime.bridge.annotations import return_annotation +from mcp_runtime.bridge.arguments import arguments, injected +from mcp_runtime.tool_result import output_schema + +__all__ = ["to_mcp_tool"] + + +def to_mcp_tool(tool: BaseTool) -> MCPTool: + """Convert a LangChain tool to an MCP tool, deriving its output schema. + + Raises ``RuntimeError`` (naming the tool) when the return annotation does + not follow the ToolResult contract — see the module docstring. + """ + if not isinstance(tool.args_schema, type) or not issubclass( + tool.args_schema, BaseModel + ): + raise RuntimeError( + f"tool {tool.name!r} has a dict args_schema; MCP tools are built " + "from a pydantic model" + ) + if names := injected(tool): + raise RuntimeError( + f"tool {tool.name!r} takes injected arguments {names}, which " + "nothing on the MCP side can fill" + ) + model, schema = output_schema(tool.name, return_annotation(tool)) + arg_model, parameters = arguments(tool) + + async def call(**arguments: Any) -> Any: + return await tool.ainvoke(arguments) + + return MCPTool( + fn=call, + name=tool.name, + description=tool.description, + parameters=parameters, + fn_metadata=FuncMetadata( + arg_model=arg_model, + output_model=model, + output_schema=schema, + wrap_output=False, + ), + is_async=True, + ) diff --git a/src/mcp_runtime/bridge/annotations.py b/src/mcp_runtime/bridge/annotations.py new file mode 100644 index 0000000..b6044fa --- /dev/null +++ b/src/mcp_runtime/bridge/annotations.py @@ -0,0 +1,17 @@ +"""Where a LangChain tool's return annotation is read. + +Read by :func:`~mcp_runtime.bridge.to_mcp_tool` for the output schema, and +by :mod:`mcp_runtime.declarations` for what a tool publishes. +""" + +from typing import Any, get_type_hints + +from langchain_core.tools import BaseTool + + +def return_annotation(tool: BaseTool) -> Any: + """The return annotation of the function behind a LangChain tool, if any.""" + fn = getattr(tool, "coroutine", None) or getattr(tool, "func", None) + if fn is None: + return None + return get_type_hints(fn).get("return") diff --git a/src/mcp_runtime/bridge/arguments.py b/src/mcp_runtime/bridge/arguments.py new file mode 100644 index 0000000..5c6d4c8 --- /dev/null +++ b/src/mcp_runtime/bridge/arguments.py @@ -0,0 +1,61 @@ +"""A LangChain tool's arguments as the MCP server validates and publishes them.""" + +from typing import Any, cast + +from langchain_core.tools import BaseTool, InjectedToolArg + +# Internal to the SDK, and imported only in this package: a minor release can +# change it, and test_the_sdk_internals_we_build_on_keep_their_shape says so. +from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase +from pydantic import BaseModel, create_model + + +def injected(tool: BaseTool) -> list[str]: + """Argument names the LangChain tool expects to be filled in for it. + + ``Annotated[str, InjectedToolArg]`` reaches pydantic as field metadata, + which is where this reads it — the marker is the whole declaration, so a + caller is meant to supply the value and a model never sees the parameter. + Nothing on the MCP side fills one, so a tool that takes one cannot be + served and :func:`~mcp_runtime.bridge.to_mcp_tool` refuses it. + """ + + def marked(metadata: list[Any]) -> bool: + return any( + isinstance(entry, InjectedToolArg) + or (isinstance(entry, type) and issubclass(entry, InjectedToolArg)) + for entry in metadata + ) + + schema = cast(type[BaseModel], tool.args_schema) + return [name for name, info in schema.model_fields.items() if marked(info.metadata)] + + +def arguments(tool: BaseTool) -> tuple[type[ArgModelBase], dict[str, Any]]: + """The argument model to validate against, and the schema to publish. + + Both forbid what the tool does not declare. Pydantic's default is to drop + an undeclared argument: the call succeeds, the tool runs without it, the + result looks ordinary, and the one thing that would explain it — that an + argument went missing — is the one thing nobody is told. A validation + error naming the parameter is something a model reads and corrects. + + Both halves are needed. Forbidding extras on the model is the enforcement; + ``additionalProperties: false`` on the published schema is what tells a + client the rule exists. Enforcing without publishing would refuse calls + the advertised schema allowed, which is its own kind of surprise. + """ + call_schema = cast(type[BaseModel], tool.tool_call_schema) + fields = { + name: (info.annotation, info) for name, info in call_schema.model_fields.items() + } + model = create_model( + f"{tool.name}Arguments", + **fields, # type: ignore[call-overload] + __base__=ArgModelBase, + ) + model.model_config["extra"] = "forbid" + model.model_rebuild(force=True) + schema = call_schema.model_json_schema() + schema["additionalProperties"] = False + return model, schema diff --git a/src/mcp_runtime/credentials.py b/src/mcp_runtime/credentials.py index 00bbbec..aaa4c52 100644 --- a/src/mcp_runtime/credentials.py +++ b/src/mcp_runtime/credentials.py @@ -2,48 +2,83 @@ Tools that act on a user's behalf (e.g. downloading from a source that needs the user's API key) must not bake secrets into the deployment. Instead the -MCP client sends them as HTTP headers on every call — `MultiServerMCPClient` -takes a ``headers`` dict per connection, ``mcp-cli`` takes ``--header`` — and -the tool reads them at call time with :func:`credential_from_header`. +MCP client sends them as HTTP headers on every call — a client connection +takes a ``headers`` dict, ``mcp-cli`` takes ``--header`` — and the tool reads +them at call time with :func:`credential_from_header`. The credential rides the transport, so it never appears in the model context, tool schemas, chat history or traces. Works with the stateless streamable HTTP runtime (every tool call is its own request, carrying its own headers) and from both sync and async tools. + +The headers reach the tool through a context variable that +:func:`credential_middleware` sets for the duration of each inbound request. +The v1 SDK published one of these itself, which this module read; v2 hands +its per-request context to handlers as an argument instead, and a tool body +several frames down has no way to reach an argument. So the contextvar is +ours now, filled from ``ctx.request`` at the top of the chain — and a tool +that reads a credential keeps the signature it always had. """ from collections.abc import Iterator from contextlib import contextmanager -from typing import Any, cast +from contextvars import ContextVar +from typing import Any -from mcp.server.lowlevel.server import request_ctx -from mcp.shared.context import RequestContext +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.mcpserver.exceptions import ToolError from starlette.requests import Request +#: Headers of the MCP request being served, or ``None`` outside one. +_headers: ContextVar[dict[str, str] | None] = ContextVar( + "mcp_runtime_credentials", default=None +) + + +class MissingCredentialError(ToolError): + """A required credential header was absent from the calling request. -class MissingCredentialError(Exception): - """A required credential header was absent from the calling request.""" + A ``ToolError`` so the message reaches the client: the v2 SDK sends a tool's + unexpected exception as ``Error executing tool `` and nothing more, + and this one is the message that tells the caller which header to send. + """ def __init__(self, header: str) -> None: super().__init__( f"missing credential: send the {header!r} HTTP header with your " - f"MCP requests (MultiServerMCPClient connections take a 'headers' " - f"dict; mcp-cli takes --header)" + f"MCP requests (a client connection takes a 'headers' dict; " + f"mcp-cli takes --header)" ) self.header = header +async def credential_middleware( + ctx: ServerRequestContext[Any, Any], call_next: CallNext +) -> HandlerResult: + """Publish the inbound request's headers for the handlers beneath it. + + Registered on every server :func:`mcp_runtime.server.build_server` builds. + Runs for each inbound message, so a stateless deployment — where each tool + call is its own HTTP request — reads that call's own headers and never a + neighbouring user's. + """ + request = ctx.request + headers = dict(request.headers) if isinstance(request, Request) else None + token = _headers.set(headers) + try: + return await call_next(ctx) + finally: + _headers.reset(token) + + def credential_from_header(header: str) -> str: """Return the named header from the MCP request that invoked this tool. Raises :class:`MissingCredentialError` if the header is absent or the tool was not invoked over HTTP (e.g. called directly in tests). """ - try: - request = request_ctx.get().request - except LookupError: - request = None - value = request.headers.get(header) if isinstance(request, Request) else None + headers = _headers.get() + value = headers.get(header.lower()) if headers else None if not value: raise MissingCredentialError(header) return value @@ -58,23 +93,8 @@ def header_context(headers: dict[str, str]) -> Iterator[None]: with header_context({"x-demo-token": "secret"}): my_tool.invoke({...}) """ - scope = { - "type": "http", - "method": "POST", - "path": "/mcp", - "headers": [ - (name.lower().encode(), value.encode()) for name, value in headers.items() - ], - } - context: RequestContext[Any, Any, Any] = RequestContext( - request_id=0, - meta=None, - session=cast(Any, None), - lifespan_context=None, - request=Request(scope), - ) - token = request_ctx.set(context) + token = _headers.set({name.lower(): value for name, value in headers.items()}) try: yield finally: - request_ctx.reset(token) + _headers.reset(token) diff --git a/src/mcp_runtime/declarations.py b/src/mcp_runtime/declarations.py index 8f7806b..8104444 100644 --- a/src/mcp_runtime/declarations.py +++ b/src/mcp_runtime/declarations.py @@ -36,9 +36,10 @@ async def clip_raster( from typing import Annotated, Any, get_args, get_origin, get_type_hints from langchain_core.tools import BaseTool -from mcp.server.fastmcp.tools import Tool as FastMCPTool +from mcp.server.mcpserver.tools import Tool as MCPTool -from mcp_runtime.fastmcp_output import _arms, _return_annotation +from mcp_runtime.bridge.annotations import return_annotation +from mcp_runtime.tool_result import arms # The ``_meta`` keys a client reads declarations from. Reverse-DNS per the MCP # ``_meta`` convention, so they cannot collide with ``ui`` (MCP Apps) or @@ -46,6 +47,7 @@ async def clip_raster( PRODUCES_META_KEY = "io.developmentseed.toolsets/produces" NOT_AUTHORED_META_KEY = "io.developmentseed.toolsets/notAuthored" + # Separator between the parts of a state key. NAMESPACE_SEP = "/" @@ -126,17 +128,17 @@ def _annotation_marker(annotation: Any, marker: type) -> Any | None: def output_fields(tool: BaseTool) -> list[str]: """A tool's ``ToolResult`` data keys, sorted. - Reads the same return annotation :mod:`mcp_runtime.fastmcp_output` derives + Reads the same return annotation :mod:`mcp_runtime.bridge` derives the output schema from, so the two cannot disagree. ``message`` is the model-facing text, never state, and is excluded along with the error arm's own fields. """ - annotation = _return_annotation(tool) + annotation = return_annotation(tool) if annotation is None: return [] fields = { field - for arm in _arms(annotation) + for arm in arms(annotation) for field in get_type_hints(arm, include_extras=True) if field not in ("message", "error", "detail") } @@ -209,9 +211,9 @@ def state_declarations(toolset: str, tools: list[BaseTool]) -> dict[str, Any]: def with_state_meta( - toolset: str, tools: list[BaseTool], fastmcp_tools: list[FastMCPTool] -) -> list[FastMCPTool]: - """Return ``fastmcp_tools`` with what each tool declares stamped on. + toolset: str, tools: list[BaseTool], mcp_tools: list[MCPTool] +) -> list[MCPTool]: + """Return ``mcp_tools`` with what each tool declares stamped on. Published data keys go into ``_meta``; :class:`NotAuthored` parameters go into ``_meta`` *and* into the served input schema, as a sentence on the @@ -224,7 +226,7 @@ def with_state_meta( Raises if :class:`NotAuthored` tags something that is not one of its tool's parameters, naming the offender so a typo fails ``build_server`` rather than going unnoticed until a client connects — the gate - :mod:`mcp_runtime.fastmcp_output` applies to returns. + :mod:`mcp_runtime.bridge` applies to returns. """ publishes_by_tool: dict[str, list[str]] = {} not_authored_by_tool: dict[str, list[str]] = {} @@ -243,24 +245,24 @@ def with_state_meta( if names: not_authored_by_tool[tool.name] = names - def stamped(fastmcp_tool: FastMCPTool) -> FastMCPTool: - meta = dict(fastmcp_tool.meta or {}) - if fields := publishes_by_tool.get(fastmcp_tool.name): + def stamped(mcp_tool: MCPTool) -> MCPTool: + meta = dict(mcp_tool.meta or {}) + if fields := publishes_by_tool.get(mcp_tool.name): meta[PRODUCES_META_KEY] = [ { - "stateKey": qualified(toolset, fastmcp_tool.name, field), + "stateKey": qualified(toolset, mcp_tool.name, field), "field": field, } for field in fields ] - names = not_authored_by_tool.get(fastmcp_tool.name) + names = not_authored_by_tool.get(mcp_tool.name) if names: meta[NOT_AUTHORED_META_KEY] = names if not meta: - return fastmcp_tool + return mcp_tool update: dict[str, Any] = {"meta": meta} if names: - update["parameters"] = _noted(fastmcp_tool.parameters, names) - return fastmcp_tool.model_copy(update=update) + update["parameters"] = _noted(mcp_tool.parameters, names) + return mcp_tool.model_copy(update=update) - return [stamped(fastmcp_tool) for fastmcp_tool in fastmcp_tools] + return [stamped(mcp_tool) for mcp_tool in mcp_tools] diff --git a/src/mcp_runtime/fastmcp_output.py b/src/mcp_runtime/fastmcp_output.py deleted file mode 100644 index 5428a9b..0000000 --- a/src/mcp_runtime/fastmcp_output.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Upstream ``to_fastmcp`` plus an output schema from the return annotation. - -langchain-mcp-adapters' ``to_fastmcp`` discards the return annotation, so -FastMCP never advertises an ``outputSchema`` and results reach clients as -JSON text only. This wrapper derives the output model from the annotation — -a TypedDict/BaseModel, or a union of them, with a required str ``message`` -on at least one arm (the :mod:`mcp_runtime.tool_result` contract) — so -results also travel as ``structuredContent``. Any other annotation raises at -conversion, aborting ``build_server``: FastMCP would wrap such values in -``{"result": ...}`` or the schema would guarantee nothing. - -FastMCP validates every returned dict against the model; undeclared keys are -dropped, so the annotation is the complete list of keys a client can see. - -Arguments are made to match: :func:`_forbid_undeclared_arguments` makes an -undeclared *input* an error, where before it was accepted and silently dropped. -""" - -from types import UnionType -from typing import Any, Union, get_args, get_origin, get_type_hints, is_typeddict - -from langchain_core.tools import BaseTool -from langchain_mcp_adapters.tools import to_fastmcp as _to_fastmcp -from mcp.server.fastmcp.tools import Tool as FastMCPTool -from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata -from pydantic import BaseModel, RootModel - - -def _return_annotation(tool: BaseTool) -> Any: - """The return annotation of the function behind a LangChain tool, if any.""" - fn = getattr(tool, "coroutine", None) or getattr(tool, "func", None) - if fn is None: - return None - return get_type_hints(fn).get("return") - - -def _arms(annotation: Any) -> tuple[Any, ...]: - """The annotation's union members, or the annotation itself.""" - if get_origin(annotation) in (Union, UnionType): - return get_args(annotation) - return (annotation,) - - -def _structured_dict(arm: Any) -> bool: - """Whether one annotation arm maps 1:1 onto a structuredContent object.""" - return is_typeddict(arm) or (isinstance(arm, type) and issubclass(arm, BaseModel)) - - -def _resolve(schema: dict[str, Any], defs: dict[str, Any]) -> dict[str, Any]: - """A schema node with its top-level ``$ref`` resolved against ``$defs``.""" - reference = schema.get("$ref", "") - if reference.startswith("#/$defs/"): - return defs.get(reference.removeprefix("#/$defs/"), {}) - return schema - - -def _shape_schema(schema: dict[str, Any]) -> dict[str, Any]: - """The advertised schema, object-rooted as MCP clients expect. - - ``RootModel`` schemas root at a ``$ref`` (single model) or an ``anyOf`` - (union): inline the former, stamp ``"type": "object"`` on the latter. - """ - defs = dict(schema.get("$defs", {})) - if schema.get("$ref", "").startswith("#/$defs/"): - inlined = dict(_resolve(schema, defs)) - defs.pop(schema["$ref"].removeprefix("#/$defs/")) - if defs: - inlined["$defs"] = defs - return inlined - if "anyOf" in schema: - return {"type": "object", "anyOf": schema["anyOf"], "$defs": defs} - return schema - - -def _offers_message(schema: dict[str, Any]) -> bool: - """Whether some arm of the schema requires a str ``message`` property.""" - defs: dict[str, Any] = schema.get("$defs", {}) - arms = [_resolve(arm, defs) for arm in schema.get("anyOf", [schema])] - return any( - arm.get("properties", {}).get("message", {}).get("type") == "string" - and "message" in arm.get("required", []) - for arm in arms - ) - - -def _forbid_undeclared_arguments(converted: FastMCPTool) -> None: - """Reject arguments a tool does not declare, rather than dropping them. - - ``to_fastmcp`` upstream publishes the input schema from the LangChain tool's - ``tool_call_schema`` but validates against a *separate* model, built from - that schema's fields alone:: - - arg_model = create_model(..., **field_definitions, __base__=ArgModelBase) - - ``ArgModelBase`` sets no extra policy, so pydantic's default applies: an - argument the tool does not declare is dropped, the tool runs without it, and - nothing anywhere says so. - - That is a silent wrong answer rather than a loud failure: the call succeeds, - the result looks ordinary, and the one thing that would explain it — that an - argument went missing — is the one thing nobody is told. A validation error - naming the parameter is something a model reads and corrects instead. - - Both halves are needed. Forbidding extras on the model is the enforcement; - ``additionalProperties: false`` on the published schema is what tells a - client the rule exists. Enforcing without publishing would refuse calls the - advertised schema allowed, which is its own kind of surprise. - """ - arg_model = converted.fn_metadata.arg_model - arg_model.model_config["extra"] = "forbid" - arg_model.model_rebuild(force=True) - converted.parameters["additionalProperties"] = False - - -def to_fastmcp(tool: BaseTool) -> FastMCPTool: - """Convert a LangChain tool to FastMCP, deriving its output schema. - - Raises ``RuntimeError`` (naming the tool) when the return annotation does - not follow the ToolResult contract — see the module docstring. - """ - converted = _to_fastmcp(tool) - _forbid_undeclared_arguments(converted) - annotation = _return_annotation(tool) - if annotation is None or not all( - _structured_dict(arm) for arm in _arms(annotation) - ): - raise RuntimeError( - f"tool {tool.name!r} needs a ToolResult return annotation " - f"(a TypedDict/BaseModel or a union of them; got {annotation!r}) " - "— see mcp_runtime.tool_result" - ) - model: type[BaseModel] = RootModel[annotation] # type: ignore[valid-type] - schema = _shape_schema(model.model_json_schema()) - if not _offers_message(schema): - raise RuntimeError( - f"tool {tool.name!r} does not follow the ToolResult contract: no arm " - "of its return annotation has a required str 'message' property" - ) - converted.fn_metadata = FuncMetadata( - arg_model=converted.fn_metadata.arg_model, - output_model=model, - output_schema=schema, - wrap_output=False, - ) - return converted diff --git a/src/mcp_runtime/index.py b/src/mcp_runtime/index.py index 7b46f30..712f06a 100644 --- a/src/mcp_runtime/index.py +++ b/src/mcp_runtime/index.py @@ -128,7 +128,7 @@ class ToolsetEntry(BaseModel): class Connection(BaseModel): - """A langchain-mcp-adapters StreamableHttpConnection.""" + """How to reach one toolset: a streamable HTTP endpoint.""" transport: Literal["streamable_http"] = "streamable_http" url: str @@ -138,7 +138,7 @@ class Index(BaseModel): """Directory of every deployed toolset. ``connections`` is shaped so an agent can pass it straight to - ``MultiServerMCPClient``; ``toolsets`` adds per-service status and tool + an MCP client; ``toolsets`` adds per-service status and tool names for humans. """ diff --git a/src/mcp_runtime/local.py b/src/mcp_runtime/local.py index 68257f9..fbef7d2 100644 --- a/src/mcp_runtime/local.py +++ b/src/mcp_runtime/local.py @@ -12,7 +12,7 @@ (so ``//mcp`` and ``//health`` match production's ingress paths exactly), and serves the same directory shape at ``/`` that ``mcp-index`` serves in production. ``mcp-agent``, ``mcp-cli`` and -``MultiServerMCPClient`` all consume that shape already, so they work +``mcp_agent`` all consume that shape already, so they work unchanged against ``http://localhost:8000/``. Driven by environment variables: @@ -41,13 +41,13 @@ import uvicorn from fastapi import FastAPI -from mcp.server.fastmcp import FastMCP from pydantic import Field, IPvAnyAddress, field_validator from pydantic_settings import BaseSettings, NoDecode from mcp_runtime.declarations import state_declarations from mcp_runtime.index import Connection, Index, StateDeclarations, ToolsetEntry from mcp_runtime.server import ( + ToolsetServer, build_server, load_credential_headers, load_tools, @@ -94,9 +94,18 @@ def _split_csv(cls, value: object) -> object: return value -def build_local_app(toolsets: list[str], base_url: str) -> FastAPI: +def build_local_app( + toolsets: list[str], base_url: str, host: str = "127.0.0.1" +) -> FastAPI: """Mount each toolset's MCP server at ``/``, plus an index at ``/``. + ``host`` is the address the app will be served on, passed to every server + built here. The SDK turns on DNS-rebinding protection — a check of the + ``Host`` header against loopback — for a server built for ``127.0.0.1`` + and leaves it off otherwise. Building for loopback while serving on + ``0.0.0.0`` would answer 421 to every request that arrives by any other + name, with the index at ``/`` still answering 200 above it. + Mirrors ``mcp_runtime.index.build_app``'s ``Index``/``ToolsetEntry`` shape, but built directly from the in-process servers instead of over HTTP from Kubernetes-discovered Services, so it needs no cluster. Every entry is @@ -109,7 +118,7 @@ def build_local_app(toolsets: list[str], base_url: str) -> FastAPI: raise RuntimeError(f"TOOLSETS has duplicate entries: {toolsets}") base_url = base_url.rstrip("/") - servers: list[tuple[str, FastMCP]] = [] + servers: list[tuple[str, ToolsetServer]] = [] connections: dict[str, Connection] = {} entries: list[ToolsetEntry] = [] @@ -117,7 +126,7 @@ def build_local_app(toolsets: list[str], base_url: str) -> FastAPI: module_name = toolset_module_name(name) tools = load_tools(module_name) credential_headers = load_credential_headers(module_name) - server = build_server(name, module_name) + server = build_server(name, module_name, host=host) servers.append((name, server)) url = f"{base_url}/{name}/mcp" @@ -137,7 +146,7 @@ def build_local_app(toolsets: list[str], base_url: str) -> FastAPI: ) # Each server's own Starlette app runs its session manager from its own - # lifespan (see FastMCP.streamable_http_app), but mounting it as a + # lifespan (see MCPServer.streamable_http_app), but mounting it as a # sub-application means the ASGI "lifespan" scope never reaches it — only # the outermost app receives it. Enter every server's session manager # here instead, so mounting several together still starts them all. @@ -175,5 +184,9 @@ def main() -> None: """Console entry point (``mcp-serve-local``).""" settings = LocalSettings() toolsets = settings.toolsets or discover_toolsets(settings.toolsets_dir) - app = build_local_app(toolsets, base_url=f"http://{settings.host}:{settings.port}") + app = build_local_app( + toolsets, + base_url=f"http://{settings.host}:{settings.port}", + host=str(settings.host), + ) uvicorn.run(app, host=str(settings.host), port=settings.port) diff --git a/src/mcp_runtime/server.py b/src/mcp_runtime/server.py index 49a836a..9a8b351 100644 --- a/src/mcp_runtime/server.py +++ b/src/mcp_runtime/server.py @@ -22,16 +22,19 @@ import importlib import re from ipaddress import IPv4Address +from typing import Any, Literal from langchain_core.tools import BaseTool -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from pydantic import Field, IPvAnyAddress, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response -from mcp_runtime.fastmcp_output import to_fastmcp +from mcp_runtime.credentials import credential_middleware from mcp_runtime.declarations import state_declarations, with_state_meta +from mcp_runtime.bridge import to_mcp_tool from mcp_runtime.views import load_views, register_views, with_view_meta @@ -139,14 +142,52 @@ def credential_instructions(credential_headers: list[str]) -> str | None: ) +class ToolsetServer(MCPServer): + """An ``MCPServer`` that remembers where and how it is served. + + The v1 SDK held host, port, path and statelessness on the server itself; + v2 takes them per call, defaulting to ``/mcp`` and stateful. Carrying them + here keeps the published URL shape and the stateless transport attached to + the server object, so a caller that does the serving itself — the local + host below, or a consumer running ``streamable_http_app()`` under its own + uvicorn — gets what the toolset was built for without repeating it. + """ + + def __init__( + self, *args: Any, host: str, port: int, path: str, **kwargs: Any + ) -> None: + super().__init__(*args, **kwargs) + self._transport: dict[str, Any] = { + "host": host, + "streamable_http_path": path, + "stateless_http": True, + } + self._port = port + + def streamable_http_app(self, **kwargs: Any) -> Starlette: + return super().streamable_http_app(**{**self._transport, **kwargs}) + + def run( + self, + transport: Literal["stdio", "sse", "streamable-http"] = "streamable-http", + **kwargs: Any, + ) -> None: + # The remembered settings are streamable HTTP's. Another transport gets + # the SDK's own defaults and whatever the caller passed, and nothing + # of ours: a stdio server has no port to bind. + if transport == "streamable-http": + kwargs = {**self._transport, "port": self._port, **kwargs} + super().run(transport, **kwargs) + + def build_server( toolset: str, module_name: str | None = None, host: str = "127.0.0.1", port: int = 8000, path_prefix: str = "", -) -> FastMCP: - """Build a stateless FastMCP server exposing the toolset's TOOLS. +) -> ToolsetServer: + """Build a stateless MCP server exposing the toolset's TOOLS. ``path_prefix`` moves the MCP endpoint under a path (``/hello/mcp``) for a proxy that cannot rewrite one away. ``/health`` is served at the prefix @@ -161,19 +202,19 @@ def build_server( credential_headers = load_credential_headers(module_name) views = load_views(module_name) - fastmcp_tools = with_view_meta( - toolset, module_name, [to_fastmcp(tool) for tool in tools], views + mcp_tools = with_view_meta( + toolset, module_name, [to_mcp_tool(tool) for tool in tools], views ) - fastmcp_tools = with_state_meta(toolset, tools, fastmcp_tools) + mcp_tools = with_state_meta(toolset, tools, mcp_tools) - server = FastMCP( + server = ToolsetServer( name=f"mcp-{toolset}", instructions=credential_instructions(credential_headers), - tools=fastmcp_tools, + tools=mcp_tools, + middleware=[credential_middleware], host=host, port=port, - streamable_http_path=f"{prefix}/mcp", - stateless_http=True, + path=f"{prefix}/mcp", ) register_views(server, toolset, module_name, views) @@ -206,4 +247,4 @@ def main() -> None: port=settings.port, path_prefix=settings.path_prefix, ) - server.run(transport="streamable-http") + server.run() diff --git a/src/mcp_runtime/tool_result.py b/src/mcp_runtime/tool_result.py index 1441400..ba5c970 100644 --- a/src/mcp_runtime/tool_result.py +++ b/src/mcp_runtime/tool_result.py @@ -17,13 +17,15 @@ class SearchDatasetsResult(ToolResult): async def search_datasets(query: str) -> SearchDatasetsResult | ToolError: ... -``mcp_runtime.fastmcp_output`` derives the tool's MCP ``outputSchema`` from +``mcp_runtime.bridge`` derives the tool's MCP ``outputSchema`` from the annotation and refuses to serve a tool that breaks the contract — a deploy-time gate, not a convention. """ -from typing import Any, TypedDict +from types import UnionType +from typing import Any, TypedDict, Union, get_args, get_origin, is_typeddict +from pydantic import BaseModel, RootModel from typing_extensions import TypeIs @@ -43,3 +45,74 @@ class ToolError(TypedDict): def is_error(result: dict[str, Any] | ToolError) -> TypeIs[ToolError]: """Whether a helper's raw dict is an error return (truthy ``error`` key).""" return bool(result.get("error")) + + +def arms(annotation: Any) -> tuple[Any, ...]: + """The annotation's union members, or the annotation itself.""" + if get_origin(annotation) in (Union, UnionType): + return get_args(annotation) + return (annotation,) + + +def output_schema(name: str, annotation: Any) -> tuple[type[BaseModel], dict[str, Any]]: + """The output model a tool's returns are validated against, and its schema. + + Raises ``RuntimeError`` naming the tool when ``annotation`` (the tool's + return annotation) breaks the contract. + """ + if annotation is None or not all(_structured_dict(arm) for arm in arms(annotation)): + raise RuntimeError( + f"tool {name!r} needs a ToolResult return annotation " + f"(a TypedDict/BaseModel or a union of them; got {annotation!r}) " + "— see mcp_runtime.tool_result" + ) + model: type[BaseModel] = RootModel[annotation] + schema = _shape_schema(model.model_json_schema()) + if not _offers_message(schema): + raise RuntimeError( + f"tool {name!r} does not follow the ToolResult contract: no arm " + "of its return annotation has a required str 'message' property" + ) + return model, schema + + +def _structured_dict(arm: Any) -> bool: + """Whether one annotation arm maps 1:1 onto a structuredContent object.""" + return is_typeddict(arm) or (isinstance(arm, type) and issubclass(arm, BaseModel)) + + +def _resolve(schema: dict[str, Any], defs: dict[str, Any]) -> dict[str, Any]: + """A schema node with its top-level ``$ref`` resolved against ``$defs``.""" + reference = schema.get("$ref", "") + if reference.startswith("#/$defs/"): + return defs.get(reference.removeprefix("#/$defs/"), {}) + return schema + + +def _shape_schema(schema: dict[str, Any]) -> dict[str, Any]: + """The advertised schema, object-rooted as MCP clients expect. + + ``RootModel`` schemas root at a ``$ref`` (single model) or an ``anyOf`` + (union): inline the former, stamp ``"type": "object"`` on the latter. + """ + defs = dict(schema.get("$defs", {})) + if schema.get("$ref", "").startswith("#/$defs/"): + inlined = dict(_resolve(schema, defs)) + defs.pop(schema["$ref"].removeprefix("#/$defs/")) + if defs: + inlined["$defs"] = defs + return inlined + if "anyOf" in schema: + return {"type": "object", "anyOf": schema["anyOf"], "$defs": defs} + return schema + + +def _offers_message(schema: dict[str, Any]) -> bool: + """Whether some arm of the schema requires a str ``message`` property.""" + defs: dict[str, Any] = schema.get("$defs", {}) + members = [_resolve(arm, defs) for arm in schema.get("anyOf", [schema])] + return any( + arm.get("properties", {}).get("message", {}).get("type") == "string" + and "message" in arm.get("required", []) + for arm in members + ) diff --git a/src/mcp_runtime/views.py b/src/mcp_runtime/views.py index 4317c44..f87795d 100644 --- a/src/mcp_runtime/views.py +++ b/src/mcp_runtime/views.py @@ -23,10 +23,9 @@ import importlib from pathlib import Path -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.resources import FunctionResource -from mcp.server.fastmcp.tools import Tool as FastMCPTool -from pydantic import AnyUrl +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.resources import FunctionResource +from mcp.server.mcpserver.tools import Tool as MCPTool # The ``_meta`` an MCP Apps host reads: ``{"ui": {"resourceUri": "ui://..."}}``. VIEW_META_KEY = "ui" @@ -70,9 +69,9 @@ def view_html(module_name: str, view_id: str) -> str: def with_view_meta( toolset: str, module_name: str, - tools: list[FastMCPTool], + tools: list[MCPTool], views: dict[str, str], -) -> list[FastMCPTool]: +) -> list[MCPTool]: """Return ``tools`` with each view-bearing tool's ``_meta`` stamped. Pure: inputs are left untouched; each view-owning tool is replaced by a copy @@ -88,7 +87,7 @@ def with_view_meta( ) view_html(module_name, view_id) # raise now if the bundle is missing - def stamped(tool: FastMCPTool) -> FastMCPTool: + def stamped(tool: MCPTool) -> MCPTool: if tool.name not in views: return tool uri = view_resource_uri(toolset, views[tool.name]) @@ -100,14 +99,14 @@ def stamped(tool: FastMCPTool) -> FastMCPTool: def register_views( - server: FastMCP, toolset: str, module_name: str, views: dict[str, str] + server: MCPServer, toolset: str, module_name: str, views: dict[str, str] ) -> None: """Register each view as an MCP resource (``ui:///``).""" for view_id in sorted(set(views.values())): html = view_html(module_name, view_id) server.add_resource( FunctionResource( - uri=AnyUrl(view_resource_uri(toolset, view_id)), + uri=view_resource_uri(toolset, view_id), name=f"{toolset}-{view_id}", mime_type=VIEW_MIME_TYPE, fn=lambda html=html: html, diff --git a/src/mcp_state/__init__.py b/src/mcp_state/__init__.py index 5031199..96bd968 100644 --- a/src/mcp_state/__init__.py +++ b/src/mcp_state/__init__.py @@ -116,6 +116,7 @@ StateEntry, authored, merge_tool_state, + tool_meta, turns_written, ) @@ -158,6 +159,7 @@ "restore_structured", "state_keys", "thread_of", + "tool_meta", "turns_written", "unresolved", "unresolved_message", diff --git a/src/mcp_state/injection.py b/src/mcp_state/injection.py index df9e9f0..caed95f 100644 --- a/src/mcp_state/injection.py +++ b/src/mcp_state/injection.py @@ -30,6 +30,7 @@ in a way the model can act on. """ +import inspect from collections.abc import Callable, Sequence from typing import Annotated, Any @@ -46,7 +47,7 @@ unresolved_message, ) from mcp_state.receipts import INJECTED_ARTIFACT_KEY, Receipt -from mcp_state.state import TOOL_STATE_KEY, StateEntry +from mcp_state.state import TOOL_STATE_KEY, StateEntry, tool_meta class StateRefusal(ToolException): @@ -105,12 +106,11 @@ def handle(error: ToolException) -> Handled: def not_authored_for(tool: BaseTool) -> frozenset[str]: """The tool's parameters its server says a model must not write. - ``langchain_mcp_adapters`` preserves the MCP tool's ``_meta`` onto the + ``langchain.mcp`` preserves the MCP tool's ``_meta`` onto the converted LangChain tool's ``metadata``, which is what makes a server-side declaration reachable here at all. """ - meta = (getattr(tool, "metadata", None) or {}).get("_meta") or {} - found = meta.get(NOT_AUTHORED_META_KEY) + found = tool_meta(tool).get(NOT_AUTHORED_META_KEY) if not isinstance(found, list): return frozenset() return frozenset(str(name) for name in found if isinstance(name, str)) @@ -250,6 +250,25 @@ def _with_receipts( return content, {**(artifact or {}), INJECTED_ARTIFACT_KEY: receipts} +def _declares_runtime(fn: Callable[..., Any]) -> bool: + """Whether the wrapped tool wants LangGraph's ``runtime`` passed to it. + + Named parameters only, never ``**kwargs``. An MCP tool's body is + ``async def call_tool(**arguments)`` and sends every argument it is given + to the server, so forwarding a runtime object to one puts it on the wire, + where it fails to serialise. A tool that asks for a runtime by name is a + local one that means it. + """ + try: + parameters = inspect.signature(fn).parameters + except (TypeError, ValueError): # a callable with no inspectable signature + return False + return any( + name == "runtime" and parameter.kind is not parameter.VAR_KEYWORD + for name, parameter in parameters.items() + ) + + def bind_injected(tool: BaseTool) -> BaseTool: """Return ``tool`` with handles offered, and narrowed where its server said. @@ -271,6 +290,7 @@ def bind_injected(tool: BaseTool) -> BaseTool: tool, "func" ) response_format = tool.response_format + forwards_runtime = _declares_runtime(inner) async def call( injected_state: Annotated[ @@ -298,7 +318,9 @@ async def call( raise StateRefusal( unresolved_message(tool.name, leftover, injected_state, not_authored) ) - result = await inner(runtime=runtime, **arguments) + if forwards_runtime: + arguments["runtime"] = runtime + result = await inner(**arguments) return _with_receipts(result, receipts, response_format) # `metadata` is carried so the tool's `_meta` survives binding — a UI reads diff --git a/src/mcp_state/middleware.py b/src/mcp_state/middleware.py index 360c82d..d4fff99 100644 --- a/src/mcp_state/middleware.py +++ b/src/mcp_state/middleware.py @@ -62,7 +62,13 @@ Receipt, receipts_of, ) -from mcp_state.state import MODEL_AUTHORED, TOOL_STATE_KEY, AgentState, StateEntry +from mcp_state.state import ( + MODEL_AUTHORED, + TOOL_STATE_KEY, + AgentState, + StateEntry, + tool_meta, +) MESSAGE_KEY = "message" @@ -85,9 +91,9 @@ Published = dict[str, dict[str, dict[str, Any]]] #: Metadata key naming the MCP server a tool was loaded from. Stamped by the -#: host at load (``langchain_mcp_adapters`` takes a ``server_name`` and puts it -#: nowhere on the tool), and read here so an undeclared capture is keyed the -#: same three-part way a declared one is. +#: host at load (``langchain.mcp`` records the serving server elsewhere on the +#: tool's metadata, not in this form), and read here so an undeclared capture +#: is keyed the same three-part way a declared one is. SERVER_METADATA_KEY = "mcp_toolsets_server" @@ -137,14 +143,13 @@ def owners(tools: list[BaseTool]) -> dict[str, str]: def publications(tools: list[BaseTool]) -> Published: """What each tool declares it publishes, from its server ``_meta``. - ``langchain_mcp_adapters`` preserves the MCP tool's ``_meta`` onto the + ``langchain.mcp`` preserves the MCP tool's ``_meta`` onto the converted LangChain tool, which is what makes the server-side declaration reachable here. Tools declaring nothing are simply absent. """ found: Published = {} for tool in tools: - meta = (getattr(tool, "metadata", None) or {}).get("_meta") or {} - declarations = meta.get(PRODUCES_META_KEY) + declarations = tool_meta(tool).get(PRODUCES_META_KEY) if not declarations: continue found[tool.name] = { diff --git a/src/mcp_state/state.py b/src/mcp_state/state.py index 664b815..daba16d 100644 --- a/src/mcp_state/state.py +++ b/src/mcp_state/state.py @@ -50,6 +50,24 @@ from langchain.agents.middleware import AgentState as _BaseAgentState + +def tool_meta(tool: Any) -> dict[str, Any]: + """The MCP ``_meta`` a converted LangChain tool carries, wherever it sits. + + ``langchain.mcp`` keeps what it reads off an MCP tool under a single + ``mcp`` namespace — ``metadata["mcp"]["tool"]["_meta"]`` — so an MCP tool's + provenance stays apart from anything else on the LangChain tool. A tool + built locally, or one stamped by this package, carries ``_meta`` flat. + + Both are read here, and the nested one wins. Every reader of a server-side + declaration goes through this: the declarations are advisory, so getting + the path wrong does not fail, it just silently stops applying them. + """ + metadata = getattr(tool, "metadata", None) or {} + nested = ((metadata.get("mcp") or {}).get("tool") or {}).get("_meta") + return nested or metadata.get("_meta") or {} + + TOOL_STATE_KEY = "tool_state" #: Serialised bytes of stored values to keep before evicting the oldest. diff --git a/tests/mcp_agent/test_agent.py b/tests/mcp_agent/test_agent.py index 83b5dcb..2ea0e88 100644 --- a/tests/mcp_agent/test_agent.py +++ b/tests/mcp_agent/test_agent.py @@ -1,3 +1,4 @@ +import httpx2 import pytest from pydantic import ValidationError from typer.main import get_command @@ -6,6 +7,7 @@ app, AgentSettings, connect_error_hint, + connect_failure, connections_from, credential_client_factory, credential_headers_from, @@ -122,8 +124,25 @@ def test_with_credential_support_wires_every_connection(): "dataset-search": {"transport": "streamable_http", "url": "http://b/mcp"}, } wired = with_credential_support(connections, {"credential-demo": ["x-demo-token"]}) - assert all(callable(c["httpx_client_factory"]) for c in wired.values()) - assert "httpx_client_factory" not in connections["credential-demo"] # untouched + assert set(wired) == set(connections) + assert all( + callable(client.transport.httpx_client_factory) for client in wired.values() + ) + assert connections["credential-demo"] == { # untouched + "transport": "streamable_http", + "url": "http://a/mcp", + } + + +def test_the_credential_factory_takes_what_the_transport_passes_it(): + """fastmcp calls the factory with `follow_redirects`, which the old adapter + did not. A factory that refuses an argument the transport passes fails at + connect time, where it reads as the server being unreachable.""" + factory = credential_client_factory(["x-demo-token"]) + + client = factory(headers={"accept": "application/json"}, follow_redirects=True) + + assert client.headers["accept"] == "application/json" def test_connect_error_hint_only_for_urls_missing_mcp_path(): @@ -165,3 +184,82 @@ def test_chat_is_an_explicit_subcommand(): # every Typer version. A collapsed app is a plain Command, which carries # no sub-command mapping at all. assert "chat" in getattr(command, "commands", {}), "the command group collapsed" + + +def test_a_refused_connection_is_a_connect_failure_however_fastmcp_wraps_it(): + """fastmcp reports a connection that never came up as a bare `RuntimeError` + with the transport's exception as its cause, so the types an `except` on + the transport would name never reach the caller. The cause is what names + the port, so that is what is reported.""" + refused = httpx2.ConnectError("All connection attempts failed") + wrapped = RuntimeError("Client failed to connect: All connection attempts failed") + wrapped.__cause__ = refused + + assert connect_failure(wrapped) is refused + assert connect_failure(ExceptionGroup("build", [wrapped])) is refused + assert connect_failure(refused) is refused + assert connect_failure(RuntimeError("something else entirely")) is None + assert connect_failure(ExceptionGroup("build", [ValueError("no")])) is None + + +async def test_overlapping_calls_each_carry_their_own_users_credentials(monkeypatch): + """Two users' calls in flight at once, through the one client an agent holds. + + A fastmcp client is reentrant: a call that starts while another is in + flight joins that session, whose HTTP client was built — headers and all — + for whoever connected it. Listed through `list_tools`, each call opens a + session of its own, so the second user's call cannot go out as the first. + """ + import asyncio + import json + import sys + import types + + import uvicorn + from langchain_core.tools import tool + + from mcp_agent.main import list_tools + from mcp_runtime.credentials import credential_from_header + from mcp_runtime.server import build_server + from mcp_runtime.tool_result import ToolResult + + @tool + async def whoami() -> ToolResult: + """Report the caller's credential, slowly enough for calls to overlap.""" + seen = credential_from_header("x-demo-token") + await asyncio.sleep(0.2) + return {"message": seen} + + module = types.ModuleType("overlap_toolset.tools") + module.TOOLS = [whoami] + module.CREDENTIAL_HEADERS = ["x-demo-token"] + monkeypatch.setitem(sys.modules, "overlap_toolset.tools", module) + + # A real server on a real port: the session reuse under test is the HTTP + # transport's, and a test client in-process would bypass it. + app = build_server("overlap-toolset").streamable_http_app() + uv = uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=0, log_level="error") + ) + serving = asyncio.create_task(uv.serve()) + while not uv.started: + await asyncio.sleep(0.01) + try: + port = uv.servers[0].sockets[0].getsockname()[1] + connections = {"demo": {"url": f"http://127.0.0.1:{port}/mcp"}} + (client,) = with_credential_support( + connections, {"demo": ["x-demo-token"]} + ).values() + (whoami_tool,) = await list_tools(client) + + def as_user(name: str) -> asyncio.Task: + with user_credentials({"x-demo-token": name}): + return asyncio.create_task(whoami_tool.ainvoke({})) + + results = await asyncio.gather(as_user("alice"), as_user("bob")) + finally: + uv.should_exit = True + await serving + + seen = [json.loads(blocks[0]["text"])["message"] for blocks in results] + assert seen == ["alice", "bob"] diff --git a/tests/mcp_runtime/test_fastmcp_output.py b/tests/mcp_runtime/test_bridge.py similarity index 68% rename from tests/mcp_runtime/test_fastmcp_output.py rename to tests/mcp_runtime/test_bridge.py index faa15bc..d35b7b9 100644 --- a/tests/mcp_runtime/test_fastmcp_output.py +++ b/tests/mcp_runtime/test_bridge.py @@ -1,4 +1,4 @@ -"""The extended to_fastmcp: derived output schemas and the ToolResult gate.""" +"""The extended to_mcp_tool: derived output schemas and the ToolResult gate.""" from typing import Any, NotRequired, TypedDict @@ -6,7 +6,7 @@ from langchain_core.tools import tool from pydantic import BaseModel -from mcp_runtime.fastmcp_output import to_fastmcp +from mcp_runtime.bridge import to_mcp_tool from mcp_runtime.tool_result import ToolError, ToolResult @@ -31,15 +31,19 @@ def message_only(text: str) -> ToolResult: async def run_structured(converted, arguments: dict[str, Any]) -> Any: - """The structuredContent FastMCP would emit for a call.""" - result = await converted.run(arguments, convert_result=True) - assert isinstance(result, tuple), "no structured content was produced" - _unstructured, structured = result - return structured + """The structuredContent the server would emit for a call. + + ``context`` is the per-request context the SDK passes to a tool that asked + for one. These tools never do — the conversion sets no ``context_kwarg`` — + so ``run`` has nothing to pass it to, and there is nothing to build here. + """ + result = await converted.run(arguments, None, convert_result=True) + assert result.structured_content is not None, "no structured content was produced" + return result.structured_content def test_output_schema_is_object_rooted_union(): - schema = to_fastmcp(probe).output_schema + schema = to_mcp_tool(probe).output_schema assert schema is not None assert schema["type"] == "object" arms = [ref["$ref"].removeprefix("#/$defs/") for ref in schema["anyOf"]] @@ -51,7 +55,7 @@ def test_output_schema_is_object_rooted_union(): def test_single_arm_schema_is_inlined(): - schema = to_fastmcp(message_only).output_schema + schema = to_mcp_tool(message_only).output_schema assert schema is not None assert "$ref" not in schema assert schema["type"] == "object" @@ -59,23 +63,23 @@ def test_single_arm_schema_is_inlined(): async def test_structured_content_is_the_returned_dict(): - converted = to_fastmcp(probe) + converted = to_mcp_tool(probe) structured = await run_structured(converted, {"query": "era5"}) assert structured == {"message": "Found 1 for 'era5'.", "items": [{"id": "era5"}]} async def test_absent_optional_keys_are_not_null_padded(): - structured = await run_structured(to_fastmcp(probe), {"query": "empty"}) + structured = await run_structured(to_mcp_tool(probe), {"query": "empty"}) assert structured == {"message": "Nothing found."} async def test_error_returns_conform_to_the_schema(): - structured = await run_structured(to_fastmcp(probe), {"query": "boom"}) + structured = await run_structured(to_mcp_tool(probe), {"query": "boom"}) assert structured == {"error": "bad_query", "detail": "boom"} async def test_sync_tool_supported(): - structured = await run_structured(to_fastmcp(message_only), {"text": "hi"}) + structured = await run_structured(to_mcp_tool(message_only), {"text": "hi"}) assert structured == {"message": "hi"} @@ -87,7 +91,7 @@ async def sneaky(text: str) -> ToolResult: result["extra"] = "undeclared" # type: ignore[typeddict-unknown-key] return result - structured = await run_structured(to_fastmcp(sneaky), {"text": "hi"}) + structured = await run_structured(to_mcp_tool(sneaky), {"text": "hi"}) assert structured == {"message": "hi"} @@ -101,7 +105,7 @@ async def modeled(text: str) -> ModelResult: """Echo the text.""" return ModelResult(message=text) - schema = to_fastmcp(modeled).output_schema + schema = to_mcp_tool(modeled).output_schema assert schema is not None assert "message" in schema["required"] @@ -125,7 +129,7 @@ async def loose(text: str): # noqa: ANN202 - annotation applied below if annotation is None: del loose.__annotations__["return"] with pytest.raises(RuntimeError, match="loose"): - to_fastmcp(tool(loose)) + to_mcp_tool(tool(loose)) def test_typeddict_without_message_rejected(): @@ -138,7 +142,7 @@ async def messageless(text: str) -> NoMessage: return NoMessage(data=text) with pytest.raises(RuntimeError, match="required str 'message'"): - to_fastmcp(messageless) + to_mcp_tool(messageless) def test_non_string_message_rejected(): @@ -151,7 +155,7 @@ async def numeric(text: str) -> NumericMessage: return NumericMessage(message=len(text)) with pytest.raises(RuntimeError, match="required str 'message'"): - to_fastmcp(numeric) + to_mcp_tool(numeric) def test_union_with_non_dict_arm_rejected(): @@ -161,25 +165,24 @@ async def mixed(text: str) -> ToolResult | str: return text with pytest.raises(RuntimeError, match="mixed"): - to_fastmcp(mixed) + to_mcp_tool(mixed) async def test_an_undeclared_argument_is_refused_not_dropped(): - """Upstream builds the validation model from the schema's fields alone, so - ``model_config`` is lost and pydantic's default drops unknown arguments. + """Pydantic's default is to drop an argument the model did not declare. The caller is never told, which is the dangerous part: a parameter the model meant to send simply is not there, and the tool runs as though it had never been asked for. """ - converted = to_fastmcp(probe) + converted = to_mcp_tool(probe) with pytest.raises(Exception, match="query_typo"): - await converted.run({"query": "hello", "query_typo": "hello"}) + await converted.run({"query": "hello", "query_typo": "hello"}, None) async def test_a_declared_argument_still_gets_through(): - converted = to_fastmcp(probe) + converted = to_mcp_tool(probe) assert await run_structured(converted, {"query": "hello"}) == { "message": "Found 1 for 'hello'.", @@ -191,4 +194,28 @@ def test_the_input_schema_says_so_too(): """Upstream publishes no extra policy at all, so enforcing one on its own would refuse calls the advertised schema allowed. The rule has to be in the schema for a client to apply it, or to know why it was refused.""" - assert to_fastmcp(probe).parameters["additionalProperties"] is False + assert to_mcp_tool(probe).parameters["additionalProperties"] is False + + +def test_the_sdk_internals_we_build_on_keep_their_shape(): + """`to_mcp_tool` builds on `mcp.server.mcpserver.utilities.func_metadata`, + which is internal to the SDK, so a minor release can change it. This names + exactly what we rely on, so such a release fails here first and clearly.""" + from mcp.server.mcpserver.tools import Tool as MCPTool + from mcp.server.mcpserver.utilities.func_metadata import ( + ArgModelBase, + FuncMetadata, + ) + + assert issubclass(ArgModelBase, BaseModel) + assert {"arg_model", "output_model", "output_schema", "wrap_output"} <= set( + FuncMetadata.model_fields + ) + assert { + "fn", + "name", + "description", + "parameters", + "fn_metadata", + "is_async", + } <= set(MCPTool.model_fields) diff --git a/tests/mcp_runtime/test_credentials.py b/tests/mcp_runtime/test_credentials.py index 1522da6..050d445 100644 --- a/tests/mcp_runtime/test_credentials.py +++ b/tests/mcp_runtime/test_credentials.py @@ -1,5 +1,12 @@ +import sys +import types + import pytest +from langchain_core.tools import tool +from starlette.testclient import TestClient +from mcp_runtime.server import build_server +from mcp_runtime.tool_result import ToolResult from mcp_runtime.credentials import ( MissingCredentialError, credential_from_header, @@ -29,3 +36,61 @@ def test_header_context_resets(): credential_from_header("x-demo-token") with pytest.raises(MissingCredentialError): credential_from_header("x-demo-token") + + +def call_whoami(monkeypatch, headers: dict[str, str]) -> str: + """The server's reply to a `whoami` call made with ``headers``.""" + + @tool + def whoami() -> ToolResult: + """Report the caller's credential.""" + return {"message": credential_from_header("x-demo-token")} + + module = types.ModuleType("credential_toolset.tools") + module.TOOLS = [whoami] + module.CREDENTIAL_HEADERS = ["x-demo-token"] + monkeypatch.setitem(sys.modules, "credential_toolset.tools", module) + + server = build_server("credential-toolset") + # Bound to 127.0.0.1, where the SDK turns on DNS-rebinding protection and + # checks the Host header — so the request has to come from there. + with TestClient( + server.streamable_http_app(), base_url="http://127.0.0.1:8000" + ) as client: + response = client.post( + "/mcp", + headers={ + "accept": "application/json, text/event-stream", + "content-type": "application/json", + **headers, + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "whoami", "arguments": {}}, + }, + ) + assert response.status_code == 200 + return response.text + + +def test_the_calling_requests_headers_reach_the_tool(monkeypatch): + """End to end, because this is the part with no SDK equivalent. + + A tool body reads its credential from a context variable several frames + below the handler, and what fills that variable is the middleware every + built server carries. Reading it through a real request is the only check + that the two are still connected. + """ + assert "secret" in call_whoami(monkeypatch, {"x-demo-token": "secret"}) + + +def test_a_missing_credential_tells_the_client_which_header(monkeypatch): + """The v2 SDK sends a tool's unexpected exception as `Error executing tool + ` and withholds the text. This error's text is the whole point of it, + so it has to be the kind the SDK passes on.""" + reply = call_whoami(monkeypatch, {}) + + assert "x-demo-token" in reply + assert "isError" in reply diff --git a/tests/mcp_runtime/test_local.py b/tests/mcp_runtime/test_local.py index d9f1b34..7624a24 100644 --- a/tests/mcp_runtime/test_local.py +++ b/tests/mcp_runtime/test_local.py @@ -226,3 +226,20 @@ def test_mounted_toolsets_serve_mcp_traffic(monkeypatch): }, ) assert response.status_code == 200 + + +def test_a_host_built_for_every_address_answers_by_any_name(monkeypatch): + """The SDK checks the `Host` header against loopback for a server built + for 127.0.0.1, and not otherwise. Built for `0.0.0.0` — what HOST is set + to in a container — a toolset has to answer a request that arrives by the + machine's name, or the index at `/` hands out URLs nothing can use.""" + register_toolsets(monkeypatch) + app = build_local_app(["alpha"], base_url="http://10.0.0.5:8000", host="0.0.0.0") + + with TestClient(app, base_url="http://10.0.0.5:8000") as client: + response = client.post( + "/alpha/mcp", + headers={"accept": "application/json, text/event-stream"}, + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ) + assert response.status_code == 200, response.text diff --git a/tests/mcp_runtime/test_server.py b/tests/mcp_runtime/test_server.py index 75cc2bc..e9a66e5 100644 --- a/tests/mcp_runtime/test_server.py +++ b/tests/mcp_runtime/test_server.py @@ -6,6 +6,8 @@ from pydantic import ValidationError from starlette.testclient import TestClient +from mcp.server.mcpserver import MCPServer + from mcp_runtime.server import ( RuntimeSettings, build_server, @@ -219,11 +221,33 @@ async def test_build_server_advertises_output_schema(monkeypatch): tools_module(monkeypatch, "schema_toolset.tools", TOOLS=[echo]) server = build_server("schema-toolset") (listed,) = await server.list_tools() - assert listed.outputSchema is not None - assert "message" in listed.outputSchema["required"] + assert listed.output_schema is not None + assert "message" in listed.output_schema["required"] def test_build_server_rejects_non_contract_tool(monkeypatch): tools_module(monkeypatch, "loose_toolset.tools", TOOLS=[bare_echo]) with pytest.raises(RuntimeError, match="bare_echo"): build_server("loose-toolset") + + +def test_run_passes_another_transport_through_untouched(monkeypatch): + """The settings a toolset server remembers are streamable HTTP's. Asked to + run over stdio it must say stdio, and not carry a port along with it.""" + tools_module(monkeypatch, "stdio_toolset.tools", TOOLS=[echo]) + server = build_server("stdio-toolset", host="0.0.0.0", port=9000, path_prefix="x") + calls = [] + monkeypatch.setattr( + MCPServer, + "run", + lambda self, transport="stdio", **kw: calls.append((transport, kw)), + ) + + server.run("stdio") + server.run() + + assert calls[0] == ("stdio", {}) + assert calls[1][0] == "streamable-http" + assert calls[1][1]["port"] == 9000 + assert calls[1][1]["streamable_http_path"] == "/x/mcp" + assert calls[1][1]["stateless_http"] is True diff --git a/tests/mcp_runtime/test_views.py b/tests/mcp_runtime/test_views.py index 183a70c..2feec1c 100644 --- a/tests/mcp_runtime/test_views.py +++ b/tests/mcp_runtime/test_views.py @@ -11,7 +11,7 @@ import pytest from langchain_core.tools import tool -from mcp_runtime.fastmcp_output import to_fastmcp +from mcp_runtime.bridge import to_mcp_tool from mcp_runtime.server import build_server from mcp_runtime.tool_result import ToolResult from mcp_runtime.views import load_views, view_html, with_view_meta @@ -73,7 +73,7 @@ def test_view_html_missing_bundle(monkeypatch, tmp_path): def test_with_view_meta_stamps_and_is_pure(monkeypatch, tmp_path): module = toolset_with_views(monkeypatch, tmp_path, "stamp") - original = to_fastmcp(show) + original = to_mcp_tool(show) (stamped,) = with_view_meta("stamp", module, [original], {"show": "panel"}) assert stamped.meta == {"ui": {"resourceUri": "ui://stamp/panel"}} assert original.meta is None # input untouched @@ -84,7 +84,7 @@ def test_with_view_meta_unknown_tool(monkeypatch, tmp_path): monkeypatch, tmp_path, "unknown", views={"ghost": "panel"} ) with pytest.raises(RuntimeError, match="ghost"): - with_view_meta("unknown", module, [to_fastmcp(show)], {"ghost": "panel"}) + with_view_meta("unknown", module, [to_mcp_tool(show)], {"ghost": "panel"}) async def test_build_server_registers_view_resource(monkeypatch, tmp_path): @@ -97,6 +97,6 @@ async def test_build_server_registers_view_resource(monkeypatch, tmp_path): resources = await server.list_resources() assert str(resources[0].uri) == "ui://srv/panel" # The MCP Apps profile MIME — a plain text/html is rejected by spec hosts. - assert resources[0].mimeType == "text/html;profile=mcp-app" + assert resources[0].mime_type == "text/html;profile=mcp-app" contents = list(await server.read_resource("ui://srv/panel")) assert contents[0].content == "

panel

" diff --git a/tests/mcp_state/test_injection.py b/tests/mcp_state/test_injection.py index 6412d00..23f0430 100644 --- a/tests/mcp_state/test_injection.py +++ b/tests/mcp_state/test_injection.py @@ -34,12 +34,14 @@ def remote_tool( ) -> StructuredTool: """A stand-in for a tool loaded from an MCP server by the adapter. - Mirrors what ``langchain_mcp_adapters`` builds: a dict ``args_schema`` - taken verbatim from the server's ``inputSchema``, a ``**arguments`` - coroutine, and the server's ``_meta`` preserved under ``metadata``. + Mirrors what an MCP client builds: a dict ``args_schema`` taken verbatim + from the server's ``inputSchema``, the server's ``_meta`` under + ``metadata``, and a coroutine taking nothing but ``**arguments`` — which + it would send to the server as the call's arguments, so anything the + wrapper adds to them travels. """ - async def call(runtime: Any = None, **arguments: Any) -> Any: + async def call(**arguments: Any) -> Any: if seen is not None: seen.update(arguments) return "called", None @@ -283,3 +285,40 @@ def test_merge_stamps_write_order_so_recency_is_knowable() -> None: first = merge_tool_state({}, {"t/a/x": StateEntry(value=1)}) second = merge_tool_state(first, {"t/b/y": StateEntry(value=2)}) assert second["t/b/y"]["seq"] > second["t/a/x"]["seq"] + + +async def test_the_runtime_is_not_forwarded_to_a_tool_that_never_asked(): + """An MCP tool sends every argument it is handed to the server. + + Its coroutine is `**arguments` and nothing else, so a runtime object added + by the wrapper does not stay local: it goes on the wire, where it fails to + serialise and takes the call with it. Only a tool that names ``runtime`` + is given one. + """ + seen: dict[str, Any] = {} + tool = remote_tool( + "clip", + properties={"aoi": GEOJSON_SCHEMA, "dataset_id": {"type": "string"}}, + required=["dataset_id"], + seen=seen, + ) + bound = bind_injected(tool) + assert bound is not tool, "nothing was bound, so nothing is under test" + + await bound.coroutine(injected_state={}, dataset_id="chirps") + + assert seen == {"dataset_id": "chirps"} + + +async def test_a_declaration_is_read_from_the_clients_nested_metadata(): + """`langchain.mcp` keeps an MCP tool's `_meta` under its own `mcp` + namespace. Reading only the flat position would find nothing, and the + parameter would silently go back to accepting a value the model wrote.""" + tool = remote_tool("clip", properties={"aoi": GEOJSON_SCHEMA}, required=["aoi"]) + tool.metadata = {"mcp": {"tool": {"_meta": {NOT_AUTHORED_META_KEY: ["aoi"]}}}} + + bound = bind_injected(tool) + + assert bound is not tool, "the declaration was not seen" + with pytest.raises(StateRefusal, match="aoi"): + await bound.coroutine(injected_state={}, aoi={"type": "Polygon"}) diff --git a/tests/mcp_state/test_not_authored.py b/tests/mcp_state/test_not_authored.py index a513bf0..2cc7b21 100644 --- a/tests/mcp_state/test_not_authored.py +++ b/tests/mcp_state/test_not_authored.py @@ -21,7 +21,7 @@ not_authored, with_state_meta, ) -from mcp_runtime.fastmcp_output import to_fastmcp +from mcp_runtime.bridge import to_mcp_tool from mcp_runtime.tool_result import ToolResult from mcp_state.handles import HANDLE_PREFIX, handle_for from mcp_state.injection import StateRefusal, bind_injected, not_authored_for @@ -116,7 +116,7 @@ async def clip_raster( """Clip a dataset.""" return ToolResult(message="clipped") - served = with_state_meta("raster-ops", [clip_raster], [to_fastmcp(clip_raster)])[0] + served = with_state_meta("raster-ops", [clip_raster], [to_mcp_tool(clip_raster)])[0] assert served.meta[NOT_AUTHORED_META_KEY] == ["aoi"] assert NOT_AUTHORED_NOTE in served.parameters["properties"]["aoi"]["description"] diff --git a/tests/mcp_state/test_tool_meta.py b/tests/mcp_state/test_tool_meta.py new file mode 100644 index 0000000..97b99c4 --- /dev/null +++ b/tests/mcp_state/test_tool_meta.py @@ -0,0 +1,20 @@ +from mcp_state import tool_meta + + +def test_tool_meta_is_read_wherever_the_client_put_it(): + """`langchain.mcp` nests an MCP tool's `_meta` under its own `mcp` + namespace; a tool built here carries it flat. Every reader of a server-side + declaration goes through `tool_meta`, and a declaration that is not found + does not fail — it just stops applying, which is why this is pinned.""" + + class Stub: + def __init__(self, metadata): + self.metadata = metadata + + nested = Stub({"mcp": {"tool": {"_meta": {"k": 1}}, "server": {"name": "x"}}}) + flat = Stub({"_meta": {"k": 2}}) + + assert tool_meta(nested) == {"k": 1} + assert tool_meta(flat) == {"k": 2} + assert tool_meta(Stub({"mcp": {"server": {"name": "x"}}})) == {} + assert tool_meta(Stub(None)) == {} diff --git a/uv.lock b/uv.lock index 37d4923..332b705 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/b0/850be4576e6443dc575daa54188d8136ae2b2eca3f19e098e7a088fc9ebc/ag_ui_protocol-1.0.0-py3-none-any.whl", hash = "sha256:0058eaf0522201089d444e430fc35180bc8ad0222cae39e8568b313e8105d9e6", size = 36842, upload-time = "2026-09-17T18:31:18.471Z" }, ] +[[package]] +name = "aiofile" +version = "3.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/31/edb06aabd8f8f0b56d659f30800795f40b93cba96be946ce179f6931e3a5/aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41", size = 21600, upload-time = "2026-08-04T22:59:27.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -83,6 +95,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "authlib" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "boto3" version = "1.43.108" @@ -111,6 +145,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/0b/4670b7e23914b5cc357be5ca45fae503b57d98ecff4b3eabea70412809fd/botocore-1.43.108-py3-none-any.whl", hash = "sha256:ab9d16c6b4350aaa54ed28202dfa2998b2d735fdbf3247eb60af469d8d48a5b8", size = 16008194, upload-time = "2026-10-02T19:32:15.086Z" }, ] +[[package]] +name = "cachetools" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/44/71476a5812da1ddf2c9a3efd31ae76d01480a1cf03ed13ac28aa8f2402e4/cachetools-7.2.1.tar.gz", hash = "sha256:b1a7537025c06abf96fcc1443e496af9a3fb95e774e70e1f0af226f73f7f2dcc", size = 41357, upload-time = "2026-10-05T18:40:06.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/c9/2a61d784caf0d869a3326728c57c7203f50cc53f3cca2ee76bf924769eb4/cachetools-7.2.1-py3-none-any.whl", hash = "sha256:63aa53dfe7473c10cccdd5a01dedf76ef2c4b73a58840d9396e7d0752cbdac3b", size = 17006, upload-time = "2026-10-05T18:40:04.827Z" }, +] + +[[package]] +name = "caio" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/51/bd8b64bf700f5b1a956a60bb62276b79a094e8cd0ddc60b1b61c3edd496f/caio-0.12.9.tar.gz", hash = "sha256:99e99419b44ab5511f7468c6a452887dd125b8e4042672a7589f0cf01d254ea8", size = 84084, upload-time = "2026-09-26T09:51:49.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/36/4cf8f5a6fd0ea7234107e5ab38ca5e0058beae7477c24768a6ab51b79e0a/caio-0.12.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:554c6ee883a0f1e95236d85b8e7d8187b212668855ac3a858acb80f5e2e82e82", size = 49041, upload-time = "2026-09-26T09:51:03.37Z" }, + { url = "https://files.pythonhosted.org/packages/94/31/3b6e9d644f9337ac6823f25b3cce838569d4b27ac9a3f9a371369f3d15d5/caio-0.12.9-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:6e72fb0ddd369f712a4ad229ee0f1d7df6852e9b59b36fe8a46210b4c9ea8e82", size = 164686, upload-time = "2026-09-26T09:51:04.88Z" }, + { url = "https://files.pythonhosted.org/packages/71/f7/1894b1ca1fae9317b8b8203f999107e927ca9badafbe0f7a294952fe00cb/caio-0.12.9-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:f0698976f84dd40024204f0f77cf59ed7446e989575c8ad8cb64cd4b3e2871f3", size = 162375, upload-time = "2026-09-26T09:51:06.448Z" }, + { url = "https://files.pythonhosted.org/packages/21/ce/040af704ea23a6f4383685d05068ec59301e3bb12cc2ca7c664d16a82691/caio-0.12.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a06afa038f76324f439595b7a3242abf090a80b03006ed3d7dbbb9c031baf8a3", size = 162189, upload-time = "2026-09-26T09:51:07.862Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/d94003593fee5b76d725e5cc51e902439ee3491c6bd4331b232b2ebf12aa/caio-0.12.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed343077d8178c003b1596f40b1f46955be963a2871a1f85519be823113b24b0", size = 161976, upload-time = "2026-09-26T09:51:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bf/ed21d62204e4feece79985f45d2ebce6949c9e3d320e9f9e9638e9dc980a/caio-0.12.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ed23f6ad6897fae4c02e3e8d236aa3cc18e766a7c5bb0352ea5e9fb26c5d3335", size = 49024, upload-time = "2026-09-26T09:51:10.472Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c6/fea92dd83cdf28ee20d403a910e194fa5e1a97215a00e5bbbeb699ac582a/caio-0.12.9-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:c327977b8174337c1aaff27da17ac249d176ef8ea6e2dbf70b49cdd8038d57d3", size = 164835, upload-time = "2026-09-26T09:51:12.213Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0f/8ec37d3d6b47b8c6bd6c68a1d2d9b5de1ea05df59b601575b7003c9439e2/caio-0.12.9-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:f6ffb3d448016d20d8c40c53864d81bdf7463157969de65841eeb370519dbf78", size = 162567, upload-time = "2026-09-26T09:51:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5f/b3258150ea0e87f032859df825dd0ec8e9f3a6c798addc6463cf1e4a2298/caio-0.12.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a11421fb4ac591e6fea5512d9a8ad1b488e7b28cf610ede973bbfb4be5177454", size = 162299, upload-time = "2026-09-26T09:51:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6e/5712cbf5168fdb4c65c44b0435daeeac7c00609cdc1fd3414812691fbb68/caio-0.12.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c3f6dc05486ce4e1027f1d2da4d84c1d6bb815886e76f34a9228d370d86a5537", size = 162155, upload-time = "2026-09-26T09:51:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/5a08b26fae45320c8d6f3b42e6225c5316b39f6cea811ba3f890315b4641/caio-0.12.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5c43eaebf220aeb9a388d309a5352e82562a7b9b2ea0102502204413641cfed7", size = 42777, upload-time = "2026-09-26T09:51:17.929Z" }, + { url = "https://files.pythonhosted.org/packages/c0/99/96888ad510c9adf42bfbad9ba139831b58055fd61e1301f6268e9bb490b8/caio-0.12.9-py3-none-any.whl", hash = "sha256:bf12d4f014b2a33e642ed7905b5787656ede2fc24be21f7c086826ff0d32cec3", size = 26154, upload-time = "2026-09-26T09:51:48.071Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -289,6 +352,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] +[[package]] +name = "cyclopts" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/c1/3debeeb6e0eb74a51d6f8cf1f273ed7c479e3321631cbf89fb9700e65e28/cyclopts-5.2.0.tar.gz", hash = "sha256:b63c1b1beaadf3ead19214385a0f90b990f152c4107c174e1724c45dc71e9541", size = 246542, upload-time = "2026-10-06T15:02:39.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/dd/3f73d04c95f6acc6a54700011637e49362e337139b518be895edf4786f87/cyclopts-5.2.0-py3-none-any.whl", hash = "sha256:5da2a5d65164e03008621fc4795946b88a722b881398b5dfa58afa6218342cff", size = 289196, upload-time = "2026-10-06T15:02:37.57Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -298,6 +376,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "fastapi" version = "0.142.2" @@ -315,6 +436,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" }, ] +[[package]] +name = "fastmcp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/c4/fe4e24af2cb03f4701f45634873d87557c2b5d283d287412345345ad0ede/fastmcp-4.1.0.tar.gz", hash = "sha256:7a8bf4e58cc6c2f3a8552b5a7a17ba1e4682b6fd4cafb7f461920a66dbd2499f", size = 42686511, upload-time = "2026-10-08T22:57:05.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/81/77610de5d79187455ec4a4eafdc17942365f1bc4e5850633e50159e9afdc/fastmcp-4.1.0-py3-none-any.whl", hash = "sha256:cf19fd603315b48ef1829c8579431e77793feafdcbed51f0dc5607af10a24e9b", size = 8154, upload-time = "2026-10-08T22:57:02.531Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mcp-types" }, + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/88/85aafec43ec9940a360d75f2d8e9517eda01ac38b8b1610b0e9e125d61d3/fastmcp_slim-4.1.0.tar.gz", hash = "sha256:390b2b430195e0cdd0327aff80fe5dceb862b2421baf71de6d4eec92f80f99f8", size = 734647, upload-time = "2026-10-08T22:56:40.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6a/d61e1541f4c65872146a576c1379f6f32013fd8c636bcaf0f8e70a04e533/fastmcp_slim-4.1.0-py3-none-any.whl", hash = "sha256:6edb48a78e508ef5ac691bf2a68f3e5000403773655d859eceea1068b894df4a", size = 911585, upload-time = "2026-10-08T22:56:39.136Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx2" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx2" }, + { name = "joserfc" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pyperclip" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "starlette" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "griffelib" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/27/b55f1a5278918be765fb2fd8b20966bc72bbdd3f789f031937cceea7834a/griffelib-2.3.2.tar.gz", hash = "sha256:df00c7a0dee3d86268d76788997a1859272cb1fb7b865658e043d2c0c3d52e60", size = 234729, upload-time = "2026-10-06T09:54:37.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/e5/0ae74c83c1cab2c14daadf28bd0143eb14bb81503834af987b66badc7b61/griffelib-2.3.2-py3-none-any.whl", hash = "sha256:8e710afededd5607f95bf3c8ccc175ee306e7084459faf27b9f9f44ef2a7a274", size = 169420, upload-time = "2026-10-06T09:54:32.038Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -337,6 +531,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -353,12 +560,29 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -379,6 +603,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -388,6 +654,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -409,6 +687,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -424,6 +711,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -436,23 +738,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "langchain" -version = "1.4.3" +version = "1.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/aa/0fcaf5d7dbebcf7d0eb74293dca5aee6f0558242731f4dee4a800cc2431d/langchain-1.4.3.tar.gz", hash = "sha256:aed5c1a7cd581f70ddc076b75fdbcbae8031b8373983f86efd287aa70798abc2", size = 738607, upload-time = "2026-09-28T20:17:24.38Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/61/f52073a1b7b401b7ad82db3674e94dbe2d0fee28e794a8c4440223c0bc91/langchain-1.4.5.tar.gz", hash = "sha256:e99b9bd3b1203a475f627ba970ede2b1263fa4e52154b6c3660b4c3ee1bf73c8", size = 745545, upload-time = "2026-10-12T19:28:24.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/03/8cc027f9d297473de94f134546dae2bfca255161ebbda988522fc509e1d5/langchain-1.4.3-py3-none-any.whl", hash = "sha256:0334066939dd1c7551889727ed2d09a72dea7a6aed2193ffb61817fb658d8921", size = 164708, upload-time = "2026-09-28T20:17:22.978Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e0/c8ec47569a73c0c6205eb545f229ffb9fae66e85b12e9032765442317a09/langchain-1.4.5-py3-none-any.whl", hash = "sha256:0879b85a79d7fbc89fc91f6c65c9fa573f03f4d9458e6b5b3c50c717868bd79f", size = 166359, upload-time = "2026-10-12T19:28:22.883Z" }, +] + +[package.optional-dependencies] +mcp = [ + { name = "fastmcp" }, ] [[package]] name = "langchain-core" -version = "1.6.6" +version = "1.6.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -466,23 +790,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/87/9e5d393bbcec068c51becc255a15aacfd8a14ac5826179751c1c320e5258/langchain_core-1.6.6.tar.gz", hash = "sha256:67616d984c085009be2c381c2efd330d32429a58aebab295b4f51b6573964511", size = 1007227, upload-time = "2026-09-29T14:52:54.598Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/00/0a95f74a79908e7bc844a82fca35c1afc55689f55aaed086e95745946db8/langchain_core-1.6.10.tar.gz", hash = "sha256:3ad7a64eab150c1fea9f8a748b1c076aa1a960c5cf7c28d81a841a2f2dbffad1", size = 1010280, upload-time = "2026-10-12T14:13:51.184Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/10/8c00b4dc094886314c907ec7ead258dfee28948cf44175976cc4fba9aba8/langchain_core-1.6.6-py3-none-any.whl", hash = "sha256:d9a71e312ab7443701238750e04a57aefc037c4f1ec82837c8a1609d9a14a9b1", size = 572080, upload-time = "2026-09-29T14:52:53.213Z" }, -] - -[[package]] -name = "langchain-mcp-adapters" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "mcp" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/49/f3b8497b64024ab50d10011f27e94d149668fef754da74c1c2ce6ebe4a30/langchain_mcp_adapters-0.3.2.tar.gz", hash = "sha256:61cd1a09597adb619a9bafb0642938ffc2a9463d699a753f7af0420ea46c381a", size = 47129, upload-time = "2026-08-06T06:15:04.094Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/4f117d2500a661079a1895a6eb18954a906e458b1e45fa04a301fcdabd61/langchain_mcp_adapters-0.3.2-py3-none-any.whl", hash = "sha256:094e6b3096dbcc408417d5722f6915f164772e50c502ae3d8989405bf12c3c84", size = 28879, upload-time = "2026-08-06T06:15:02.832Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/6ed698c6b451af0ed0efdbe94a703c18aea768d925347d8d1efd5645ae8c/langchain_core-1.6.10-py3-none-any.whl", hash = "sha256:14341bdd8b42d0dd9a53dbbcd8b0599ab47b0c718c7caa12e3eb5c50b32cffcb", size = 573253, upload-time = "2026-10-12T14:13:49.616Z" }, ] [[package]] @@ -499,7 +809,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.2.11" +version = "1.2.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -509,22 +819,22 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/69/d43defeb393d5e222574b80411ee3c214dc4de4012b4ce363f6faf115aff/langgraph-1.2.15.tar.gz", hash = "sha256:bebcfe5369b7307de1369ac00775f6e7b5a64ec94c050896b67de69d98aac612", size = 767102, upload-time = "2026-10-12T22:38:13.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/d79317d651dc575aa471cd777d781d8fdd28d974b1de90e8718d473f6863/langgraph-1.2.15-py3-none-any.whl", hash = "sha256:6e1611c4dad33d933b8cf21a91db73285221e67508feb2db5a0397af55fb838f", size = 259575, upload-time = "2026-10-12T22:38:11.806Z" }, ] [[package]] name = "langgraph-checkpoint" -version = "4.1.1" +version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "ormsgpack" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/69/31fdbdc65a85bbd6178afa193c772bb926620f47b4869638bc2bc80afaaa/langgraph_checkpoint-4.3.0.tar.gz", hash = "sha256:c75965d84cc2c1d549163e910a15bcb577758001b141619d05297c463280b018", size = 182652, upload-time = "2026-10-12T22:26:31.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0c/84747e340bf4f29291c84cdd5733fc8d0a822f3d33bb24e664a18afa4a7c/langgraph_checkpoint-4.3.0-py3-none-any.whl", hash = "sha256:bedfafe2f997ded60e4fa593e79f56f436a6e45586392dc382aa810d0c751c64", size = 58063, upload-time = "2026-10-12T22:26:30.429Z" }, ] [[package]] @@ -557,7 +867,7 @@ wheels = [ [[package]] name = "langgraph-sdk" -version = "0.4.2" +version = "0.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -566,9 +876,9 @@ dependencies = [ { name = "orjson" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/5d/cbeacb114f4a6269fc476f7d2feba088f6ada0c00f81bf5decc587f81511/langgraph_sdk-0.4.7.tar.gz", hash = "sha256:6827560be31e38daae1514234e9aa12c345dd40d4d4b94aa1b443729bfccda69", size = 350917, upload-time = "2026-10-12T22:54:05.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2b/996e641d2020f30b25a523e8cc43f068406eb2b5b677190b3ca917cbbd05/langgraph_sdk-0.4.7-py3-none-any.whl", hash = "sha256:a005c7ac662c318a3405e436e9effaa90c05343f9f4ae9e11dca19c9369727dd", size = 162399, upload-time = "2026-10-12T22:54:04.224Z" }, ] [[package]] @@ -645,15 +955,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -663,9 +973,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [[package]] @@ -676,7 +986,6 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "langchain-core" }, - { name = "langchain-mcp-adapters" }, { name = "mcp" }, { name = "pydantic-settings" }, { name = "rich" }, @@ -686,13 +995,17 @@ dependencies = [ [package.optional-dependencies] agent = [ + { name = "fastmcp" }, + { name = "httpx2" }, { name = "jsonschema" }, - { name = "langchain" }, + { name = "langchain", extra = ["mcp"] }, ] api = [ { name = "ag-ui-protocol" }, + { name = "fastmcp" }, + { name = "httpx2" }, { name = "jsonschema" }, - { name = "langchain" }, + { name = "langchain", extra = ["mcp"] }, ] aws = [ { name = "boto3" }, @@ -720,13 +1033,15 @@ requires-dist = [ { name = "ag-ui-protocol", marker = "extra == 'api'", specifier = ">=1.0.0,<1.1.0" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.43.87,<2.0.0" }, { name = "fastapi", specifier = ">=0.140.11,<1.0.0" }, + { name = "fastmcp", marker = "extra == 'agent'", specifier = ">=4.0.11,<5.0.0" }, { name = "httpx", specifier = ">=0.28.1,<1.0.0" }, + { name = "httpx2", marker = "extra == 'agent'", specifier = ">=2.13.0,<3.0.0" }, { name = "jsonschema", marker = "extra == 'state'", specifier = ">=4.26.0,<5.0.0" }, { name = "langchain", marker = "extra == 'state'", specifier = ">=1.3.7,<2.0.0" }, + { name = "langchain", extras = ["mcp"], marker = "extra == 'agent'", specifier = ">=1.4.3,<2.0.0" }, { name = "langchain-core", specifier = ">=1.4.6,<2.0.0" }, - { name = "langchain-mcp-adapters", specifier = ">=0.3.0,<0.4.0" }, { name = "langgraph-checkpoint-postgres", marker = "extra == 'checkpointing-postgres'", specifier = ">=3.1.1,<4.0.0" }, - { name = "mcp", specifier = ">=1.27.2,<2.0.0" }, + { name = "mcp", specifier = ">=2.3.0,<3.0.0" }, { name = "mcp-toolsets-runtime", extras = ["agent"], marker = "extra == 'api'" }, { name = "mcp-toolsets-runtime", extras = ["state"], marker = "extra == 'agent'" }, { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, @@ -746,6 +1061,19 @@ dev = [ { name = "types-jsonschema", specifier = ">=4.26.0" }, ] +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -755,6 +1083,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -792,6 +1129,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/32/0c9bd3e4e847cd6117b64dbef4cf810faa9cdbd6689323b811569cc8a1b8/openapi_pydantic-0.6.0.tar.gz", hash = "sha256:11f3ac6ad41521fc156381ec587246b0c0ecea523bd9565ed74d3e7fac9d91cd", size = 1667049, upload-time = "2026-10-02T12:20:00.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/57/9d3eb823a03c33e9d4c810e6a3276e3a6dc820a7fdc0961db2c04e613817/openapi_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:8bd6d548a250da37db96f461bcd52b3468e8834ad7d6db14b00a6b2ebbd163a3", size = 96931, upload-time = "2026-10-02T12:19:58.917Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.45.1" @@ -877,6 +1226,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -886,6 +1244,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "platformdirs" +version = "4.12.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -920,6 +1287,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/99/c346e3474853801ec5ecf4c3ee60cfc6060327ebc31424df4e346620dde4/py_key_value_aio-0.4.6.tar.gz", hash = "sha256:267c03c3e24cb99d3097612f8a5cfd8e11785c6a2975e272db0e303ea1850bfd", size = 112876, upload-time = "2026-09-18T15:29:52.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/46/12871550fb0613d149144bb9d41f22680d5f46717bc45a76eb5798c20c3f/py_key_value_aio-0.4.6-py3-none-any.whl", hash = "sha256:820b30c7959fc738ea3d00160cdb10cd258f51e38737b2add530defeea1ded77", size = 171289, upload-time = "2026-09-18T15:29:50.665Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -944,6 +1336,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -1026,6 +1423,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1112,6 +1518,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1194,6 +1609,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rich-rst" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/faf7c7e36630561e3e9611730c47510cd972dd5fde8f95941ff78f76accd/rich_rst-2.2.0.tar.gz", hash = "sha256:b1e6a67f8f694a6f36035624bf73e2b1a0a4be13edaf3ba5e654d9758b61073a", size = 326270, upload-time = "2026-09-27T19:12:42.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4e/716b42bf066e180c1235783b46f095a5d871d05dca7006d2295aac29c491/rich_rst-2.2.0-py3-none-any.whl", hash = "sha256:1ea1c43813dc4a8d86475fce27ffb74e0ada3d4656c9f8130ba2a59868055fe9", size = 279274, upload-time = "2026-09-27T19:12:40.548Z" }, +] + [[package]] name = "rpds-py" version = "2026.6.3" @@ -1269,6 +1697,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1331,6 +1772,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.27.2" @@ -1388,6 +1838,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1447,6 +1906,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, +] + [[package]] name = "websockets" version = "15.0.1"