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
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ modules, plus the view-side JS bridge:

| Module | What it is |
| --- | --- |
| `mcp_runtime` | Discovers a toolset's LangChain tools (`TOOLS`) and serves them as an MCP server; serves UI views (`VIEWS`) as `ui://` resources; derives server `instructions` from `CREDENTIAL_HEADERS`; advertises what each tool publishes into and takes from session state (`Kind`). Entry points: `mcp-serve` (one toolset), `mcp-serve-local` (several at once, for local dev), `mcp-index`. |
| `mcp_runtime` | Discovers a toolset's LangChain tools (`TOOLS`) and serves them as an MCP server; serves UI views (`VIEWS`) as `ui://` resources; derives server `instructions` from `CREDENTIAL_HEADERS`; advertises what each tool publishes into session state, and which parameters a model may not write (`NotAuthored`). Entry points: `mcp-serve` (one toolset), `mcp-serve-local` (several at once, for local dev), `mcp-index`. |
| `mcp_state` | Session state for *any* agent driving MCP tools: the `tool_state` namespace, `StateCaptureMiddleware` (moves large payloads out of the transcript), `inspect_state` (the model reads one on demand), and `bind_injected` (fills declared parameters from state, and offers `@state:<key>` handles on the rest). A filled parameter leaves a receipt, so a value the model never saw can still be traced to the tool that published it. Works against unmodified third-party servers. Requires the `[state]` extra. |
| `mcp_cli` | Typer CLI to list and call tools on a running MCP service. Entry point: `mcp-cli`. |
| `mcp_toolset` | Scaffolds a new toolset in a consumer repo (`mcp-toolset new [--with-ui] <name>`), wired to this package + the npm view bridge. |
Expand All @@ -33,16 +33,22 @@ module exporting:
- `CREDENTIAL_HEADERS` *(optional)* — header names the tools read off the
transport; used to derive the model-facing auth hint.

A tool may additionally tag a value with the `Kind` it is — on a `ToolResult`
data key to say what it publishes, on a parameter to say what it takes. The
tag is advertised in the tool's `_meta`, and lets an `mcp_state` client move a
large value — a geometry, an item collection — from the tool that produced it
to the tool that needs it *without the model generating or reading it*.
Resolution is by kind, so producer and consumer may be different toolsets on
different servers. See `mcp_runtime.kinds` for the shared vocabulary.
Every data key of a `ToolResult` — every field but `message` — is a value the
tool publishes. An `mcp_state` client captures each into session state under
`<toolset>/<tool>/<field>` and lets a later tool be pointed at it by that key,
so a large value — a geometry, an item collection — moves from the tool that
produced it to the tool that needs it *without passing through the model*.
Producer and consumer may be different toolsets on different servers; the key
is the only thing they share, which is why **a data key is a public name**.

A tool may also tag a parameter `NotAuthored`, which says only that a model
must not write the value — no type, nothing for another toolset to agree with.
An `mcp_state` client narrows that parameter until the only thing it accepts is
a reference to a value some tool already produced; a client that has never
heard of any of this is unaffected.

Keeping a value out of the context is client-side work, so an external MCP host
does none of it: served to Claude.ai or ChatGPT, a tagged toolset behaves like
does none of it: served to Claude.ai or ChatGPT, a toolset behaves like
any other. Tag for the agents that understand it, and size tool returns for the
clients that don't.

Expand All @@ -51,7 +57,7 @@ Tagging is an accelerator, not a requirement: `mcp_state` moves values across
and letting the model point a parameter at one with an `@state:<key>` handle.
What the tag buys is that the parameter leaves the model's schema entirely.

Treat `ToolResult`, `Kind`, and the `ui/*` wire protocol as **public API**. The
Treat `ToolResult`, `NotAuthored`, and the `ui/*` wire protocol as **public API**. The
state contract, worked through as sequence diagrams — including the trust
assumption it rests on — is in
**[docs/SESSION-STATE.md](./docs/SESSION-STATE.md)**, with a runnable version
Expand Down
216 changes: 101 additions & 115 deletions docs/CONSUMING.md

Large diffs are not rendered by default.

810 changes: 257 additions & 553 deletions docs/SESSION-STATE.md

Large diffs are not rendered by default.

