From c79998a6651a42349b7a624463ed176a3c651f35 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Wed, 23 Sep 2026 11:30:24 +0100 Subject: [PATCH 01/11] feat!: mcp 2, and the tool bridge off the retired adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcp` 2 renamed or removed most of what the server side was built on, and `langchain-mcp-adapters` — both halves of the LangChain/MCP bridge — was retired on 11 September. They move together: the adapter's successor, `langchain[mcp]`, reaches servers through fastmcp 4, which requires mcp 2. Server side. `FastMCP` is now `MCPServer`, and the host, port, path and statelessness it used to hold are per-call arguments. `ToolsetServer` keeps them on the server object, so `streamable_http_app()` still yields the toolset's own URL shape and stateless transport for a consumer that does its own serving. The LangChain-tool to MCP-tool conversion is ours now — `langchain.mcp` converts in the client direction only. It is some thirty lines, and the half this package already owned (the output schema derived from the return annotation, the argument model that forbids extras) is unchanged. `mcp_runtime.fastmcp_output.to_fastmcp` is `mcp_runtime.mcp_tools.to_mcp_tool`, named for what it builds now that no FastMCP is involved. Credentials. v2 publishes no request contextvar, and hands its per-request context to handlers as an argument a tool body several frames down cannot reach. So the contextvar is ours, filled by a `ServerMiddleware` every built server carries; `credential_from_header` is unchanged for callers. A new test drives it through a real HTTP request, since that is the part with no SDK equivalent left to lean on. Client side. `MultiServerMCPClient` becomes one fastmcp `Client` per connection behind `MCPAdapter`, still adapted per server so each tool records where it came from. The per-user credential factory survives intact — fastmcp's transport takes an `httpx_client_factory` the same way — on httpx2, which is the HTTP stack mcp 2 moved to. `mcp-cli` uses the v2 `Client` and the renamed wire fields (`input_schema`, `is_error`, `structured_content`). Checked against a running server, not only in tests: `mcp-serve-local` boots, `mcp-cli list` and `call` work through the local host's mounted app, and an undeclared argument is still refused by name. One behaviour change worth knowing: a server bound to 127.0.0.1 now validates the Host header (the SDK turns on DNS-rebinding protection for loopback), so a local client must reach it by an address with a port. Deployments bind 0.0.0.0, where the SDK leaves the protection off, and are unaffected. Refs #163 Co-Authored-By: Claude Opus 5 --- docs/CONSUMING.md | 19 +- docs/SESSION-STATE.md | 8 +- examples/agui-events/README.md | 2 +- examples/agui-events/service/agent.py | 13 +- examples/agui-events/service/servers.py | 4 +- examples/session-state/README.md | 2 +- examples/session-state/demo.py | 14 +- examples/session-state/foreign_server.py | 6 +- pyproject.toml | 15 +- src/mcp_agent/host.py | 26 +- src/mcp_agent/main.py | 62 +- src/mcp_cli/main.py | 33 +- src/mcp_runtime/credentials.py | 74 ++- src/mcp_runtime/declarations.py | 32 +- src/mcp_runtime/fastmcp_output.py | 145 ----- src/mcp_runtime/index.py | 4 +- src/mcp_runtime/local.py | 8 +- src/mcp_runtime/mcp_tools.py | 196 +++++++ src/mcp_runtime/server.py | 58 +- src/mcp_runtime/tool_result.py | 2 +- src/mcp_runtime/views.py | 17 +- src/mcp_state/injection.py | 2 +- src/mcp_state/middleware.py | 8 +- tests/mcp_agent/test_agent.py | 10 +- tests/mcp_runtime/test_credentials.py | 50 ++ ...st_fastmcp_output.py => test_mcp_tools.py} | 53 +- tests/mcp_runtime/test_server.py | 4 +- tests/mcp_runtime/test_views.py | 8 +- tests/mcp_state/test_injection.py | 2 +- tests/mcp_state/test_not_authored.py | 4 +- uv.lock | 539 +++++++++++++++++- 31 files changed, 1042 insertions(+), 378 deletions(-) delete mode 100644 src/mcp_runtime/fastmcp_output.py create mode 100644 src/mcp_runtime/mcp_tools.py rename tests/mcp_runtime/{test_fastmcp_output.py => test_mcp_tools.py} (77%) diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index 0e6d5ba..abf8844 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -252,8 +252,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 +354,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` | @@ -601,7 +601,8 @@ Three pieces, all three required: ```python from langchain.agents import create_agent -from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain.mcp import MCPAdapter +from mcp_agent.main import with_credential_support from mcp_state import ( SESSION_STATE_PROMPT, StateCaptureMiddleware, @@ -614,13 +615,13 @@ 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. 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 MCPAdapter(client).list_tools() ] published = publications(tools) 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..88e7a2e 100644 --- a/examples/agui-events/service/agent.py +++ b/examples/agui-events/service/agent.py @@ -13,10 +13,10 @@ import logging -from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain.mcp import MCPAdapter from langgraph.checkpoint.memory import InMemorySaver -from mcp_agent.main import BuiltAgent, with_session_state +from mcp_agent.main import BuiltAgent, with_credential_support, with_session_state from mcp_state import with_server_name from service import model, servers @@ -33,13 +33,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 MCPAdapter(client).list_tools() ] chat, named = model.build() 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..a8797d3 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,7 +39,8 @@ 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 langchain.mcp import MCPAdapter +from mcp_agent.main import with_credential_support from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, PRODUCES_META_KEY from mcp_runtime.server import build_server @@ -223,14 +224,13 @@ 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 MCPAdapter(client).list_tools() ] published = publications(tools) 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..c22fdf8 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.2.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,15 @@ 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.2", + "fastmcp<5.0.0,>=4.0.1", +] # mcp_agent_api: the agent over HTTP, on top of [agent]. # @@ -132,7 +139,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..3ea4bbe 100644 --- a/src/mcp_agent/host.py +++ b/src/mcp_agent/host.py @@ -16,7 +16,6 @@ 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 ( @@ -58,16 +57,21 @@ 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: diff --git a/src/mcp_agent/main.py b/src/mcp_agent/main.py index 7b779a0..360a514 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,19 @@ 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 MCPAdapter +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 pydantic import Field, SecretStr, ValidationError, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from rich.console import Console @@ -415,7 +418,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: @@ -465,7 +468,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 +515,9 @@ 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, + ) -> httpx2.AsyncClient: provided = _credentials.get() or {} send = { header: value @@ -530,15 +533,24 @@ 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. + """ 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() } @@ -770,15 +782,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 MCPAdapter(client).list_tools() ] chat_model = init_chat_model(model, api_key=api_key.get_secret_value()) if not session_state: 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/credentials.py b/src/mcp_runtime/credentials.py index 00bbbec..210369c 100644 --- a/src/mcp_runtime/credentials.py +++ b/src/mcp_runtime/credentials.py @@ -2,24 +2,37 @@ 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 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(Exception): """A required credential header was absent from the calling request.""" @@ -27,23 +40,39 @@ class MissingCredentialError(Exception): 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 +87,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..86a9ed0 100644 --- a/src/mcp_runtime/declarations.py +++ b/src/mcp_runtime/declarations.py @@ -36,9 +36,9 @@ 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.mcp_tools import _arms, _return_annotation # The ``_meta`` keys a client reads declarations from. Reverse-DNS per the MCP # ``_meta`` convention, so they cannot collide with ``ui`` (MCP Apps) or @@ -126,7 +126,7 @@ 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.mcp_tools` 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. @@ -209,9 +209,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 +224,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.mcp_tools` applies to returns. """ publishes_by_tool: dict[str, list[str]] = {} not_authored_by_tool: dict[str, list[str]] = {} @@ -243,24 +243,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..e3b1153 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, @@ -109,7 +109,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] = [] @@ -137,7 +137,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. diff --git a/src/mcp_runtime/mcp_tools.py b/src/mcp_runtime/mcp_tools.py new file mode 100644 index 0000000..16dcc9c --- /dev/null +++ b/src/mcp_runtime/mcp_tools.py @@ -0,0 +1,196 @@ +"""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 +module 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 — some 30 lines, and the half we already owned is unchanged. +""" + +from types import UnionType +from typing import Any, Union, cast, get_args, get_origin, get_type_hints, is_typeddict + +from langchain_core.tools import BaseTool, InjectedToolArg +from mcp.server.mcpserver.tools import Tool as MCPTool +from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata +from pydantic import BaseModel, RootModel, create_model + + +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 _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:`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 + + +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 injected := _injected(tool): + raise RuntimeError( + f"tool {tool.name!r} takes injected arguments {injected}, which " + "nothing on the MCP side can fill" + ) + 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" + ) + 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/server.py b/src/mcp_runtime/server.py index 49a836a..3110e8c 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 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.mcp_tools import to_mcp_tool from mcp_runtime.views import load_views, register_views, with_view_meta @@ -139,14 +142,45 @@ 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, *args: Any, **kwargs: Any) -> None: + super().run( + "streamable-http", **{**self._transport, "port": self._port, **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 +195,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 +240,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..607c14d 100644 --- a/src/mcp_runtime/tool_result.py +++ b/src/mcp_runtime/tool_result.py @@ -17,7 +17,7 @@ class SearchDatasetsResult(ToolResult): async def search_datasets(query: str) -> SearchDatasetsResult | ToolError: ... -``mcp_runtime.fastmcp_output`` derives the tool's MCP ``outputSchema`` from +``mcp_runtime.mcp_tools`` 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. """ 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/injection.py b/src/mcp_state/injection.py index df9e9f0..a081e2b 100644 --- a/src/mcp_state/injection.py +++ b/src/mcp_state/injection.py @@ -105,7 +105,7 @@ 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. """ diff --git a/src/mcp_state/middleware.py b/src/mcp_state/middleware.py index 360c82d..7521747 100644 --- a/src/mcp_state/middleware.py +++ b/src/mcp_state/middleware.py @@ -85,9 +85,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,7 +137,7 @@ 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. """ diff --git a/tests/mcp_agent/test_agent.py b/tests/mcp_agent/test_agent.py index 83b5dcb..03d4f3d 100644 --- a/tests/mcp_agent/test_agent.py +++ b/tests/mcp_agent/test_agent.py @@ -122,8 +122,14 @@ 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_connect_error_hint_only_for_urls_missing_mcp_path(): diff --git a/tests/mcp_runtime/test_credentials.py b/tests/mcp_runtime/test_credentials.py index 1522da6..c5e1381 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,46 @@ def test_header_context_resets(): credential_from_header("x-demo-token") with pytest.raises(MissingCredentialError): credential_from_header("x-demo-token") + + +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. + """ + + @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", + "x-demo-token": "secret", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "whoami", "arguments": {}}, + }, + ) + assert response.status_code == 200 + assert "secret" in response.text diff --git a/tests/mcp_runtime/test_fastmcp_output.py b/tests/mcp_runtime/test_mcp_tools.py similarity index 77% rename from tests/mcp_runtime/test_fastmcp_output.py rename to tests/mcp_runtime/test_mcp_tools.py index faa15bc..ede0097 100644 --- a/tests/mcp_runtime/test_fastmcp_output.py +++ b/tests/mcp_runtime/test_mcp_tools.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.mcp_tools 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,4 @@ 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 diff --git a/tests/mcp_runtime/test_server.py b/tests/mcp_runtime/test_server.py index 75cc2bc..f592d59 100644 --- a/tests/mcp_runtime/test_server.py +++ b/tests/mcp_runtime/test_server.py @@ -219,8 +219,8 @@ 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): diff --git a/tests/mcp_runtime/test_views.py b/tests/mcp_runtime/test_views.py index 183a70c..229cf3c 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.mcp_tools 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..d986a13 100644 --- a/tests/mcp_state/test_injection.py +++ b/tests/mcp_state/test_injection.py @@ -34,7 +34,7 @@ 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`` + Mirrors what an MCP client builds: a dict ``args_schema`` taken verbatim from the server's ``inputSchema``, a ``**arguments`` coroutine, and the server's ``_meta`` preserved under ``metadata``. """ diff --git a/tests/mcp_state/test_not_authored.py b/tests/mcp_state/test_not_authored.py index a513bf0..6e228d9 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.mcp_tools 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/uv.lock b/uv.lock index 37d4923..9221ba7 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.0.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/ca/e76fde836e6e443f49b1a16ad91fa71715ec220141261a290c9241024857/fastmcp-4.0.11.tar.gz", hash = "sha256:55fc7b8e25d4f8365818ba93f7008d1b6e646ce7fe8ae49342687243ccbef9f2", size = 42606797, upload-time = "2026-10-04T16:58:10.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/0c/6c97f76e4ca6cd26fe056d6668ec4a550b2368d3699e6a50789aed226849/fastmcp-4.0.11-py3-none-any.whl", hash = "sha256:defbc5f2566a702f45817d1acead120dfa8127d60b1c1512b3200232ead13a44", size = 8161, upload-time = "2026-10-04T16:58:08.278Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "4.0.11" +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/02/d3/8d247edc9120c3be157315acd6df17c9790358c39ea45d0f9dfcec6a094b/fastmcp_slim-4.0.11.tar.gz", hash = "sha256:9152aac74d8837bc37576f5f3d83f17567250a9dfcd8187133c43faaaa9dd06f", size = 722160, upload-time = "2026-10-04T16:56:07.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/69/0b4fbe30298c9c6cc123ba8ac63e78583b3567ac04f7d125f3c2309a6e9e/fastmcp_slim-4.0.11-py3-none-any.whl", hash = "sha256:524905197e318d934908594f16fedea559d44dd682bd763d40f3556233a2b1d9", size = 898247, upload-time = "2026-10-04T16:56:05.97Z" }, +] + +[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,6 +738,23 @@ 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" @@ -450,6 +769,11 @@ 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" }, ] +[package.optional-dependencies] +mcp = [ + { name = "fastmcp" }, +] + [[package]] name = "langchain-core" version = "1.6.6" @@ -471,20 +795,6 @@ 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" }, -] - [[package]] name = "langchain-protocol" version = "0.0.18" @@ -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,15 @@ dependencies = [ [package.optional-dependencies] agent = [ + { name = "fastmcp" }, { name = "jsonschema" }, - { name = "langchain" }, + { name = "langchain", extra = ["mcp"] }, ] api = [ { name = "ag-ui-protocol" }, + { name = "fastmcp" }, { name = "jsonschema" }, - { name = "langchain" }, + { name = "langchain", extra = ["mcp"] }, ] aws = [ { name = "boto3" }, @@ -720,13 +1031,14 @@ 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.1,<5.0.0" }, { name = "httpx", specifier = ">=0.28.1,<1.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.2,<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.2.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 +1058,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 +1080,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 +1126,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 +1223,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 +1241,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 +1284,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 +1333,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 +1420,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 +1515,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 +1606,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 +1694,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 +1769,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 +1835,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 +1903,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" From d994d8ebec8f121341b0a0c268ca8c977c7fa0ae Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Wed, 23 Sep 2026 11:47:53 +0100 Subject: [PATCH 02/11] fix: three breaks the examples found, and the tests that missed them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running both examples end to end turned up three things the suite was green through, all of them the same shape: the new client is the old one's successor in role, not in the details this package reached into. The credential factory is called by fastmcp's transport with an argument the old adapter never passed (`follow_redirects`), so every connection failed at the point of use with a `TypeError` that reads like an unreachable server. It now takes what the transport gives it. The SDK stopped reading that setting off a supplied client in 2.2.0, so dropping it changes no request. A tool's `_meta` is where a server's declarations travel, and `langchain.mcp` keeps it under its own `mcp` namespace rather than flat. Reading only the flat position found nothing: `NotAuthored` stopped narrowing, publications stopped being declared, and views stopped resolving — all silently, because declarations are advisory and an absent one is indistinguishable from a server that declared nothing. `tool_meta` reads both positions and every reader goes through it. `mcp_state.injection` forwarded LangGraph's `runtime` to the tool it wraps. The old adapter declared that parameter and absorbed it; `langchain.mcp`'s tool body is `**arguments` and sends everything it is handed to the server, so the runtime went on the wire and failed to serialise. It is forwarded only to a tool that names it. The tests missed all three because the MCP tool double mirrored the old adapter: it declared `runtime`, and it stamped `_meta` flat. The double now matches what `langchain.mcp` builds, which is what makes the three new tests here worth having. Also: `httpx2` logs every request at INFO under its own name, so both examples silence it as they already silenced `httpx`, and the session-state demo reads declarations through `tool_meta` like everything else. Co-Authored-By: Claude Opus 5 --- examples/agui-events/service/app.py | 2 +- examples/session-state/demo.py | 11 +++++-- src/mcp_agent/host.py | 5 +-- src/mcp_agent/main.py | 11 +++++++ src/mcp_runtime/declarations.py | 18 +++++++++++ src/mcp_state/injection.py | 30 +++++++++++++++--- src/mcp_state/middleware.py | 5 ++- tests/mcp_agent/test_agent.py | 11 +++++++ tests/mcp_runtime/test_server.py | 20 ++++++++++++ tests/mcp_state/test_injection.py | 47 ++++++++++++++++++++++++++--- 10 files changed, 143 insertions(+), 17 deletions(-) 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/session-state/demo.py b/examples/session-state/demo.py index a8797d3..dfdb4ac 100644 --- a/examples/session-state/demo.py +++ b/examples/session-state/demo.py @@ -42,7 +42,11 @@ from langchain.mcp import MCPAdapter from mcp_agent.main import 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, + tool_meta, +) from mcp_runtime.server import build_server from mcp_state import ( StateCaptureMiddleware, @@ -64,7 +68,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) @@ -236,7 +241,7 @@ async def main() -> None: 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/src/mcp_agent/host.py b/src/mcp_agent/host.py index 3ea4bbe..3e56006 100644 --- a/src/mcp_agent/host.py +++ b/src/mcp_agent/host.py @@ -17,6 +17,8 @@ from langchain_core.messages import AIMessage, BaseMessage, ToolMessage from langchain_core.tools import BaseTool +from mcp_runtime.declarations import tool_meta + from mcp_agent.main import with_credential_support from mcp_state import ( Receipt, @@ -76,8 +78,7 @@ async def view_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 360a514..854bd65 100644 --- a/src/mcp_agent/main.py +++ b/src/mcp_agent/main.py @@ -517,7 +517,18 @@ def factory( headers: dict[str, str] | None = None, 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 diff --git a/src/mcp_runtime/declarations.py b/src/mcp_runtime/declarations.py index 86a9ed0..eb0e71a 100644 --- a/src/mcp_runtime/declarations.py +++ b/src/mcp_runtime/declarations.py @@ -46,6 +46,24 @@ async def clip_raster( PRODUCES_META_KEY = "io.developmentseed.toolsets/produces" NOT_AUTHORED_META_KEY = "io.developmentseed.toolsets/notAuthored" + +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 {} + + # Separator between the parts of a state key. NAMESPACE_SEP = "/" diff --git a/src/mcp_state/injection.py b/src/mcp_state/injection.py index a081e2b..33dca48 100644 --- a/src/mcp_state/injection.py +++ b/src/mcp_state/injection.py @@ -30,13 +30,14 @@ in a way the model can act on. """ +import inspect from collections.abc import Callable, Sequence from typing import Annotated, Any from langchain_core.tools import BaseTool, StructuredTool, ToolException from langgraph.prebuilt import InjectedState -from mcp_runtime.declarations import NOT_AUTHORED_META_KEY +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, tool_meta from mcp_state.handles import ( available, dereference_with_receipts, @@ -109,8 +110,7 @@ def not_authored_for(tool: BaseTool) -> frozenset[str]: 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 7521747..48e37e0 100644 --- a/src/mcp_state/middleware.py +++ b/src/mcp_state/middleware.py @@ -55,7 +55,7 @@ from langchain_core.tools import BaseTool from langgraph.types import Command -from mcp_runtime.declarations import PRODUCES_META_KEY, qualified +from mcp_runtime.declarations import PRODUCES_META_KEY, qualified, tool_meta from mcp_state.handles import handle_key, is_handle from mcp_state.receipts import ( INJECTED_ARTIFACT_KEY, @@ -143,8 +143,7 @@ def publications(tools: list[BaseTool]) -> Published: """ 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/tests/mcp_agent/test_agent.py b/tests/mcp_agent/test_agent.py index 03d4f3d..58c0a66 100644 --- a/tests/mcp_agent/test_agent.py +++ b/tests/mcp_agent/test_agent.py @@ -132,6 +132,17 @@ def test_with_credential_support_wires_every_connection(): } +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(): assert "under /mcp" in connect_error_hint("http://localhost:8000") assert "under /mcp" in connect_error_hint("http://localhost:8000/") diff --git a/tests/mcp_runtime/test_server.py b/tests/mcp_runtime/test_server.py index f592d59..cfe7f93 100644 --- a/tests/mcp_runtime/test_server.py +++ b/tests/mcp_runtime/test_server.py @@ -6,6 +6,7 @@ from pydantic import ValidationError from starlette.testclient import TestClient +from mcp_runtime.declarations import tool_meta from mcp_runtime.server import ( RuntimeSettings, build_server, @@ -227,3 +228,22 @@ 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_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/tests/mcp_state/test_injection.py b/tests/mcp_state/test_injection.py index d986a13..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 an MCP client 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"}) From 86a376c92d2160d68588fa0c22e728d12669020a Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Wed, 23 Sep 2026 12:05:22 +0100 Subject: [PATCH 03/11] fix: what a review of the port turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions, both in corners the new SDK and client differ from the old in, and neither exercised by a test until now. `mcp-serve-local` on any address but loopback answered 421 to every toolset. The local host built each server for 127.0.0.1 whatever HOST said, and the SDK turns on DNS-rebinding protection — a check of the Host header against loopback — for exactly that. The index at `/` still answered 200 above it, handing out URLs nothing could use. `build_local_app` now takes the host it is served on and builds every server for it. `mcp-agent chat` against an unreachable server dumped a traceback. fastmcp reports a connection that never came up as a bare `RuntimeError` with the transport's exception as its `__cause__`, so the types `CONNECT_ERRORS` names never reached the `except*`. `connect_failure` reads the cause, and the message names the port again — with the `/mcp` hint back beside it. Smaller: `ToolsetServer.run` took positional arguments and dropped them, so `run("stdio")` ran streamable HTTP; it now runs the transport it is given and applies the remembered settings only to streamable HTTP. `httpx2` is declared where it is imported, as `httpx` already was. CONSUMING's "wire it into your own agent" said `[state]` for a snippet that imports from `mcp_agent`, which is `[agent]`. Co-Authored-By: Claude Fable 5.1 --- docs/CONSUMING.md | 4 +++- pyproject.toml | 4 ++++ src/mcp_agent/main.py | 26 +++++++++++++++++++++++--- src/mcp_runtime/local.py | 19 ++++++++++++++++--- src/mcp_runtime/server.py | 17 ++++++++++++----- tests/mcp_agent/test_agent.py | 18 ++++++++++++++++++ tests/mcp_runtime/test_local.py | 17 +++++++++++++++++ tests/mcp_runtime/test_server.py | 24 ++++++++++++++++++++++++ uv.lock | 3 +++ 9 files changed, 120 insertions(+), 12 deletions(-) diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index abf8844..7a8d980 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -596,7 +596,9 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index c22fdf8..11879dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,10 @@ agent = [ "mcp-toolsets-runtime[state]", "langchain[mcp]<2.0.0,>=1.4.2", "fastmcp<5.0.0,>=4.0.1", + # 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]. diff --git a/src/mcp_agent/main.py b/src/mcp_agent/main.py index 854bd65..b56a51d 100644 --- a/src/mcp_agent/main.py +++ b/src/mcp_agent/main.py @@ -428,6 +428,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"): @@ -1017,10 +1035,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_runtime/local.py b/src/mcp_runtime/local.py index e3b1153..fbef7d2 100644 --- a/src/mcp_runtime/local.py +++ b/src/mcp_runtime/local.py @@ -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 @@ -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" @@ -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 3110e8c..05b232d 100644 --- a/src/mcp_runtime/server.py +++ b/src/mcp_runtime/server.py @@ -22,7 +22,7 @@ import importlib import re from ipaddress import IPv4Address -from typing import Any +from typing import Any, Literal from langchain_core.tools import BaseTool from mcp.server.mcpserver import MCPServer @@ -167,10 +167,17 @@ def __init__( def streamable_http_app(self, **kwargs: Any) -> Starlette: return super().streamable_http_app(**{**self._transport, **kwargs}) - def run(self, *args: Any, **kwargs: Any) -> None: - super().run( - "streamable-http", **{**self._transport, "port": self._port, **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( diff --git a/tests/mcp_agent/test_agent.py b/tests/mcp_agent/test_agent.py index 58c0a66..3ced1ab 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, @@ -182,3 +184,19 @@ 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 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 cfe7f93..ccc7734 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.declarations import tool_meta from mcp_runtime.server import ( RuntimeSettings, @@ -247,3 +249,25 @@ def __init__(self, metadata): assert tool_meta(flat) == {"k": 2} assert tool_meta(Stub({"mcp": {"server": {"name": "x"}}})) == {} assert tool_meta(Stub(None)) == {} + + +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/uv.lock b/uv.lock index 9221ba7..0fba83c 100644 --- a/uv.lock +++ b/uv.lock @@ -996,12 +996,14 @@ dependencies = [ [package.optional-dependencies] agent = [ { name = "fastmcp" }, + { name = "httpx2" }, { name = "jsonschema" }, { name = "langchain", extra = ["mcp"] }, ] api = [ { name = "ag-ui-protocol" }, { name = "fastmcp" }, + { name = "httpx2" }, { name = "jsonschema" }, { name = "langchain", extra = ["mcp"] }, ] @@ -1033,6 +1035,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.140.11,<1.0.0" }, { name = "fastmcp", marker = "extra == 'agent'", specifier = ">=4.0.1,<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.2,<2.0.0" }, From c3793f986fcde334c2af47baa4b80d71d35a39db Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Thu, 8 Oct 2026 16:06:23 +0100 Subject: [PATCH 04/11] build(deps): mcp 2.3, fastmcp 4.0.11 and langchain 1.4.3 Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 6 +++--- uv.lock | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 11879dc..946d92b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.12,<3.14" license = { file = "LICENSE" } dependencies = [ # mcp_runtime - "mcp<3.0.0,>=2.2.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", @@ -47,8 +47,8 @@ state = [ # `mcp_agent.main` constructs directly to inject per-user credential headers. agent = [ "mcp-toolsets-runtime[state]", - "langchain[mcp]<2.0.0,>=1.4.2", - "fastmcp<5.0.0,>=4.0.1", + "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. diff --git a/uv.lock b/uv.lock index 0fba83c..a6a717f 100644 --- a/uv.lock +++ b/uv.lock @@ -776,7 +776,7 @@ mcp = [ [[package]] name = "langchain-core" -version = "1.6.6" +version = "1.6.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -790,9 +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/0b/0e/cd8ace920319158f1111ad3e6d6a418254fad7594402a57560ff285d5127/langchain_core-1.6.7.tar.gz", hash = "sha256:55842ca61ffb460787c6f62c8bf775275972e9b9a3fd9b2210931eb77d038e2d", size = 1009564, upload-time = "2026-10-06T15:46:53.498Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/af/fb/12b08f501fb2fc379b82eb5c391b56c6ecc70d30aedeb4acb44de03a9252/langchain_core-1.6.7-py3-none-any.whl", hash = "sha256:e12cb80b2008b0484c33d209c42d6290ef173f7be9d639b372aba0d438676cdd", size = 572761, upload-time = "2026-10-06T15:46:52.069Z" }, ] [[package]] @@ -1033,15 +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.1,<5.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.2,<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 = "langgraph-checkpoint-postgres", marker = "extra == 'checkpointing-postgres'", specifier = ">=3.1.1,<4.0.0" }, - { name = "mcp", specifier = ">=2.2.0,<3.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" }, From 24f8fffc18b0a94aeefd0bf1cdf1a5ba4963d878 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Thu, 8 Oct 2026 16:33:52 +0100 Subject: [PATCH 05/11] fix(agent): a session per tool call, so overlapping users keep their own credentials A fastmcp client is reentrant: a call that starts while another is in flight joins that session rather than opening its own. The session's HTTP client was built by credential_client_factory when the session connected, with the credentials of whoever connected it, so a second user's call that overlapped the first's went out carrying the first user's headers. The old adapter opened a connection per call, which is what the per-turn contextvar design relied on. list_tools(client) replaces MCPAdapter(client).list_tools() in build_agent, the docs and the examples. It lists through the shared client and gives each tool a call that opens a client of its own (Client.new: same transport, no session). A test drives two users through one client at once, against a real server, and fails on the shared session with ['alice', 'alice']. Co-Authored-By: Claude Fable 5.1 --- docs/CONSUMING.md | 9 ++-- examples/agui-events/service/agent.py | 10 +++-- examples/session-state/demo.py | 5 +-- src/mcp_agent/main.py | 40 ++++++++++++++++- tests/mcp_agent/test_agent.py | 63 +++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 12 deletions(-) diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index 7a8d980..c54ce02 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -603,8 +603,7 @@ Three pieces, all three required: ```python from langchain.agents import create_agent -from langchain.mcp import MCPAdapter -from mcp_agent.main import with_credential_support +from mcp_agent.main import list_tools, with_credential_support from mcp_state import ( SESSION_STATE_PROMPT, StateCaptureMiddleware, @@ -619,11 +618,13 @@ from mcp_state import ( # Loaded per server, so each tool records where it came from. The adapter # 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. +# 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, client in with_credential_support(connections, None).items() - for tool in await MCPAdapter(client).list_tools() + for tool in await list_tools(client) ] published = publications(tools) diff --git a/examples/agui-events/service/agent.py b/examples/agui-events/service/agent.py index 88e7a2e..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 import MCPAdapter from langgraph.checkpoint.memory import InMemorySaver -from mcp_agent.main import BuiltAgent, with_credential_support, 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 @@ -38,7 +42,7 @@ async def build() -> BuiltAgent: tools = [ with_server_name(tool, server) for server, client in with_credential_support(connections, None).items() - for tool in await MCPAdapter(client).list_tools() + for tool in await list_tools(client) ] chat, named = model.build() diff --git a/examples/session-state/demo.py b/examples/session-state/demo.py index dfdb4ac..df91d76 100644 --- a/examples/session-state/demo.py +++ b/examples/session-state/demo.py @@ -39,8 +39,7 @@ 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 import MCPAdapter -from mcp_agent.main import with_credential_support +from mcp_agent.main import list_tools, with_credential_support from mcp_runtime.declarations import ( NOT_AUTHORED_META_KEY, @@ -235,7 +234,7 @@ async def main() -> None: tools = [ with_server_name(tool, server) for server, client in with_credential_support(connections, None).items() - for tool in await MCPAdapter(client).list_tools() + for tool in await list_tools(client) ] published = publications(tools) diff --git a/src/mcp_agent/main.py b/src/mcp_agent/main.py index b56a51d..41e4c5d 100644 --- a/src/mcp_agent/main.py +++ b/src/mcp_agent/main.py @@ -47,13 +47,14 @@ from fastmcp.client.transports import StreamableHttpTransport from langchain.agents import create_agent from langchain.chat_models import init_chat_model -from langchain.mcp import MCPAdapter +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 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 @@ -569,6 +570,9 @@ def with_credential_support( 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: Client( @@ -584,6 +588,38 @@ def with_credential_support( } +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("-", "_") @@ -819,7 +855,7 @@ async def build_agent( tools = [ with_server_name(tool, server) for server, client in clients.items() - for tool in await MCPAdapter(client).list_tools() + for tool in await list_tools(client) ] chat_model = init_chat_model(model, api_key=api_key.get_secret_value()) if not session_state: diff --git a/tests/mcp_agent/test_agent.py b/tests/mcp_agent/test_agent.py index 3ced1ab..2ea0e88 100644 --- a/tests/mcp_agent/test_agent.py +++ b/tests/mcp_agent/test_agent.py @@ -200,3 +200,66 @@ def test_a_refused_connection_is_a_connect_failure_however_fastmcp_wraps_it(): 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"] From ee7fa48bc6ea2d72f11a6c04750d219190002862 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Wed, 14 Oct 2026 16:40:44 +0100 Subject: [PATCH 06/11] build(deps): fastmcp 4.1, langchain 1.4.5 and langgraph 1.2.15 in the lock Co-Authored-By: Claude Opus 5.5 --- uv.lock | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/uv.lock b/uv.lock index a6a717f..332b705 100644 --- a/uv.lock +++ b/uv.lock @@ -438,19 +438,19 @@ wheels = [ [[package]] name = "fastmcp" -version = "4.0.11" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/ca/e76fde836e6e443f49b1a16ad91fa71715ec220141261a290c9241024857/fastmcp-4.0.11.tar.gz", hash = "sha256:55fc7b8e25d4f8365818ba93f7008d1b6e646ce7fe8ae49342687243ccbef9f2", size = 42606797, upload-time = "2026-10-04T16:58:10.598Z" } +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/49/0c/6c97f76e4ca6cd26fe056d6668ec4a550b2368d3699e6a50789aed226849/fastmcp-4.0.11-py3-none-any.whl", hash = "sha256:defbc5f2566a702f45817d1acead120dfa8127d60b1c1512b3200232ead13a44", size = 8161, upload-time = "2026-10-04T16:58:08.278Z" }, + { 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.0.11" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mcp-types" }, @@ -461,9 +461,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/d3/8d247edc9120c3be157315acd6df17c9790358c39ea45d0f9dfcec6a094b/fastmcp_slim-4.0.11.tar.gz", hash = "sha256:9152aac74d8837bc37576f5f3d83f17567250a9dfcd8187133c43faaaa9dd06f", size = 722160, upload-time = "2026-10-04T16:56:07.286Z" } +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/47/69/0b4fbe30298c9c6cc123ba8ac63e78583b3567ac04f7d125f3c2309a6e9e/fastmcp_slim-4.0.11-py3-none-any.whl", hash = "sha256:524905197e318d934908594f16fedea559d44dd682bd763d40f3556233a2b1d9", size = 898247, upload-time = "2026-10-04T16:56:05.97Z" }, + { 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] @@ -757,16 +757,16 @@ wheels = [ [[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] @@ -776,7 +776,7 @@ mcp = [ [[package]] name = "langchain-core" -version = "1.6.7" +version = "1.6.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -790,9 +790,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/0e/cd8ace920319158f1111ad3e6d6a418254fad7594402a57560ff285d5127/langchain_core-1.6.7.tar.gz", hash = "sha256:55842ca61ffb460787c6f62c8bf775275972e9b9a3fd9b2210931eb77d038e2d", size = 1009564, upload-time = "2026-10-06T15:46:53.498Z" } +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/af/fb/12b08f501fb2fc379b82eb5c391b56c6ecc70d30aedeb4acb44de03a9252/langchain_core-1.6.7-py3-none-any.whl", hash = "sha256:e12cb80b2008b0484c33d209c42d6290ef173f7be9d639b372aba0d438676cdd", size = 572761, upload-time = "2026-10-06T15:46:52.069Z" }, + { 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]] @@ -809,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" }, @@ -819,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]] @@ -867,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" }, @@ -876,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]] From f0640b085741e78047a35a913619c37160a79ea7 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Thu, 15 Oct 2026 10:57:04 +0100 Subject: [PATCH 07/11] fix(credentials): a missing credential still names its header under mcp 2 The v2 SDK sends a tool's unexpected exception to the client as `Error executing tool ` and withholds the text. MissingCredentialError was a plain Exception, so the one message that tells a caller which header to send stopped arriving. It is a ToolError now, which the SDK passes on. Co-Authored-By: Claude Fable 5.1 --- src/mcp_runtime/credentials.py | 10 ++++++-- tests/mcp_runtime/test_credentials.py | 35 +++++++++++++++++++-------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/mcp_runtime/credentials.py b/src/mcp_runtime/credentials.py index 210369c..aaa4c52 100644 --- a/src/mcp_runtime/credentials.py +++ b/src/mcp_runtime/credentials.py @@ -26,6 +26,7 @@ from typing import Any 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. @@ -34,8 +35,13 @@ ) -class MissingCredentialError(Exception): - """A required credential header was absent from the calling request.""" +class MissingCredentialError(ToolError): + """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__( diff --git a/tests/mcp_runtime/test_credentials.py b/tests/mcp_runtime/test_credentials.py index c5e1381..050d445 100644 --- a/tests/mcp_runtime/test_credentials.py +++ b/tests/mcp_runtime/test_credentials.py @@ -38,14 +38,8 @@ def test_header_context_resets(): credential_from_header("x-demo-token") -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. - """ +def call_whoami(monkeypatch, headers: dict[str, str]) -> str: + """The server's reply to a `whoami` call made with ``headers``.""" @tool def whoami() -> ToolResult: @@ -68,7 +62,7 @@ def whoami() -> ToolResult: headers={ "accept": "application/json, text/event-stream", "content-type": "application/json", - "x-demo-token": "secret", + **headers, }, json={ "jsonrpc": "2.0", @@ -78,4 +72,25 @@ def whoami() -> ToolResult: }, ) assert response.status_code == 200 - assert "secret" in response.text + 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 From b89d5784029cf7c219a2bb03c7918214a6889673 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Thu, 15 Oct 2026 11:03:15 +0100 Subject: [PATCH 08/11] docs(consuming): what 0.11 breaks, and how to take it A section for each persona: the exception text that no longer reaches the model, the loopback Host check, the client replacement and its three traps (nested _meta, httpx2, MCPError), the server rename, and a grep for the rest. The ToolError paragraph in the contract says which exceptions the model sees. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +-- docs/CONSUMING.md | 81 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) 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 c54ce02..bd1849e 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 @@ -1456,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_runtime.declarations.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.mcp_tools.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. From 1c6c704fd89fae8c444a71555aad3b07d7f65f25 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Fri, 16 Oct 2026 10:08:07 +0100 Subject: [PATCH 09/11] docs(api): say in /docs how a client finds and sends credentials `POST /runs` and `GET /connections` now say that a credential goes as an HTTP header on the run, named by `/connections`. `CredentialInfo.header` has a description, and the app has an overview and the package version. Notes for maintainers stay in the docstrings, after a `\f`, so `/docs` leaves them out. Co-Authored-By: Claude Opus 5.5 --- src/mcp_agent_api/app.py | 20 +++++++++++++++++++- src/mcp_agent_api/routes.py | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) 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. From a14b4ce66106f08ce2b3d92c7e5e78a1c798f4b2 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Fri, 16 Oct 2026 10:08:07 +0100 Subject: [PATCH 10/11] refactor: tool_meta to mcp_state, public annotation helpers, SDK internals test - `tool_meta` reads the client-side layout of `langchain.mcp`, so it moves from `mcp_runtime.declarations` to `mcp_state`. - `return_annotation` and `arms` are public: `declarations` uses them too. - A test names the SDK internals `to_mcp_tool` builds on, so a minor mcp release that changes them fails there first. Co-Authored-By: Claude Opus 5.5 --- docs/CONSUMING.md | 2 +- examples/session-state/demo.py | 2 +- src/mcp_agent/host.py | 2 +- src/mcp_runtime/declarations.py | 23 +++-------------------- src/mcp_runtime/mcp_tools.py | 15 ++++++++------- src/mcp_state/__init__.py | 2 ++ src/mcp_state/injection.py | 4 ++-- src/mcp_state/middleware.py | 10 ++++++++-- src/mcp_state/state.py | 18 ++++++++++++++++++ tests/mcp_runtime/test_mcp_tools.py | 24 ++++++++++++++++++++++++ tests/mcp_runtime/test_server.py | 20 -------------------- tests/mcp_state/test_tool_meta.py | 20 ++++++++++++++++++++ 12 files changed, 88 insertions(+), 54 deletions(-) create mode 100644 tests/mcp_state/test_tool_meta.py diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index bd1849e..e127a78 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -1502,7 +1502,7 @@ users keep their own credentials; the code is in [4b](#4b-wiring-it-into-your-ow Three things it changes around that: - A tool's `_meta` is nested under `metadata["mcp"]["tool"]`. Read it with - `mcp_runtime.declarations.tool_meta(tool)`, never `metadata["_meta"]`: the old + `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 diff --git a/examples/session-state/demo.py b/examples/session-state/demo.py index df91d76..46a590a 100644 --- a/examples/session-state/demo.py +++ b/examples/session-state/demo.py @@ -44,7 +44,6 @@ from mcp_runtime.declarations import ( NOT_AUTHORED_META_KEY, PRODUCES_META_KEY, - tool_meta, ) from mcp_runtime.server import build_server from mcp_state import ( @@ -56,6 +55,7 @@ owners, publications, state_keys, + tool_meta, with_server_name, ) diff --git a/src/mcp_agent/host.py b/src/mcp_agent/host.py index 3e56006..6022ed4 100644 --- a/src/mcp_agent/host.py +++ b/src/mcp_agent/host.py @@ -17,10 +17,10 @@ from langchain_core.messages import AIMessage, BaseMessage, ToolMessage from langchain_core.tools import BaseTool -from mcp_runtime.declarations import tool_meta from mcp_agent.main import with_credential_support from mcp_state import ( + tool_meta, Receipt, describe, receipts_of, diff --git a/src/mcp_runtime/declarations.py b/src/mcp_runtime/declarations.py index eb0e71a..2875988 100644 --- a/src/mcp_runtime/declarations.py +++ b/src/mcp_runtime/declarations.py @@ -38,7 +38,7 @@ async def clip_raster( from langchain_core.tools import BaseTool from mcp.server.mcpserver.tools import Tool as MCPTool -from mcp_runtime.mcp_tools import _arms, _return_annotation +from mcp_runtime.mcp_tools import arms, return_annotation # The ``_meta`` keys a client reads declarations from. Reverse-DNS per the MCP # ``_meta`` convention, so they cannot collide with ``ui`` (MCP Apps) or @@ -47,23 +47,6 @@ async def clip_raster( NOT_AUTHORED_META_KEY = "io.developmentseed.toolsets/notAuthored" -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 {} - - # Separator between the parts of a state key. NAMESPACE_SEP = "/" @@ -149,12 +132,12 @@ def output_fields(tool: BaseTool) -> list[str]: 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") } diff --git a/src/mcp_runtime/mcp_tools.py b/src/mcp_runtime/mcp_tools.py index 16dcc9c..ca8d43d 100644 --- a/src/mcp_runtime/mcp_tools.py +++ b/src/mcp_runtime/mcp_tools.py @@ -22,7 +22,7 @@ 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 — some 30 lines, and the half we already owned is unchanged. +is ours now, and the half we already owned is unchanged. """ from types import UnionType @@ -30,11 +30,14 @@ from langchain_core.tools import BaseTool, InjectedToolArg from mcp.server.mcpserver.tools import Tool as MCPTool + +# Internal to the SDK, and imported nowhere else: a minor release can change +# them, and test_the_sdk_internals_we_build_on_keep_their_shape says so first. from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata from pydantic import BaseModel, RootModel, create_model -def _return_annotation(tool: BaseTool) -> Any: +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: @@ -42,7 +45,7 @@ def _return_annotation(tool: BaseTool) -> Any: return get_type_hints(fn).get("return") -def _arms(annotation: Any) -> tuple[Any, ...]: +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) @@ -160,10 +163,8 @@ def to_mcp_tool(tool: BaseTool) -> MCPTool: f"tool {tool.name!r} takes injected arguments {injected}, which " "nothing on the MCP side can fill" ) - annotation = _return_annotation(tool) - if annotation is None or not all( - _structured_dict(arm) for arm in _arms(annotation) - ): + 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}) " 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 33dca48..caed95f 100644 --- a/src/mcp_state/injection.py +++ b/src/mcp_state/injection.py @@ -37,7 +37,7 @@ from langchain_core.tools import BaseTool, StructuredTool, ToolException from langgraph.prebuilt import InjectedState -from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, tool_meta +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY from mcp_state.handles import ( available, dereference_with_receipts, @@ -47,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): diff --git a/src/mcp_state/middleware.py b/src/mcp_state/middleware.py index 48e37e0..d4fff99 100644 --- a/src/mcp_state/middleware.py +++ b/src/mcp_state/middleware.py @@ -55,14 +55,20 @@ from langchain_core.tools import BaseTool from langgraph.types import Command -from mcp_runtime.declarations import PRODUCES_META_KEY, qualified, tool_meta +from mcp_runtime.declarations import PRODUCES_META_KEY, qualified from mcp_state.handles import handle_key, is_handle from mcp_state.receipts import ( INJECTED_ARTIFACT_KEY, 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" 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_runtime/test_mcp_tools.py b/tests/mcp_runtime/test_mcp_tools.py index ede0097..46a43b8 100644 --- a/tests/mcp_runtime/test_mcp_tools.py +++ b/tests/mcp_runtime/test_mcp_tools.py @@ -195,3 +195,27 @@ def test_the_input_schema_says_so_too(): 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_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_server.py b/tests/mcp_runtime/test_server.py index ccc7734..e9a66e5 100644 --- a/tests/mcp_runtime/test_server.py +++ b/tests/mcp_runtime/test_server.py @@ -8,7 +8,6 @@ from mcp.server.mcpserver import MCPServer -from mcp_runtime.declarations import tool_meta from mcp_runtime.server import ( RuntimeSettings, build_server, @@ -232,25 +231,6 @@ def test_build_server_rejects_non_contract_tool(monkeypatch): build_server("loose-toolset") -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)) == {} - - 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.""" 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)) == {} From f4ffbd04f2410824b8bc83d4bd55b871ad152455 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Fri, 16 Oct 2026 10:16:18 +0100 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20split=20the=20LangChain=20?= =?UTF-8?q?=E2=86=92=20MCP=20bridge=20from=20the=20ToolResult=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcp_runtime.mcp_tools` becomes `mcp_runtime.bridge`, the only code that touches the SDK's internals: `to_mcp_tool`, the argument model (`bridge.arguments`) and the return annotation (`bridge.annotations`). The output schema and its gate move to `tool_result.output_schema`, next to the contract they enforce, with `arms`. Each half can be tested on its own. Co-Authored-By: Claude Opus 5.5 --- docs/CONSUMING.md | 2 +- src/mcp_runtime/bridge/__init__.py | 83 ++++++++ src/mcp_runtime/bridge/annotations.py | 17 ++ src/mcp_runtime/bridge/arguments.py | 61 ++++++ src/mcp_runtime/declarations.py | 7 +- src/mcp_runtime/mcp_tools.py | 197 ------------------ src/mcp_runtime/server.py | 2 +- src/mcp_runtime/tool_result.py | 77 ++++++- .../{test_mcp_tools.py => test_bridge.py} | 2 +- tests/mcp_runtime/test_views.py | 2 +- tests/mcp_state/test_not_authored.py | 2 +- 11 files changed, 245 insertions(+), 207 deletions(-) create mode 100644 src/mcp_runtime/bridge/__init__.py create mode 100644 src/mcp_runtime/bridge/annotations.py create mode 100644 src/mcp_runtime/bridge/arguments.py delete mode 100644 src/mcp_runtime/mcp_tools.py rename tests/mcp_runtime/{test_mcp_tools.py => test_bridge.py} (99%) diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index e127a78..3dc1a70 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -1519,7 +1519,7 @@ an `MCPServer` that remembers its host, path and stateless transport, so 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.mcp_tools.to_mcp_tool`. +`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 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/declarations.py b/src/mcp_runtime/declarations.py index 2875988..8104444 100644 --- a/src/mcp_runtime/declarations.py +++ b/src/mcp_runtime/declarations.py @@ -38,7 +38,8 @@ async def clip_raster( from langchain_core.tools import BaseTool from mcp.server.mcpserver.tools import Tool as MCPTool -from mcp_runtime.mcp_tools 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 @@ -127,7 +128,7 @@ 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.mcp_tools` 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. @@ -225,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.mcp_tools` 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]] = {} diff --git a/src/mcp_runtime/mcp_tools.py b/src/mcp_runtime/mcp_tools.py deleted file mode 100644 index ca8d43d..0000000 --- a/src/mcp_runtime/mcp_tools.py +++ /dev/null @@ -1,197 +0,0 @@ -"""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 -module 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, and the half we already owned is unchanged. -""" - -from types import UnionType -from typing import Any, Union, cast, get_args, get_origin, get_type_hints, is_typeddict - -from langchain_core.tools import BaseTool, InjectedToolArg -from mcp.server.mcpserver.tools import Tool as MCPTool - -# Internal to the SDK, and imported nowhere else: a minor release can change -# them, and test_the_sdk_internals_we_build_on_keep_their_shape says so first. -from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata -from pydantic import BaseModel, RootModel, create_model - - -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 _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:`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 - - -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 injected := _injected(tool): - raise RuntimeError( - f"tool {tool.name!r} takes injected arguments {injected}, which " - "nothing on the MCP side can fill" - ) - 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" - ) - 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/server.py b/src/mcp_runtime/server.py index 05b232d..9a8b351 100644 --- a/src/mcp_runtime/server.py +++ b/src/mcp_runtime/server.py @@ -34,7 +34,7 @@ from mcp_runtime.credentials import credential_middleware from mcp_runtime.declarations import state_declarations, with_state_meta -from mcp_runtime.mcp_tools import to_mcp_tool +from mcp_runtime.bridge import to_mcp_tool from mcp_runtime.views import load_views, register_views, with_view_meta diff --git a/src/mcp_runtime/tool_result.py b/src/mcp_runtime/tool_result.py index 607c14d..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.mcp_tools`` 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/tests/mcp_runtime/test_mcp_tools.py b/tests/mcp_runtime/test_bridge.py similarity index 99% rename from tests/mcp_runtime/test_mcp_tools.py rename to tests/mcp_runtime/test_bridge.py index 46a43b8..d35b7b9 100644 --- a/tests/mcp_runtime/test_mcp_tools.py +++ b/tests/mcp_runtime/test_bridge.py @@ -6,7 +6,7 @@ from langchain_core.tools import tool from pydantic import BaseModel -from mcp_runtime.mcp_tools import to_mcp_tool +from mcp_runtime.bridge import to_mcp_tool from mcp_runtime.tool_result import ToolError, ToolResult diff --git a/tests/mcp_runtime/test_views.py b/tests/mcp_runtime/test_views.py index 229cf3c..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.mcp_tools import to_mcp_tool +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 diff --git a/tests/mcp_state/test_not_authored.py b/tests/mcp_state/test_not_authored.py index 6e228d9..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.mcp_tools import to_mcp_tool +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