Repository navigation
feat!: mcp 2, and the tool bridge off the retired adapter - #165
Conversation
2454353 to
93bbb3f
Compare
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
… lock Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1b1423a to
ee7fa48
Compare
|
Notes for review, in place of earlier progress comments. Why some code in the diff exists. The new client takes over the old adapter's job but handles the details differently. Each of these broke something without any error until fixed, and each now has a test that fails on the old behaviour:
Verified beyond the tests. Both examples run end to end, including a real Not covered. A toolset tool that asks the user for input (elicitation) already raises an interrupt through this stack, but nothing maps the answer back when the run resumes. Nothing uses that path today. It's part of #166. |
…cp 2 The v2 SDK sends a tool's unexpected exception to the client as `Error executing tool <name>` 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
yellowcap
left a comment
There was a problem hiding this comment.
Tried this locally and works fine. A few comments that might be worth addressing. The key one being adding something to the openAPI spec so that clients can discover the auth patterns. An idea for a small reorganisation of the code (in potential follow up), and a suggestion to allow making any python function an mcp tool, not just langchain (now that we control the MCP tool builder)
OpenAPI and credentials (these routes are not in this diff, so the notes are here):
POST /runsdoes not mention credentials. A reader of/docscannot see that a toolset credential goes as an HTTP header on this request. Suggestion: add a short paragraph to the docstring. For example: "Some toolsets need a credential. Send each one as an HTTP header on this request. Get the header names fromGET /connections. Send only the headers wheresuppliedisfalse."CredentialInfo.headerhas no field description. Say that it is the name of the HTTP header to send onPOST /runs.- The app has no OpenAPI description, and
info.versionis0.1.0. An overview (connections first, then runs, and the credential step) gives a client one place to start. - The
GET /connectionsdocstring is written for maintainers (it has:func:roles and implementation notes), but Swagger shows it to callers as-is. Maybe keep the caller-facing part in the docstring and move the rest to a comment.
The header names cannot be fixed in the schema, because they depend on the connected toolsets. So /connections as the discovery route is right. It only needs to be documented as that.
| @@ -0,0 +1,196 @@ | |||
| """A LangChain tool as an MCP tool, with an output schema from its return. | |||
There was a problem hiding this comment.
Idea for a follow-up, not this PR: to_mcp_tool does two different jobs.
- A generic LangChain → MCP bridge (argument model, no extra arguments, refuse
InjectedToolArg, wrapainvoke). This is the part that replaces the adapter. - Our ToolResult contract (output schema from the return annotation, the required
messagegate). This belongs next totool_result.py.
A small subpackage could keep them apart:
mcp_runtime/
bridge/ # LangChain tool -> MCP tool; the only code that touches SDK internals
__init__.py # to_mcp_tool
arguments.py # argument model, extra="forbid", InjectedToolArg refusal
annotations.py # return_annotation, arms (public, also used by declarations)
tool_result.py # contract + output schema derivation and gate
Then each half can be tested and reused on its own.
There was a problem hiding this comment.
Done here rather than as a follow-up, in f4ffbd0. mcp_runtime.bridge holds to_mcp_tool (__init__.py), arguments.py and annotations.py, and is the only code that imports SDK internals. The output schema and its gate are tool_result.output_schema. One change from your layout: arms lives in tool_result.py, because with it in bridge/annotations.py the two modules imported each other.
| 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. |
There was a problem hiding this comment.
Nit: "some 30 lines" is not correct now. The bridge half alone is about 80 lines.
|
|
||
| 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 |
There was a problem hiding this comment.
ArgModelBase and FuncMetadata come from mcp.server.mcpserver.utilities.func_metadata, which is an internal SDK module. The pin is mcp>=2.3.0,<3.0.0, so a minor release can change these. Maybe keep all imports of SDK internals in one module (see the bridge/ idea), or add a test that fails clearly if their shape changes.
| return model, schema | ||
|
|
||
|
|
||
| def to_mcp_tool(tool: BaseTool) -> MCPTool: |
There was a problem hiding this comment.
Now that we own this builder, LangChain is only a small part of it. _return_annotation, not_authored and output_fields already read the hints of the plain function behind the tool. The LangChain-specific parts are only tool_call_schema, ainvoke, InjectedToolArg, and the isinstance(tool, BaseTool) check in load_tools (server.py).
Suggestion for a follow-up: let the bridge take a neutral spec, for example ToolSpec(name, description, fn, args_model), with two constructors, from_langchain(BaseTool) and from_function(fn). Then TOOLS can contain plain Python functions, and the ToolResult gate, NotAuthored, views and state _meta work the same. Later this could also make langchain-core optional for toolset authors.
A smaller alternative is to wrap plain callables with StructuredTool.from_function in load_tools. That takes about 3 lines, but toolsets still depend on langchain-core.
There was a problem hiding this comment.
Agreed it's worth having, but no toolset has asked yet, so I've written both options up in #195 and left it for when one does.
| 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 |
There was a problem hiding this comment.
This imports private helpers (_arms, _return_annotation) from another module. They are shared annotation helpers now, so maybe make them public, in their own small module.
| NOT_AUTHORED_META_KEY = "io.developmentseed.toolsets/notAuthored" | ||
|
|
||
|
|
||
| def tool_meta(tool: Any) -> dict[str, Any]: |
There was a problem hiding this comment.
tool_meta reads the client-side layout of langchain.mcp (metadata["mcp"]["tool"]["_meta"]), which is the MCP → LangChain direction. Only mcp_agent and mcp_state use it. It does not belong in the server package, so maybe move it to mcp_state or to a small client-side module.
There was a problem hiding this comment.
Moved to mcp_state.tool_meta in a14b4ce. It was new in this PR, so nothing released imported it from declarations.
`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 <noreply@anthropic.com>
…rnals 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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
|
Thanks @yellowcap. The OpenAPI points are in 1c6c704:
The inline points are answered on each comment. Your |
yellowcap
left a comment
There was a problem hiding this comment.
Lets roll MCP v2 here we go. Thanks @ciaransweet !
🤖 I have created a release *beep* *boop* --- ## [0.11.0](mcp-toolsets-runtime-v0.10.7...mcp-toolsets-runtime-v0.11.0) (2026-10-16) ### ⚠ BREAKING CHANGES * mcp 2, and the tool bridge off the retired adapter ([#165](#165)) ### Features * mcp 2, and the tool bridge off the retired adapter ([#165](#165)) ([9cd1705](9cd1705)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: ds-release-bot[bot] <116609932+ds-release-bot[bot]@users.noreply.github.com>
Moves the runtime to mcp 2 and off
langchain-mcp-adapters, which was retired on 11 September. Its successor,langchain[mcp], needs fastmcp 4, which needs mcp 2, so the two have to move together. Releases as 0.11.0. Closes #163.Breaking changes
Toolset authors: nothing to rewrite. Tools are LangChain tools as before, and declarations,
ToolResult, views and credentials are written the same way. One behaviour change applies to them, the second bullet under the table.Hosts and agents that import the runtime:
mcp<2mcp>=2.3,<3. The SDK's own renames apply, e.g.McpError→MCPErrorlangchain-mcp-adapters,MultiServerMCPClient[agent]extra bringslangchain[mcp],fastmcp>=4andhttpx2mcp_agent.main.with_credential_support(...)returns connection dictsClientper toolset. Load each one's tools withmcp_agent.main.list_tools(client)credential_client_factorybuilds anhttpxclienthttpx2client, the HTTP stack mcp 2 usesmetadata["_meta"]mcp_runtime.declarations.tool_meta(tool).langchain.mcpnests_meta, and the old read now finds nothing without failing, which turns off NotAuthored, publications and viewsmcp_runtime.fastmcp_output.to_fastmcpmcp_runtime.mcp_tools.to_mcp_toolbuild_local_app(toolsets, base_url)host="127.0.0.1". Pass the address you serve on, or see belowbuild_serverreturns aFastMCPToolsetServer, anMCPServer.streamable_http_app()still gives the same URL shape and stateless transportBehaviour changes from the SDK:
Hostheader. A server built for127.0.0.1but reached by another name answers 421. Deployments bind0.0.0.0and are unaffected.Error executing tool <name>, with no detail. v1 appended the exception's text. A returnedToolResulterror arm, or a raisedmcp.server.mcpserver.exceptions.ToolError, still carries its message, so a tool that raises to tell the model something needs one of those.MissingCredentialErroris aToolErrornow for this reason, so a missing header is still named.Where to look in review
src/mcp_runtime/credentials.py: per-user credentials on the server. mcp 2 has no request contextvar, so this PR adds its own, set bycredential_middleware. This is the only part with no SDK equivalent to rely on. Tested through a real HTTP request.src/mcp_agent/main.py,list_tools: credential isolation on the client. Each tool call opens its own client session. A fastmcp client is reentrant, so with a shared session one user's call could go out with another user's credential headers. A test runs two users through one client at once against a real server.src/mcp_runtime/mcp_tools.py: converting LangChain tools to MCP tools. This is now our code, becauselangchain.mcponly converts the other way. It keeps the output schema and the argument model that refuses unknown arguments.docs/CONSUMING.md§7 is the upgrade guide for consumers: the same changes, by persona, with a grep for the rest.Not in this PR: rebuilding views and elicitation on mcp 2's own extensions (#166). They're ported as they stand.
Checks
mistral-smallturn through theagui-eventsexample.🤖 Generated with Claude Code