62 changes: 45 additions & 17 deletions examples/agui-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ Ask for something the toolsets can do:

| | |
| --- | --- |
| **find rainfall datasets and clip chirps to that area** | the full turn: tool calls, both receipt paths, a publish, a `ui://` view |
| **anything about contours** | the tool the deployment cannot offer |
| **find rainfall datasets and clip chirps to that area** | the full turn: two tool calls, a publish, a receipt, a `ui://` view |
| **smooth the contours** | a `NotAuthored` parameter nothing has published for — the refusal, and the model reading it |
| **sketch a rough boundary around the Severn catchment and call it Severn** | the other side of it: an *untagged* parameter, so the model writes the polygon, and the panel shows you what it wrote |
| **a few paragraphs about anything** | enough tokens to watch them land |

Session state persists across turns on the thread, so the second time you ask
for a clip, `clip_raster` is filled from the geometry the first turn published.
for a clip, the model passes `@state:<key>` for a geometry an earlier turn
published — the payload is fetched by the panel, never re-sent.

## Is this really AG-UI?

Expand Down Expand Up @@ -93,8 +95,8 @@ keeps in it, so state moves by patch — see below.
### What a consumer has to know that the protocol does not tell it

**The `activityType` values are ours.** AG-UI standardises the envelope, not the
vocabulary. There are five — `tools.withheld`, `state.consumed`,
`state.published`, `mcp.view`, `answer.citations` — and a client wanting to
vocabulary. There are four — `state.consumed`, `state.published`, `mcp.view`,
`answer.citations` — and a client wanting to
style them switches on those strings. A client that does not know them is not
stuck: every activity carries a `display` line, generated by the same code the
bundled Chainlit host renders, so printing `content.display` is a complete
Expand All @@ -105,10 +107,10 @@ is documented rather than advertised.
**`STATE_DELTA` carries metadata, not state.** This is the sharpest
difference. AG-UI's state channel is normally the agent's actual state, and
clients render or patch it wholesale. Here each key carries only
`{kind, tool, bytes, seq}` — never the value, because the values are exactly
`{tool, bytes, seq, inputs}` — never the value, because the values are exactly
what session state exists to keep out of the conversation. Fetching one is
`GET /threads/{id}/state/{key}`, which is outside the protocol entirely. A stock
client showing "state" will show sizes and kinds and think it has everything.
client showing "state" will show sizes and think it has everything.

The metadata sits under a **`toolState`** key rather than at the root of the
state object, and every operation names a path inside it — `add` of the whole
Expand Down Expand Up @@ -193,14 +195,44 @@ each receipt where it belongs, and the server emitted it before the answer's
text message opened so it cannot land after the answer it explains.

**The heavy value is never on the wire.** The state channel carries
`{kind, tool, bytes}` per key. The right-hand panel is built from that; clicking
`{tool, bytes, inputs}` per key. The panel renders `inputs` in full — each
argument of the producing call, and whether it came from the model or from
another key. The state-sourced half is what makes the chain walkable: every key
it names is another row in the same panel, one click away. Dropping it (as the
*model-facing* listing does, where every line costs context) would leave the
panel unable to answer "what does this rest on" without scrolling back to a
call that has long gone.

`inputs` carries names and keys, never values — a model-authored argument can
be arbitrarily large, the state channel is re-sent every turn, and nothing
filters an argument the way `BLOCKED_KEY_PATTERN` filters a captured field. So
the value is not on the wire.

The panel shows it anyway, because it does not need the wire to: it joins
`state.published`'s `toolCallId` back to the call in the transcript it already
holds (`producedArguments` in `src/chat.tsx`), and reads the argument off that.
A value sits on a chip after an `=`, against the `←` that marks one which came
from another key: both halves are monospace, so without the marks
`dataset_id chirps` reads as a single token. Long values fold into a
`<details>` — `sketch_area` is in the deployment
precisely so there is one to fold, since a model asked to draw a boundary
writes a few hundred characters of polygon into an untagged parameter, which is
the case worth seeing and the one that would otherwise fill the panel.

