Skip to content

feat!: mcp 2, and the tool bridge off the retired adapter - #165

Merged
ciaransweet merged 11 commits into
mainfrom
feat/mcp-2
Oct 16, 2026
Merged

ciaransweet merged 11 commits into
mainfrom
feat/mcp-2

Conversation

@ciaransweet

@ciaransweet ciaransweet commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

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:

Was Now
mcp<2 mcp>=2.3,<3. The SDK's own renames apply, e.g. McpError → MCPError
langchain-mcp-adapters, MultiServerMCPClient Removed. The [agent] extra brings langchain[mcp], fastmcp>=4 and httpx2
mcp_agent.main.with_credential_support(...) returns connection dicts Returns one fastmcp Client per toolset. Load each one's tools with mcp_agent.main.list_tools(client)
credential_client_factory builds an httpx client Builds an httpx2 client, the HTTP stack mcp 2 uses
A tool's metadata["_meta"] mcp_runtime.declarations.tool_meta(tool). langchain.mcp nests _meta, and the old read now finds nothing without failing, which turns off NotAuthored, publications and views
mcp_runtime.fastmcp_output.to_fastmcp mcp_runtime.mcp_tools.to_mcp_tool
build_local_app(toolsets, base_url) Adds host="127.0.0.1". Pass the address you serve on, or see below
build_server returns a FastMCP Returns ToolsetServer, an MCPServer. streamable_http_app() still gives the same URL shape and stateless transport

Behaviour changes from the SDK:

  • Loopback servers check the Host header. A server built for 127.0.0.1 but reached by another name answers 421. Deployments bind 0.0.0.0 and are unaffected.
  • A tool's raised exception reaches the model as Error executing tool <name>, with no detail. v1 appended the exception's text. A returned ToolResult error arm, or a raised mcp.server.mcpserver.exceptions.ToolError, still carries its message, so a tool that raises to tell the model something needs one of those. MissingCredentialError is a ToolError now for this reason, so a missing header is still named.

Where to look in review

  1. src/mcp_runtime/credentials.py: per-user credentials on the server. mcp 2 has no request contextvar, so this PR adds its own, set by credential_middleware. This is the only part with no SDK equivalent to rely on. Tested through a real HTTP request.
  2. 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.
  3. src/mcp_runtime/mcp_tools.py: converting LangChain tools to MCP tools. This is now our code, because langchain.mcp only converts the other way. It keeps the output schema and the argument model that refuses unknown arguments.
  4. The public API above. It can't change again without another major release.

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

  • Lint is clean, the JS builds, and 588 tests pass, 7 skipped.
  • Both examples work end to end, including a real mistral-small turn through the agui-events example.
  • Ported downstream in ecmwf/dss-agentic-ai-services#239: 658 tests pass locally against this branch.

🤖 Generated with Claude Code

ciaransweet and others added 6 commits October 14, 2026 16:39
`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>
@ciaransweet
ciaransweet marked this pull request as ready for review October 14, 2026 16:03
@ciaransweet

Copy link
Copy Markdown
Contributor Author

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:

  • tool_meta: langchain.mcp nests a tool's _meta. Reading it flat finds nothing, which quietly turned off NotAuthored, publications and views.
  • The credential factory accepts extra transport options such as follow_redirects. fastmcp passes them, and rejecting them failed every connection with an error that looked like an unreachable server.
  • mcp_state.injection no longer forwards LangGraph's runtime. The new client sends every argument to the server, and runtime can't be serialised.
  • list_tools opens a session per call. A shared fastmcp session let one user's call go out with another user's credentials.
  • connect_failure reads the error's __cause__, because fastmcp wraps a refused connection in a bare RuntimeError.
  • build_local_app takes host. Built for loopback but served on another address, every toolset answered 421.

Verified beyond the tests. Both examples run end to end, including a real mistral-small turn through agui-events. In that turn a tool took a large value from session state by reference, which shows the session-state contract still works against a real model.

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.

ciaransweet and others added 2 commits October 15, 2026 10:57
…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>
@ciaransweet
ciaransweet requested a review from yellowcap October 15, 2026 10:10

@yellowcap yellowcap left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /runs does not mention credentials. A reader of /docs cannot 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 from GET /connections. Send only the headers where supplied is false."
  • CredentialInfo.header has no field description. Say that it is the name of the HTTP header to send on POST /runs.
  • The app has no OpenAPI description, and info.version is 0.1.0. An overview (connections first, then runs, and the credential step) gives a client one place to start.
  • The GET /connections docstring 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.

Comment thread src/mcp_runtime/mcp_tools.py Outdated
@@ -0,0 +1,196 @@
"""A LangChain tool as an MCP tool, with an output schema from its return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea for a follow-up, not this PR: to_mcp_tool does two different jobs.

  1. A generic LangChain → MCP bridge (argument model, no extra arguments, refuse InjectedToolArg, wrap ainvoke). This is the part that replaces the adapter.
  2. Our ToolResult contract (output schema from the return annotation, the required message gate). This belongs next to tool_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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/mcp_runtime/mcp_tools.py Outdated
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "some 30 lines" is not correct now. The bridge half alone is about 80 lines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a14b4ce: the count is gone.

Comment thread src/mcp_runtime/mcp_tools.py Outdated

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both, in a14b4ce and f4ffbd0: the internals are imported only inside mcp_runtime.bridge, and test_the_sdk_internals_we_build_on_keep_their_shape names the fields we rely on, so a minor mcp release that changes them fails there first.

Comment thread src/mcp_runtime/mcp_tools.py Outdated
return model, schema


def to_mcp_tool(tool: BaseTool) -> MCPTool:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/mcp_runtime/declarations.py Outdated
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public now (return_annotation, arms) in a14b4ce, and in their own modules in f4ffbd0: bridge.annotations and tool_result.

Comment thread src/mcp_runtime/declarations.py Outdated
NOT_AUTHORED_META_KEY = "io.developmentseed.toolsets/notAuthored"


def tool_meta(tool: Any) -> dict[str, Any]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to mcp_state.tool_meta in a14b4ce. It was new in this PR, so nothing released imported it from declarations.

ciaransweet and others added 3 commits October 16, 2026 10:08
`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>
@ciaransweet

Copy link
Copy Markdown
Contributor Author

Thanks @yellowcap. The OpenAPI points are in 1c6c704:

  • POST /runs says credentials go as HTTP headers on the run, named by GET /connections, and to send only those where supplied is false.
  • CredentialInfo.header has a description.
  • The app has an overview (connections, then runs and credentials, then threads), and its version is the package's.
  • The maintainer notes in the /connections and /runs docstrings now come after a \f, which FastAPI leaves out of /docs.

The inline points are answered on each comment. Your bridge/ split is done in this PR, and plain-function tools are #195. Could you take another look?

@ciaransweet
ciaransweet requested a review from yellowcap October 16, 2026 09:25

@yellowcap yellowcap left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets roll MCP v2 here we go. Thanks @ciaransweet !

@ciaransweet
ciaransweet merged commit 9cd1705 into main Oct 16, 2026
4 checks passed
@ciaransweet
ciaransweet deleted the feat/mcp-2 branch October 16, 2026 09:39
ciaransweet pushed a commit that referenced this pull request Oct 16, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate to mcp 2.x, off the retired langchain-mcp-adapters

2 participants