Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 93 additions & 12 deletions docs/CONSUMING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`).
Expand Down Expand Up @@ -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 <name>` 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
Expand Down Expand Up @@ -252,8 +257,8 @@ mcp-serve-local # from your repo root

Each toolset is mounted at `/<toolset>`, so `/<toolset>/mcp` and
`/<toolset>/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
Expand Down Expand Up @@ -354,7 +359,7 @@ user's call:
| --- | --- |
| the module imports, and exports a non-empty `TOOLS` | `load_tools` |
| every entry is a LangChain `BaseTool` | `load_tools` |
| every tool returns a `ToolResult`, with a required `message` | `to_fastmcp` |
| every tool returns a `ToolResult`, with a required `message` | `to_mcp_tool` |
| `CREDENTIAL_HEADERS` is a list of header names | `load_credential_headers` |
| every `VIEWS` entry names a real tool, with a built bundle on disk | `load_views` |
| every `NotAuthored` sits on a parameter that exists | `with_state_meta` |
Expand Down Expand Up @@ -596,12 +601,14 @@ Three things it does that a loop of your own would have to get right:

### 4b. Wiring it into your own agent

Only needed if you are *not* using the bundled agent. Needs the `[state]` extra.
Only needed if you are *not* using the bundled agent. Needs the `[agent]` extra:
`with_credential_support` comes from `mcp_agent`, and it is what builds one
client per connection.
Three pieces, all three required:

```python
from langchain.agents import create_agent
from langchain_mcp_adapters.client import MultiServerMCPClient
from mcp_agent.main import list_tools, with_credential_support
from mcp_state import (
SESSION_STATE_PROMPT,
StateCaptureMiddleware,
Expand All @@ -614,13 +621,15 @@ from mcp_state import (
)

# Loaded per server, so each tool records where it came from. The adapter
# takes a `server_name` and stamps it nowhere; without this an undeclared
# capture cannot be keyed `<toolset>/<tool>/<field>` 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 `<toolset>/<tool>/<field>` like a declared
# one. `with_credential_support` builds one client per connection, and
# `list_tools` gives each call a session of its own — through a shared one, a
# call that overlaps another user's would carry that user's credentials.
tools = [
with_server_name(tool, server)
for server in connections
for tool in await client.get_tools(server_name=server)
for server, client in with_credential_support(connections, None).items()
for tool in await list_tools(client)
]
published = publications(tools)

Expand Down Expand Up @@ -1452,3 +1461,75 @@ If your repo currently vendors `packages/mcp-runtime`, `packages/mcp-cli`,

Imports don't change, so application code is untouched — this is a dependency and
build-wiring change only.

---

## 7. Upgrading from 0.10 to 0.11 (mcp 2)

0.11 moves to **mcp 2** and off `langchain-mcp-adapters`, which was retired in
September. Its successor, `langchain[mcp]`, needs fastmcp 4, which needs mcp 2,
so the two move together. What that touches depends on which persona you are.

```bash
uv lock --upgrade-package mcp-toolsets-runtime
```

A `mcp<2` bound of your own blocks that resolve; the runtime now pins
`mcp>=2.3,<3`. Drop `langchain-mcp-adapters` from your dependencies if you had
it directly.

**Everyone: a raised exception no longer reaches the model.** mcp 1 sent a
tool's exception text to the client. mcp 2 sends `Error executing tool <name>`
and keeps the text in the server log. A tool that *raises* to tell the model
something — a store that is down, a quota exceeded — now tells it nothing.
Return a `ToolError` arm instead ([2](#2-author-a-toolset-the-plugin-contract)),
or raise `mcp.server.mcpserver.exceptions.ToolError`, which keeps its message.
`MissingCredentialError` already does. Grep your tools for `raise` on paths the
model is meant to read.

**Everyone: a loopback server checks the `Host` header.** mcp 2 turns on
DNS-rebinding protection for a server built for `127.0.0.1` or `localhost`:
a request addressed by any other name gets a 421. `mcp-serve` and
`mcp-serve-local` default to `127.0.0.1`, so a container port-forward or a
`host.docker.internal` URL that worked before now needs `HOST=0.0.0.0`. The
Helm chart sets that already. `build_local_app` takes `host` for the same
reason: pass the address you serve on.

**Persona 3, your own agent:** `MultiServerMCPClient` is gone. The replacement
is one fastmcp `Client` per connection from `with_credential_support`, with
tools loaded through `list_tools`, which opens a session per call so overlapping
users keep their own credentials; the code is in [4b](#4b-wiring-it-into-your-own-agent).
Three things it changes around that:

- A tool's `_meta` is nested under `metadata["mcp"]["tool"]`. Read it with
`mcp_state.tool_meta(tool)`, never `metadata["_meta"]`: the old
read finds nothing and fails nothing, which turns off `NotAuthored`,
publications and views silently.
- Outbound MCP calls go over **httpx2**, mcp 2's HTTP stack. An event hook, a
mock (`respx`) or a timeout typed against `httpx` does not see them.
`credential_client_factory` builds an `httpx2.AsyncClient`, and a factory of
your own must accept `**kwargs`: fastmcp passes `follow_redirects`.
- `McpError` is `MCPError`. Loading MCP tools needs the `[agent]` extra,
which brings `langchain[mcp]`, `fastmcp` and `httpx2`; the old adapter came
with the base package.

**Serving a built server yourself:** `build_server` returns a `ToolsetServer`,
an `MCPServer` that remembers its host, path and stateless transport, so
`streamable_http_app()` and `run()` give what they gave before. A raw `FastMCP`
of your own is `mcp.server.mcpserver.MCPServer`, and the SDK's wire fields are
snake_case now (`input_schema`, `output_schema`, `is_error`,
`structured_content`, `mime_type`). `mcp_runtime.fastmcp_output.to_fastmcp` is
`mcp_runtime.bridge.to_mcp_tool`.

**Toolset authors:** nothing to rewrite. Tools, `ToolResult`, `VIEWS`,
`CREDENTIAL_HEADERS` and `NotAuthored` are written the same way; only the
exception point above applies.

A grep that finds the rest:

```bash
grep -rn 'langchain_mcp_adapters\|MultiServerMCPClient\|McpError\|mcp.server.fastmcp\|fastmcp_output\|\["_meta"\]' --include='*.py' .
```

Then run the contract tests ([2](#testing-the-contract)) and smoke-test a
toolset server and the agent.
8 changes: 4 additions & 4 deletions docs/SESSION-STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<tool>/<field>`.

**Nothing knows what a value means.** Only its name says. A tool handed a
Expand Down
2 changes: 1 addition & 1 deletion examples/agui-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions examples/agui-events/service/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@

import logging

from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.checkpoint.memory import InMemorySaver

from mcp_agent.main import BuiltAgent, with_session_state
from mcp_agent.main import (
BuiltAgent,
list_tools,
with_credential_support,
with_session_state,
)
from mcp_state import with_server_name
from service import model, servers

Expand All @@ -33,13 +37,12 @@ async def build() -> BuiltAgent:
logger.info("connected %d MCP server(s)", len(connections))

# Loaded per server so each tool records where it came from: the adapter
# takes a `server_name` and stamps it nowhere, and an undeclared capture
# needs it to be keyed <toolset>/<tool>/<field> like a declared one.
client = MultiServerMCPClient(connections)
# stamps the serving server on metadata, not in the form an undeclared
# capture needs — keyed <toolset>/<tool>/<field> like a declared one.
tools = [
with_server_name(tool, server)
for server in connections
for tool in await client.get_tools(server_name=server)
for server, client in with_credential_support(connections, None).items()
for tool in await list_tools(client)
]
chat, named = model.build()

Expand Down
2 changes: 1 addition & 1 deletion examples/agui-events/service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions examples/agui-events/service/servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion examples/session-state/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 14 additions & 10 deletions examples/session-state/demo.py
Original file line number Diff line number Diff line change
@@ -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**.
Expand Down Expand Up @@ -39,9 +39,12 @@
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.utils.function_calling import convert_to_openai_tool
from langchain_mcp_adapters.client import MultiServerMCPClient
from mcp_agent.main import list_tools, with_credential_support

from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, PRODUCES_META_KEY
from mcp_runtime.declarations import (
NOT_AUTHORED_META_KEY,
PRODUCES_META_KEY,
)
from mcp_runtime.server import build_server
from mcp_state import (
StateCaptureMiddleware,
Expand All @@ -52,6 +55,7 @@
owners,
publications,
state_keys,
tool_meta,
with_server_name,
)

Expand All @@ -63,7 +67,8 @@
from foreign_server import mcp as foreign_mcp # noqa: E402 (needs the path above)

# The MCP SDK and httpx log every request at INFO, which buries the report.
for noisy in ("mcp", "httpx", "uvicorn", "sse_starlette"):
# `httpx2` is the client mcp 2 uses, and logs under its own name.
for noisy in ("mcp", "httpx", "httpx2", "uvicorn", "sse_starlette"):
logging.getLogger(noisy).setLevel(logging.WARNING)
logging.basicConfig(level=logging.WARNING)

Expand Down Expand Up @@ -223,20 +228,19 @@ async def main() -> None:
for port in ports.values():
await wait_for(port)

client = MultiServerMCPClient(connections)
# Stamped with where each came from, so an undeclared capture is keyed the
# same three-part way a declared one is. The adapter takes a `server_name`
# and records it nowhere, so a host that wants it does this itself.
# same three-part way a declared one is. The adapter records the serving
# server on the tool's metadata, not in that form, so a host does it here.
tools = [
with_server_name(tool, server)
for server in connections
for tool in await client.get_tools(server_name=server)
for server, client in with_credential_support(connections, None).items()
for tool in await list_tools(client)
]
published = publications(tools)

rule("1. What each server declared, over the wire")
for tool in sorted(tools, key=lambda t: t.name):
meta = (tool.metadata or {}).get("_meta") or {}
meta = tool_meta(tool)
label = tool.name
for declaration in meta.get(PRODUCES_META_KEY, []):
print(f" {label:19} publishes {declaration['stateKey']}")
Expand Down
6 changes: 3 additions & 3 deletions examples/session-state/foreign_server.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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):
Expand Down
19 changes: 15 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ requires-python = ">=3.12,<3.14"
license = { file = "LICENSE" }
dependencies = [
# mcp_runtime
"mcp<2.0.0,>=1.27.2",
"langchain-mcp-adapters<0.4.0,>=0.3.0",
"mcp<3.0.0,>=2.3.0",
"langchain-core<2.0.0,>=1.4.6",
"pydantic-settings<3.0.0,>=2.12.0",
"fastapi>=0.140.11,<1.0.0",
Expand Down Expand Up @@ -42,7 +41,19 @@ state = [

# mcp_agent.main (build_agent, run_turn) and mcp_agent.host, neither of which
# imports a UI framework. An API or a host of your own needs this and no more.
agent = ["mcp-toolsets-runtime[state]"]
#
# The MCP client lives in `langchain[mcp]` (it was `langchain-mcp-adapters`,
# retired in September) and reaches servers through fastmcp's transports, which
# `mcp_agent.main` constructs directly to inject per-user credential headers.
agent = [
"mcp-toolsets-runtime[state]",
"langchain[mcp]<2.0.0,>=1.4.3",
"fastmcp<5.0.0,>=4.0.11",
# mcp_agent.main types the credential client factory against httpx2, the
# HTTP stack mcp 2 moved to. Declared for the same reason httpx is above:
# a package this imports is not one it should get by accident.
"httpx2<3.0.0,>=2.13.0",
]

# mcp_agent_api: the agent over HTTP, on top of [agent].
#
Expand Down Expand Up @@ -132,7 +143,7 @@ warn_redundant_casts = true
check_untyped_defs = true

[[tool.mypy.overrides]]
module = ["langchain_mcp_adapters.*"]
module = ["fastmcp.*"]
ignore_missing_imports = true

# boto3 ships no types and the typed stubs are a large optional dependency for
Expand Down
Loading
Loading