It normalises what it stores rather than echoing it — closing the ring,
rounding, and stamping the name — which is what any real geometry tool does
and what keeps the card from showing the same polygon as both its value and
its input. It is also the case that defeats inferring provenance from a
*return*: the normalised boundary looks derived, the same tool without the
rounding looks like a passthrough, and the record is of what the call was
given precisely so nothing has to tell those apart. The right-hand panel is built from that; clicking
a key fetches `GET /threads/{id}/state/{key}` and shows the 39 kB geometry that
the transcript never held.

**A minimal client can print `display` and stop.** Every activity carries a
rendered line beside its fields, generated by the same code the bundled Chainlit
host uses, so the wire and the bundled UI cannot drift. `src/chat.tsx` prints it;
a client with opinions reads `via`, `kind` and `key` instead.
a client with opinions reads `key` and `tool` instead.

**No CORS anywhere.** Vite proxies `/api` to the Python process, so the browser
sees one origin. CORS belongs to `mcp_agent_api.app`, not to the router.
Expand All @@ -217,11 +249,12 @@ sees one origin. CORS belongs to `mcp_agent_api.app`, not to the router.
| `service/servers.py` | the four MCP servers — the example's stand-in for a deployed index |
| `service/settings.py` | one `BaseSettings`, read once |
| `toolsets/clip_view` | the session-state example's `clip_raster`, plus `VIEWS` so `mcp.view` has a real `ui://` to report |
| `toolsets/contour_ops` | declares a kind nothing publishes, so it is withheld |
| `toolsets/contour_ops` | takes a value nothing here publishes, so its calls are refused |
| `toolsets/sketch_ops` | leaves a structured parameter open, so the model writes the value itself |

The other two servers are [`examples/session-state`](../session-state)'s,
imported off the path: `dataset-search`, which publishes a 38 kB AOI tagged with
a `Kind`, and `terrain`, a raw FastMCP server that declares nothing at all.
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.

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 All @@ -233,11 +266,6 @@ without expanding anything.
Both of these were live bugs found by this example rather than by reading the
code, which is the argument for keeping it runnable:

- **`agui_events(withheld=…)` was annotated `Sequence[str]`** while its
docstring took `BuiltAgent.withheld`, a `list[Unsatisfiable]`. The `display`
join raised, the blanket `except` turned it into a `RUN_ERROR` before the
first tool ran, and a deployment withholding one tool got no turn at all.
Fixed in 0.5.3.
- **A tool answering in content blocks reached the wire as a Python repr.**
`stream_turn` flattened `ToolMessage.content` with `str()`, so
`TOOL_CALL_RESULT` carried `[{'type': 'text', 'text': '…'}]`.
Expand Down
29 changes: 18 additions & 11 deletions examples/agui-events/service/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
own MCP servers first.

