Skip to content

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

Draft
ciaransweet wants to merge 3 commits into
mainfrom
feat/mcp-2
Draft

ciaransweet wants to merge 3 commits into
mainfrom
feat/mcp-2

Conversation

@ciaransweet

Copy link
Copy Markdown
Contributor

Closes #163.

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. Draft because the API surface is worth a look before it releases as a major.

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 — dss-runtime calls exactly that and needs no change.

The LangChain-tool to MCP-tool conversion is ours now, since 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 now 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 that 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, and a new test drives it through a real HTTP request — this is the one piece 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, since fastmcp's transport takes an httpx_client_factory the same way — now on httpx2, the HTTP stack mcp 2 moved to. mcp-cli uses the v2 Client and the renamed wire fields (input_schema, is_error, structured_content).

Checks

  • ./scripts/lint clean, 565 passed, 6 skipped.
  • Against a running server, not only in tests: mcp-serve-local boots the example toolset, / and /<toolset>/health answer, mcp-cli list and mcp-cli call work through the mounted app, structured content comes back, and an undeclared argument is still refused by name.

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 carrying a port. Deployments bind 0.0.0.0, where the SDK leaves the protection off, so they are unaffected.

Two more from the SDK, neither requiring a change here: an unexpected exception from a tool now reaches the client as Error executing tool <name> with no detail (a returned ToolError is unaffected), and idle-session expiry does not apply to these servers because they are stateless.

Not in this PR

The extension APIs v2 added — MCP Apps as a first-class extension, and Resolve/elicitation, which overlap views.py and mcp_agent.interrupt_gate respectively — are ported as they stand, not rebuilt on the new primitives. Worth their own issue once this lands.

Downstream is pin-only: neither mcp-toolsets nor dss-agentic-ai-services imports mcp directly.

🤖 Generated with Claude Code

`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>
@ciaransweet

Copy link
Copy Markdown
Contributor Author

Filed the follow-up as #166: views.py onto mcp.server.apps (our constants already match the extension's, so what we gain is capability negotiation, resource CSP/permissions, and the spec tracking us rather than the reverse), and the seam between the interrupt gate and v2 elicitation.

One thing #166 records that is worth knowing while reviewing this PR: since MCPAdapter arms elicitation by default, a toolset tool that elicits already raises an interrupt through our stack. mcp_agent_api.events degrades correctly on the way out, but nothing maps a client's answer back into the shape langchain.mcp expects on resume. Nothing exercises that path today, in either direction.

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>
@ciaransweet

Copy link
Copy Markdown
Contributor Author

Ran both examples end to end. They did not work, and the three reasons were all the same shape — the new client is the old one's successor in role, not in the details this package reached into. Fixed in 5e5837f, with a test for each.

  1. Every connection failed. fastmcp's transport calls the credential factory with follow_redirects, which langchain-mcp-adapters never passed. The TypeError surfaces at connect time and reads like an unreachable server. (The SDK stopped honouring that setting on a supplied client in 2.2.0, so dropping it changes no request.)

  2. Every server-side declaration stopped applying, silently. langchain.mcp keeps a tool's _meta under its own mcp namespace instead of flat, so NotAuthored stopped narrowing, publications stopped being declared and views stopped resolving. Nothing failed, because an absent declaration is indistinguishable from a server that declared nothing. There is now one tool_meta reader and all three call sites go through it.

  3. runtime went on the wire. mcp_state.injection forwarded LangGraph's runtime to the wrapped tool; the old adapter declared that parameter and absorbed it, while langchain.mcp's body is **arguments and sends everything to the server. It failed to serialise and took the call with it.

The suite was green through all three because the MCP tool double mirrored the old adapter — it declared runtime and stamped _meta flat. The double now matches what langchain.mcp builds, which is what makes the new tests worth anything. 569 passed.

session-state now runs clean: three servers, the full scripted conversation, all seven sections, including the narrowing and both refusals.

agui-events needs a provider key, which I do not have, so the model turn is the one thing unverified. Everything up to it does work: five servers up, six tools loaded through MCPAdapter, publications declared on the three that declare them, the ui://raster-ops/clip bundle fetched through the rewritten view_bundles (514 kB), the app built and GET / and /connections answering. Worth someone running a real turn against it before this leaves draft.

@ciaransweet

Copy link
Copy Markdown
Contributor Author

Drove a real model turn against the agui-events example (mistralai:mistral-small-latest), which closes the last gap. No further changes needed — nothing broke.

POST /runs streamed a complete, correct turn:

RUN_STARTED -> TOOL_CALL_START/ARGS/END/RESULT -> ACTIVITY_SNAPSHOT -> STATE_DELTA
            -> (again, clip_raster) -> ACTIVITY_SNAPSHOT -> STATE_DELTA
            -> TEXT_MESSAGE_START/CONTENT/END -> STATE_DELTA
            -> MESSAGES_SNAPSHOT -> RUN_FINISHED {outcome: success}

The activity snapshots are the part worth reading, because they are the session-state contract working against a real model rather than a script:

  • state.published — search_datasets published dataset-search/search_datasets/area_of_interest and /datasets
  • state.consumed — clip_raster received aoi as a handle onto that key. That is the narrowing surviving the migration: the parameter is NotAuthored, the model passed @state:<key> rather than writing a 2000-vertex polygon, and the 38 kB never entered the transcript
  • state.published — clip_raster's own outputs
  • mcp.view — ui://raster-ops/clip with its data payload, so the client knows which view renders that result

And the view surface behind it: GET /views/raster-ops/clip serves 533 kB of text/html, an unknown view 404s.

Both examples are now verified end to end. Ready for review whenever you want to take it out of draft.

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>
@ciaransweet

Copy link
Copy Markdown
Contributor Author

Reviewed the port with fresh eyes and fixed what it turned up, in 2454353. Two regressions, both reproduced before and after:

  1. 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 for exactly that — so / answered 200 while every /<toolset>/mcp refused any Host but loopback. build_local_app now takes the host it is served on. Reproduced with HOST=0.0.0.0 and Host: toolsets:8793: 421 before, 200 after. dss-serve-local builds on build_local_app and should pass its host through when it bumps.

  2. mcp-agent chat against an unreachable server dumped a traceback. fastmcp wraps a refused connection in a bare RuntimeError with the transport error as __cause__, so CONNECT_ERRORS never matched. connect_failure reads the cause; the message names the port again and the /mcp hint is back.

Smaller: ToolsetServer.run("stdio") ran streamable HTTP (positional args were dropped); httpx2 is now declared where it is imported; CONSUMING §4b said [state] for a snippet that needs [agent].

Checked and holding, worth knowing: per-user credential isolation on one fastmcp Client — alice → bob → alice each reached the tool with their own token, so the factory runs per connection and nothing carries between users.

Each fix has a test that fails on the old behaviour. 572 passed.

This branch has not been deployed

No deployments
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

1 participant