What comes back is read by attribute, never by position: the routes want
``.agent``, ``.connections``, ``.tools``, ``.withheld`` and ``.required``, and
the runtime's `BuiltAgent` and dss's carry those five in different orders.
``.agent``, ``.connections``, ``.tools`` and ``.required``, and the runtime's
`BuiltAgent` and dss's may carry them in different orders.
"""

import logging
Expand All @@ -17,6 +17,7 @@
from langgraph.checkpoint.memory import InMemorySaver

from mcp_agent.main import BuiltAgent, with_session_state
from mcp_state import with_server_name
from service import model, servers

logger = logging.getLogger(__name__)
Expand All @@ -31,19 +32,25 @@ async def build() -> BuiltAgent:
connections = await servers.start()
logger.info("connected %d MCP server(s)", len(connections))

tools = await MultiServerMCPClient(connections).get_tools()
# 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)
tools = [
with_server_name(tool, server)
for server in connections
for tool in await client.get_tools(server_name=server)
]
chat, named = model.build()

# `with_session_state` is the three-piece pattern docs/CONSUMING.md
# documents: the capture middleware, `bind_all_injected` reading back out,
# and `inspect_state` for a value the model was only told the key of. It
# also withholds any tool whose required parameter nothing can fill.
agent, withheld = with_session_state(chat, tools, InMemorySaver())
logger.info(
"built on %s: %d tool(s), %d withheld", named, len(tools), len(withheld)
)
# documents: the capture middleware, `bind_all_injected` rewriting schemas
# so a stored value can be named, and `inspect_state` for a value the model
# was only told the key of.
agent = with_session_state(chat, tools, InMemorySaver())
logger.info("built on %s: %d tool(s)", named, len(tools))

# An in-process checkpointer, so threads live as long as this process. A
# deployment passes a configured saver — `mcp_agent.main.Checkpointing`
# builds one from MCP_AGENT_CHECKPOINT, PostgreSQL included.
return BuiltAgent(agent, connections, tools, withheld, None)
return BuiltAgent(agent, connections, tools, None)
13 changes: 8 additions & 5 deletions examples/agui-events/service/servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
module exists so the example is one command instead of five, and it is the only
part of `service/` that a real service would not have.

Four servers on ephemeral ports:
Five servers on ephemeral ports:

``dataset-search``
publishes a 38 kB area of interest, tagged with a `Kind`
publishes a 38 kB area of interest as a `ToolResult` data key
``raster-ops``
consumes that kind, and carries a ``ui://`` view
takes one by handle, and carries a ``ui://`` view
``contour-ops``
declares a kind nothing publishes, so its tool is withheld
takes a value nothing here publishes, so its calls are refused
``sketch-ops``
leaves a structured parameter open, so the model writes the value itself
``terrain``
a raw FastMCP server that declares nothing at all
"""
Expand All @@ -30,7 +32,8 @@
TOOLSETS = {
"dataset-search": "dataset_search.tools",
"raster-ops": "clip_view.tools", # the example's clip_raster, plus a view
"contour-ops": "contour_ops.tools", # withheld: nothing publishes its kind
"contour-ops": "contour_ops.tools", # nothing here publishes what it takes
"sketch-ops": "sketch_ops.tools", # leaves its parameter open, so watch it
}

#: The one server with no idea this project exists.
Expand Down
5 changes: 2 additions & 3 deletions examples/agui-events/toolsets/clip_view/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
from typing import Annotated, Any, NotRequired

from langchain_core.tools import tool
from mcp_runtime.declarations import Kind
from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST
from mcp_runtime.declarations import NotAuthored
from mcp_runtime.tool_result import ToolError, ToolResult


Expand All @@ -42,7 +41,7 @@ def _rings(geometry: dict[str, Any]) -> list[list[list[float]]]:
@tool
async def clip_raster(
dataset_id: str,
aoi: Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST, model_generatable=False)],
aoi: Annotated[dict, NotAuthored()],
) -> ClipResult | ToolError:
"""Clip a dataset to the area of interest currently in play."""
features = aoi.get("features", [])
Expand Down
2 changes: 1 addition & 1 deletion examples/agui-events/toolsets/contour_ops/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"""A toolset nothing can satisfy — the source of the `tools.withheld` activity."""
"""A toolset nothing in this deployment can satisfy yet."""
17 changes: 11 additions & 6 deletions examples/agui-events/toolsets/contour_ops/tools.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
"""A tool whose declared parameter nothing connected publishes.
"""A tool whose value nothing in this deployment has produced yet.

`geojson.ContourSet` is a kind no server here produces, and the tool says a
model must not invent one — so `partition_usable` withholds it, which is what
`tools.withheld` announces to a client.
`smooth_contours` says a model may not write its `contours`, and no server
here publishes a contour set — so the tool is offered, the model calls it, and
the binding refuses with a message naming what session state actually holds.

That refusal is the whole demonstration. Nothing is hidden from the model and
no tool is taken away at connect: the deployment cannot know in advance what
will have run by the time a call is made, so it lets the call happen and
answers it with something the model can act on.
"""

from typing import Annotated

from langchain_core.tools import tool

from mcp_runtime.declarations import Kind
from mcp_runtime.declarations import NotAuthored
from mcp_runtime.tool_result import ToolResult


@tool
async def smooth_contours(
contours: Annotated[dict, Kind("geojson.ContourSet", model_generatable=False)],
contours: Annotated[dict, NotAuthored()],
) -> ToolResult:
"""Smooth a contour set nobody in this deployment can produce."""
return ToolResult(message=f"Smoothed {len(contours.get('features', []))} contours.")
Expand Down
1 change: 1 addition & 0 deletions examples/agui-events/toolsets/sketch_ops/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""A toolset that lets a model write the value, so you can watch it do it."""
Loading