From 0dc0c4be8660ace6d6a1afb5f905fabb3fe04929 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Fri, 16 Oct 2026 14:03:55 +0100 Subject: [PATCH 1/5] docs: say what releases, keep the npm lock's version in step release-please now bumps js/mcp-view/package-lock.json too. CONTRIBUTING says which commit types release, and the README covers AGENTS.md from --install and the 503 while the agent is not built. docs/proposals/ is ignored. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +++ CONTRIBUTING.md | 32 ++++++++++++++++++-------------- README.md | 5 ++++- js/mcp-view/package-lock.json | 4 ++-- release-please-config.json | 10 ++++++++++ 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 065a7ce..074bef3 100644 --- a/.gitignore +++ b/.gitignore @@ -223,3 +223,6 @@ js/**/dist/ # The web client js/agent-ui builds into the Python package. Generated, and # put into the wheel by hatch_build.py rather than tracked here. src/mcp_agent_api/ui/ + +# Local design notes, never published +docs/proposals/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51061c1..ff49132 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,17 +17,21 @@ Every PR title must be a valid The `pr-title` check fails the PR if the type is not one of these: -| Type | Use for | -| --- | --- | -| `feat` | a new capability | -| `fix` | a bug fix | -| `perf` | a performance improvement | -| `refactor` | an internal change with no behaviour change | -| `docs` | documentation only | -| `test` | tests only | -| `build` | the build system or dependencies | -| `ci` | CI configuration | -| `chore` | anything else | +| Type | Use for | Releases | +| --- | --- | --- | +| `feat` | a new capability | yes | +| `fix` | a bug fix | yes | +| `perf` | a performance improvement | yes | +| `refactor` | an internal change with no behaviour change | no | +| `docs` | documentation only | **yes** | +| `test` | tests only | no | +| `build` | the build system or dependencies | no | +| `ci` | CI configuration | no | +| `chore` | anything else | no | + +release-please counts `docs` as user-facing, so a docs commit cuts a release +on its own (0.9.2 came from two). A `build` commit, so every dependency bump, +waits for the next release something else cuts. Mark a **breaking change** with a `!`: @@ -68,9 +72,9 @@ a range they already allow. ## The release flow 1. Merge PRs with conventional titles. -2. release-please keeps a rolling release PR. It bumps `pyproject.toml` and - `js/mcp-view/package.json`, which the linked-versions plugin keeps equal, - and updates `CHANGELOG.md`. **That PR is editable**, so curate the changelog +2. release-please keeps a rolling release PR. It bumps `pyproject.toml`, + `js/mcp-view/package.json` and its lock, which the linked-versions plugin + keeps equal, and updates `CHANGELOG.md`. **That PR is editable**, so curate the changelog before merging if a release warrants it. 3. Merging the release PR tags `vX.Y.Z` and cuts a GitHub Release. That publishes the wheel to PyPI and the bridge to npm. diff --git a/README.md b/README.md index 2902fa5..dd87708 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ The four read routes serve what the stream deliberately leaves out. `GET /connections` is what a client needs before there is a conversation at all. +Every route answers `503` while the agent is not built: whenever the provider +raises, as it does while a lifespan is still connecting to MCP servers. + **`mcp_agent_api.app`** closes the stack for a deployment with no application of its own. `create_app(build=…)` wraps those routes in a lifespan, a checkpointer, CORS and two health probes. A module-level `app` means @@ -134,7 +137,7 @@ checkpointer, CORS and two health probes. A module-level `app` means ### The toolset plugin contract Writing a toolset with a coding agent? `uv run mcp-toolset skill --install` -puts the authoring skill in the repo. It covers this section as a procedure, +puts the authoring skill in the repo and points `AGENTS.md` at it. It covers this section as a procedure, and adds a mapping table for porting an existing codebase into tools. It ships in the wheel, so it matches the pinned version. diff --git a/js/mcp-view/package-lock.json b/js/mcp-view/package-lock.json index 92ae8f4..f7f90b3 100644 --- a/js/mcp-view/package-lock.json +++ b/js/mcp-view/package-lock.json @@ -1,12 +1,12 @@ { "name": "@developmentseed/mcp-view", - "version": "0.10.3", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@developmentseed/mcp-view", - "version": "0.10.3", + "version": "0.11.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/ext-apps": "^2.0.0" diff --git a/release-please-config.json b/release-please-config.json index 63c04d9..dbaea8a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -13,6 +13,16 @@ "path": "js/mcp-view/package.json", "jsonpath": "$.version" }, + { + "type": "json", + "path": "js/mcp-view/package-lock.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "js/mcp-view/package-lock.json", + "jsonpath": "$.packages[''].version" + }, { "type": "toml", "path": "uv.lock", From 6c7019e315a9d326eef0b961bf8e6c040ad2e521 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Fri, 16 Oct 2026 14:15:32 +0100 Subject: [PATCH 2/5] docs: plain English for CONSUMING and SESSION-STATE A second pass after #139: no em-dashes in prose, no sentence over 40 words, and three corrections. SESSION-STATE has four scenarios, not six; the web client speaks eight routes, not seven; and the README named the state route without its key. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +- docs/CONSUMING.md | 1091 ++++++++++++++++++++++------------------- docs/SESSION-STATE.md | 484 +++++++++--------- 3 files changed, 841 insertions(+), 752 deletions(-) diff --git a/README.md b/README.md index dd87708..e494a28 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,11 @@ routes, and an eighth, `GET /threads`, when you pass `owner`: | `GET /threads/{id}` | the thread's transcript | | `GET /threads/{id}/idle` | answers once no run holds the thread | | `GET /threads/{id}/turns` | its turns, with the state each ended holding | -| `GET /threads/{id}/state` | a session-state payload in full; `?turn=N` for the value as it stood then | +| `GET /threads/{id}/state/{key}` | one session-state value in full; `?turn=N` for the value as it stood then | | `GET /views/...` | a `ui://` view bundle | | `GET /connections` | what the agent connected to, and which credential headers it wants | -The four read routes serve what the stream deliberately leaves out. +The read routes serve what the stream deliberately leaves out. `GET /connections` is what a client needs before there is a conversation at all. @@ -144,11 +144,11 @@ in the wheel, so it matches the pinned version. `mcp_runtime` finds a toolset by convention. Given the name `my-toolset` it imports `my_toolset.tools` and reads three module-level exports: -- **`TOOLS`** (required) — a non-empty list of LangChain tools that return a +- **`TOOLS`** (required): a non-empty list of LangChain tools that return a `ToolResult`. -- **`VIEWS`** (optional) — `{tool_name: view_id}`, with a built bundle at +- **`VIEWS`** (optional): `{tool_name: view_id}`, with a built bundle at `/views/.html`. -- **`CREDENTIAL_HEADERS`** (optional) — header names the tools read off the +- **`CREDENTIAL_HEADERS`** (optional): header names the tools read off the transport, used to derive the model-facing auth hint. **A data key is a public name.** Every field of a `ToolResult` except @@ -180,12 +180,12 @@ Treat `ToolResult`, `NotAuthored` and the `ui/*` wire protocol as public API. Further reading: -- [docs/SESSION-STATE.md](./docs/SESSION-STATE.md) — the state contract as - sequence diagrams, including the trust assumption it rests on. -- [examples/session-state/](./examples/session-state/) — a runnable version of +- [docs/SESSION-STATE.md](./docs/SESSION-STATE.md): the state contract, with + diagrams and four worked scenarios, including the trust assumption it rests on. +- [examples/session-state/](./examples/session-state/): a runnable version of the whole thing, a third-party server included. Run `uv run python examples/session-state/demo.py`; no API key needed. -- [examples/agui-events/](./examples/agui-events/) — the same machinery over +- [examples/agui-events/](./examples/agui-events/): the same machinery over HTTP, driven by the client the wheel ships. Tokens stream, tool calls and receipts arrive in order, and a state panel fetches values rather than receiving them on the wire. diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index 3dc1a70..589d1e0 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -42,8 +42,8 @@ is yours to decide. The two bundled entry points, `mcp-agent` and `create_app`'s default factory, read `PROVIDER_MODEL` and `PROVIDER_API_KEY` from the environment or a `.env` at startup. -Either way a hosted deployment holds a provider key and every visitor's turn -spends it, so put authentication in front of anything you expose. Supply your +Either way, a hosted deployment holds a provider key and every visitor's turn +spends it. So put authentication in front of anything you expose. Supply your own `build=` factory if you want the key to come from somewhere else. The package ships no provider package, so install the one you need at @@ -59,7 +59,7 @@ for the hosted chat, see `Dockerfile.chat` and `charts/mcp-chat` in [![PyPI](https://img.shields.io/pypi/v/mcp-toolsets-runtime?label=PyPI)](https://pypi.org/project/mcp-toolsets-runtime/) -It's on PyPI — an ordinary dependency, no source override: +It's on PyPI, so it is an ordinary dependency with no source override: ```toml # pyproject.toml @@ -75,20 +75,20 @@ dependencies = [ uv lock && uv sync ``` -Imports are unchanged from the old in-repo workspace packages — `from -mcp_runtime.server import build_server`, `from mcp_agent.main import ...`, etc. -`uv.lock` pins the exact version that resolved, so that — not a number in this -document — is what makes your builds reproducible. **Upgrade** with `uv lock ---upgrade-package mcp-toolsets-runtime`. +Imports are the same as in the old in-repo workspace packages: +`from mcp_runtime.server import build_server`, `from mcp_agent.main import ...`, +and so on. `uv.lock` pins the exact version that resolved. That pin, not a +number in this document, is what makes your builds reproducible. **Upgrade** +with `uv lock --upgrade-package mcp-toolsets-runtime`. 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`. 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]`). -The HTTP API has no script of its own — it is an ASGI application, served with +The console scripts are `mcp-serve`, `mcp-serve-local`, `mcp-index` and +`mcp-cli` (all in the base install), and `mcp-agent` (needs `[agent]`). The HTTP +API has no script of its own. It is an ASGI application, served with `uvicorn mcp_agent_api.app:app` (needs `[api]`). --- @@ -132,10 +132,10 @@ it in the workspace: mcp-toolset new my-toolset # or: mcp-toolset new my-toolset --with-ui ``` -It also writes a **deployment config file** — the one your deployment reads for +It also writes a **deployment config file**: the one your deployment reads for that toolset's secrets, size and variables. That file belongs to your repo, not -to this package: it names your directories and its shape follows whatever reads -it. So declare it, and the scaffolder copies your own template: +to this package. It names your directories, and its shape follows whatever +reads it. So declare it, and the scaffolder copies your own template: ```toml # your repo's root pyproject.toml @@ -146,21 +146,24 @@ deployment-config = [ ] ``` -`path` is where the file lands inside the new toolset; `template` is a file in +`path` is where the file lands inside the new toolset. `template` is a file in your repo, copied with `__NAME__` and `__PKG__` substituted. An empty list means -a new toolset gets no deployment file at all. A template that is missing is an -error rather than a warning, because the alternative is a toolset scaffolded -without the one file its deployment reads. +a new toolset gets no deployment file at all. A missing template is an error +rather than a warning, because the alternative is a toolset scaffolded without +the one file its deployment reads. -Declare nothing and it falls back to guessing from your layout: `toolset.yaml` -(Helm values) beside a `charts/` directory, `toolset.aws.yaml` beside an -`infra/` one, both if you have both, and the Helm file if you have neither. -That is what every consumer got before this was configurable, and it is enough -for a repo laid out the way this package's own template is. +Declare nothing and it guesses from your layout: + +- `toolset.yaml` (Helm values) if there is a `charts/` directory; +- `toolset.aws.yaml` if there is an `infra/` one; +- both if you have both, and the Helm file if you have neither. + +Every consumer got that before this was configurable. It is enough for a repo +laid out the way this package's own template is. The rest of this section is what that scaffold contains. `mcp_runtime` discovers -a toolset by convention. Given `TOOLSET=my-toolset`, it imports `my_toolset.tools` -and reads module-level exports: +a toolset by convention. Given `TOOLSET=my-toolset`, it imports +`my_toolset.tools` and reads module-level exports: ```python # toolsets/my-toolset/src/my_toolset/tools.py @@ -178,7 +181,7 @@ class SearchResult(ToolResult): @tool async def search(query: str) -> SearchResult | ToolError: - """One-line docstring — becomes the tool's MCP description (required).""" + """One-line docstring. It becomes the tool's MCP description (required).""" return SearchResult(message=f"results for {query}", datasets=[...]) @@ -187,38 +190,40 @@ TOOLS = [search] # required: non-empty list of tools # CREDENTIAL_HEADERS = ["X-My-Token"] # optional: headers read off the transport ``` -- **`TOOLS`** — every tool must return a `ToolResult` (its annotations become the - MCP output schema; `build_server` rejects tools that don't at startup). - Declare them `async def`: a tool that does I/O has to be, and the runtime - hands a sync one to a thread pool, at a thread per call. Keep `def` for pure - computation. -- **`ToolError`** — the failure half of the same contract: an `error` kind +- **`TOOLS`**: every tool must return a `ToolResult`. Its annotations become + the MCP output schema, and `build_server` rejects a tool that doesn't at + startup. Declare tools `async def`. A tool that does I/O has to be, and the + runtime hands a sync one to a thread pool, at a thread per call. Keep `def` + for pure computation. +- **`ToolError`**: the failure half of the same contract, with an `error` kind and a `detail` the model reads. Union it into the return annotation of any 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 + rather than raising. + + Two failures stay exceptions: a missing credential (`MissingCredentialError`, which tells the *caller* to send a header) and a - 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 ` and nothing - more, with the traceback in the server log. A failure the model should read - is a returned `ToolError`, or a raised + bug in the tool. The model sees the first one's message but not the second's. + A raised exception reaches it as `Error executing tool ` and nothing + more, with the traceback in the server log. So 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 - credential never enters the model context. -- **`VIEWS`** — see [UI views](#3-ui-views-rendered-by-any-mcp-apps-host). - -A tool may also tag a parameter `NotAuthored` — see -[Session state](#4-session-state-keeping-large-values-out-of-the-model). Which +- **`CREDENTIAL_HEADERS`**: names of headers the tools read off the transport. + The runtime derives the server `instructions` from these, so the model is + told that a credential rides the connection and it shouldn't ask the user for + it. The credential never enters the model context. +- **`VIEWS`**: see [UI views](#3-ui-views-rendered-by-any-mcp-apps-host). + +A tool may also tag a parameter `NotAuthored` (see +[Session state](#4-session-state-keeping-large-values-out-of-the-model)). Which half of that is optional depends on which end you control: - **The tag is optional.** An agent built on `mcp_state` keeps large values out - of the model's context whether or not you use it; tagging makes it cheaper and + of the model's context whether or not you use it. Tagging makes it cheaper and safer. -- **The agent is not.** Keeping a value out of the context is entirely - client-side work. Serve the same toolset to Claude.ai, ChatGPT, or any other - MCP client and none of it happens — the tag is advertised in `_meta`, nothing - reads it, and large values land in the transcript as they always would. +- **The agent is not.** Keeping a value out of the context is entirely the + client's work. Serve the same toolset to Claude.ai, ChatGPT or any other MCP + client and none of it happens. The tag is advertised in `_meta`, nothing reads + it, and large values land in the transcript as they always would. So tag for the benefit of agents that understand it, and size your tool returns on the assumption that a client might not. @@ -230,23 +235,23 @@ TOOLSET=my-toolset mcp-serve # serves this toolset's tools over MCP ``` `HOST` and `PORT` say where it listens. `MCP_PATH_PREFIX` says where it -*answers*: unset, the endpoint is `/mcp` and health is `/health`; set to +*answers*. Unset, the endpoint is `/mcp` and health is `/health`. Set to `/my-toolset`, they move to `/my-toolset/mcp` and `/my-toolset/health`. -Reach for it when many toolsets share one domain and whatever routes them -cannot rewrite a path away. A Kubernetes Ingress can, which is why the charts -strip `/` and the container has always seen `/mcp`. An AWS -Application Load Balancer cannot: a forward action passes on the path it -received, so each toolset has to answer on its own. +Use it when many toolsets share one domain and whatever routes them cannot +rewrite a path away. A Kubernetes Ingress can, which is why the charts strip +`/` and the container has always seen `/mcp`. An AWS Application Load +Balancer cannot. A forward action passes on the path it received, so each +toolset has to answer on its own. -`/health` keeps answering at the root as well as under the prefix. The two -have different callers — a load balancer's health check and `mcp-index` reach -the container directly, below whatever routing added the prefix, while the +`/health` keeps answering at the root as well as under the prefix, because the +two have different callers. A load balancer's health check and `mcp-index` +reach the container directly, below whatever routing added the prefix. The prefixed one is what the published URL promises a person. ### Serving all of them at once, locally -`mcp-serve` is one toolset per process, which is what production wants — every +`mcp-serve` runs one toolset per process, which is what production wants: every toolset its own service, behind one entry point, with `mcp-index` presenting them as one directory. Locally you have neither, so `mcp-serve-local` builds that same shape in a single process: @@ -256,30 +261,35 @@ mcp-serve-local # from your repo root ``` Each toolset is mounted at `/`, so `//mcp` and -`//health` are the paths production serves, and `/` returns the same +`//health` are the paths production serves. `/` returns the same directory document `mcp-index` returns. `mcp-cli`, `mcp-agent` and any MCP -client all consume that already: +client already consume it: ```bash mcp-cli call forecast city=Lisbon --url http://localhost:8000/weather/mcp mcp-agent chat http://localhost:8000/ # every toolset, one agent ``` -With no `TOOLSETS` set it serves every directory under `./toolsets` that has a -`pyproject.toml` — the layout `mcp-toolset new` produces. That only supplies the -*names*; each one is still resolved by importing `.tools`, so they must be -installed in the environment you run from. Name them explicitly (`TOOLSETS=a,b`) -and the directory is never read, which is what to do if your toolsets live -somewhere else — or point `TOOLSETS_DIR` at them. `HOST` and `PORT` work as they -do for `mcp-serve`. +With no `TOOLSETS` set, it serves every directory under `./toolsets` that has a +`pyproject.toml`, which is the layout `mcp-toolset new` produces. That only +supplies the *names*. Each one is still resolved by importing `.tools`, so +they must be installed in the environment you run from. + +If your toolsets live somewhere else, either name them (`TOOLSETS=a,b`), in +which case the directory is never read, or point `TOOLSETS_DIR` at them. `HOST` +and `PORT` work as they do for `mcp-serve`. ### Serving the directory in production (`mcp-index`) -`mcp-index` is the deployed counterpart of that `/` route: one service beside -the toolsets that asks the platform which of them are **running**, asks each -one's `/health` for its tool names, and serves the aggregate. It asks every -time rather than reading a list fixed at deploy time, so a toolset that failed -to start is absent from the directory instead of advertised. +`mcp-index` is the deployed counterpart of that `/` route. It is one service +beside the toolsets that: + +1. asks the platform which toolsets are **running**; +2. asks each one's `/health` for its tool names; +3. serves the aggregate. + +It asks every time rather than reading a list fixed at deploy time. So a toolset +that failed to start is absent from the directory instead of advertised. ```bash PUBLIC_URL=https://mcp.example.com mcp-index @@ -290,17 +300,17 @@ PUBLIC_URL=https://mcp.example.com mcp-index | `PUBLIC_URL` | *required* | External base URL the toolsets are served under | | `HOST` / `PORT` | `127.0.0.1` / `8000` | Where the index itself listens | | `MCP_INDEX_DISCOVERY` | `kubernetes` | `kubernetes` or `ecs` | -| `MCP_ECS_CLUSTER` | — | Cluster to list; required when discovery is `ecs` | +| `MCP_ECS_CLUSTER` | none | Cluster to list; required when discovery is `ecs` | | `MCP_ECS_TOOLSET_PORT` | `8000` | Port to address a toolset on when its registration names none | -**`kubernetes`** lists Services in the index's own namespace carrying the +**`kubernetes`** lists Services in the index's own namespace that carry the `mcp-toolsets/toolset` label, and addresses each by its Service name. It needs nothing installed and no credentials beyond the pod's service account, which must be allowed to `get` and `list` services in that namespace. -**`ecs`** lists services on `MCP_ECS_CLUSTER` tagged `mcp-toolsets/toolset`, -and addresses each by the Cloud Map registration ECS made for it — so a service -must be both tagged and registered to appear. Install the extra +**`ecs`** lists services on `MCP_ECS_CLUSTER` tagged `mcp-toolsets/toolset`, and +addresses each by the Cloud Map registration ECS made for it. So a service must +be both tagged and registered to appear. Install the extra (`mcp-toolsets-runtime[aws]`) and give the index's task role: ``` @@ -314,12 +324,12 @@ rather than serving an empty directory with a 200. ### How the runtime finds your toolset Discovery is a **Python import**, not a directory scan. `TOOLSET=` is -converted kebab→snake and the runtime imports `.tools` (e.g. -`my-toolset` → `import my_toolset.tools`). So the one requirement is that your -toolset package is **installed in the environment** you serve from — on the -Python import path, not at any particular folder. `mcp-toolset new` handles this -by running `uv add`, which adds the toolset to your deps so `uv sync` installs it -editable. +converted from kebab case to snake case, and the runtime imports `.tools` +(`my-toolset` becomes `import my_toolset.tools`). So the one requirement is +that your toolset package is **installed in the environment** you serve from. +It has to be on the Python import path, not in any particular folder. +`mcp-toolset new` handles this by running `uv add`, which adds the toolset to +your dependencies so that `uv sync` installs it editable. A typical consumer repo: @@ -337,10 +347,10 @@ your-repo/ └── tools.py # exports TOOLS (+ optional VIEWS / CREDENTIAL_HEADERS) ``` -The `src/` layout is just the standard Python "src layout" — the folder path is -irrelevant to discovery; only the installed, importable package name is. That's -why a toolset can live wherever you like (a nested path, or even a separate -distribution) as long as it ends up on the import path. +The `src/` layout is just the standard Python "src layout". The folder path +plays no part in discovery; only the installed, importable package name does. +That's why a toolset can live wherever you like (a nested path, or even a +separate distribution) as long as it ends up on the import path. **Custom module or non-conventional layout.** To point the runtime at a module that doesn't match the `.tools` convention, set `TOOLSET_MODULE`: @@ -351,9 +361,9 @@ TOOLSET=my-toolset TOOLSET_MODULE=my_pkg.mcp_tools mcp-serve ### Testing the contract -You don't write the assertions — **`build_server` is the conformance check**. -Every clause is validated there, and each raises at startup rather than on a -user's call: +You don't write the assertions: **`build_server` is the conformance check**. +Every clause is validated there, and each one raises at startup rather than on +a user's call: | Checked | Raised by | | --- | --- | @@ -364,9 +374,9 @@ user's call: | 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` | -What the runtime *can't* ship is the **enumeration**. Discovery is by Python -import, not a directory scan, so only your repo knows which toolsets exist and -where their names come from. That makes the sweep about three lines: +What the runtime *can't* ship is the **list of toolsets**. Discovery is by +Python import, not a directory scan, so only your repo knows which toolsets +exist and where their names come from. That makes the test about three lines: ```python import pytest @@ -382,23 +392,27 @@ def test_toolset_conforms(toolset: str) -> None: `build_server` binds no port and starts nothing, so this is a fast unit test. [`mcp-toolsets`](https://github.com/developmentseed/mcp-toolsets) has a -`test_contract.py` doing exactly this over its workspace members. +`test_contract.py` that does exactly this over its workspace members. --- ## 3. UI views (rendered by any MCP Apps host) -A view is a pre-built HTML bundle the runtime serves as an MCP resource -`ui:///` (MIME `text/html;profile=mcp-app`) and stamps onto the -owning tool's `_meta`. Because it follows the **MCP Apps** standard -(`modelcontextprotocol/ext-apps`), it renders inline in **any MCP Apps host** — -Claude.ai, ChatGPT, Goose, VS Code — with no host-specific work from you: the -runtime already serves the spec MIME and speaks the `ui/*` postMessage bridge. -Views are progressive enhancement — a tool's `message` + structured content +A view is a pre-built HTML bundle. The runtime serves it as an MCP resource, +`ui:///` (MIME `text/html;profile=mcp-app`), and stamps it onto the +owning tool's `_meta`. + +Because it follows the **MCP Apps** standard +(`modelcontextprotocol/ext-apps`), it renders inline in **any MCP Apps host**, +such as Claude.ai, ChatGPT, Goose and VS Code. That takes no host-specific work +from you, because the runtime already serves the spec's MIME type and speaks +the `ui/*` postMessage bridge. + +Views are progressive enhancement. A tool's `message` and structured content still stand alone in a plain MCP client that can't render them. -For the common case (your server connected to Claude.ai / ChatGPT), you only do -**[3a](#3a-declare--build-the-bundle) + +For the common case (your server connected to Claude.ai or ChatGPT), you only do +**[3a](#3a-declare--build-the-bundle) and [3b](#3b-the-view-side-bridge-developmentseedmcp-view)**. [3c](#3c-rendering-views-in-your-own-frontend) is for when the host is a frontend of your own. @@ -406,10 +420,10 @@ frontend of your own. ### 3a. Declare + build the bundle Declare `VIEWS = {tool_name: view_id}` and ship a built bundle at -`/views/.html`. `build_server` validates the wiring at startup -(unknown tool or missing bundle aborts). How you build the bundle (Vite, etc.) is -your repo's concern — see the `toolsets/*/ui/` setup in -[`mcp-toolsets`](https://github.com/developmentseed/mcp-toolsets). +`/views/.html`. `build_server` validates the wiring at +startup: an unknown tool or a missing bundle stops it. How you build the bundle +(Vite or anything else) is your repo's concern. See the `toolsets/*/ui/` setup +in [`mcp-toolsets`](https://github.com/developmentseed/mcp-toolsets). ### 3b. The view-side bridge (`@developmentseed/mcp-view`) @@ -425,16 +439,16 @@ onData((data) => render(data)); // the tool's structuredContent, from the button.onclick = () => sendMessage("run the next thing"); // a user turn back to the chat ``` -A third thing happens with no code of yours: the bridge watches your document -and tells the host how tall it is, as the `ui/notifications/size-changed` +A third thing happens with no code of yours. The bridge watches your document +and tells the host how tall it is, using the `ui/notifications/size-changed` notification the MCP Apps protocol defines. A host that acts on it sizes your -view to its content — the bundled web client does, between 44 and 1000 pixels — -and a host that ignores it keeps whatever sizing it already had. Either way -your bundle stays the same, so do not measure or post a height yourself. +view to its content; the bundled web client does, between 44 and 1000 pixels. A +host that ignores it keeps whatever sizing it already had. Either way your +bundle stays the same, so do not measure or post a height yourself. -This is **host-agnostic** — the exact same bundle works in Claude.ai, ChatGPT, -and any host of your own. It's a public package on npm, so it needs no -registry configuration or auth, in your repo or in CI. From your `ui/` project: +This works with any host: the exact same bundle runs in Claude.ai, ChatGPT and +any host of your own. It's a public package on npm, so it needs no registry +configuration or auth, in your repo or in CI. From your `ui/` project: ```bash npm install @developmentseed/mcp-view @@ -449,16 +463,17 @@ changes your toolsets or their bundles: - **Embed with a standard MCP Apps client**, which handles the `ui/*` protocol for you. -- **Implement the host end yourself** — fetch the `ui:///` resource, - render it in an iframe, and speak the `ui/*` postMessage protocol back. - The web client this package ships does exactly that: read `js/agent-ui` - as the reference implementation. Act on `ui/notifications/size-changed` if - the frame is yours to size — the `View` component in - `js/agent-ui/src/chat.tsx` shows one way, clamped at both ends — and ignore - it if the frame is fixed. +- **Implement the host end yourself.** Fetch the `ui:///` + resource, render it in an iframe, and speak the `ui/*` postMessage protocol + back. The web client this package ships does exactly that, so read + `js/agent-ui` as the reference implementation. + + If the frame is yours to size, act on `ui/notifications/size-changed`. The + `View` component in `js/agent-ui/src/chat.tsx` shows one way, clamped at both + ends. If the frame is fixed, ignore it. Either way you do -**[3a](#3a-declare--build-the-bundle)–[3b](#3b-the-view-side-bridge-developmentseedmcp-view)**. +**[3a](#3a-declare--build-the-bundle) and [3b](#3b-the-view-side-bridge-developmentseedmcp-view)**. If your frontend also drives its own agent, add [Session state](#4-session-state-keeping-large-values-out-of-the-model). @@ -466,30 +481,33 @@ If your frontend also drives its own agent, add ## 4. Session state (keeping large values out of the model) -Some tool inputs and outputs are too large for a model to be handling: a clip +Some tool inputs and outputs are too large for a model to handle: a clip geometry, an item collection, a raster footprint. `mcp_state` moves such a value -from the tool that produced it to the tool that needs it, through your agent's -state, without it entering the conversation. +from the tool that produced it to the tool that needs it. It travels through +your agent's state and never enters the conversation. -All of this is **client-side**, and the asymmetry matters: +All of this happens in the **client**, and the difference between the two ends +matters: -- **Any server works, unmodified** — including ones that know nothing about this - runtime. [Tagging](#4c-naming-things-and-the-one-tag-worth-adding) makes it cheaper - and safer; it is not what makes it work. -- **Only an `mcp_state` client works** — the bundled agent, or your own wired up +- **Any server works, unmodified**, including ones that know nothing about this + runtime. [Tagging](#4c-naming-things-and-the-one-tag-worth-adding) makes it + cheaper and safer, but it is not what makes it work. +- **Only an `mcp_state` client works**: the bundled agent, or your own wired up as below. An external MCP host does none of it, so a toolset served to Claude.ai or ChatGPT gets no benefit from being tagged. -The full contract — two decision flowcharts and six worked scenarios — is in -[SESSION-STATE.md](./SESSION-STATE.md), with a runnable version in +The full contract, with two decision flowcharts and four worked scenarios, is in +[SESSION-STATE.md](./SESSION-STATE.md). A runnable version is in [`examples/session-state/`](../examples/session-state/). **The bundled agent has this on already.** `mcp-agent` and the HTTP API wire everything in 4b for you, so pointing either at your toolsets is the fastest way -to see tagging pay off. Set `MCP_AGENT_STATE=0` (environment or `.env`) to build -the plain agent instead — every value through the transcript, no capture, no -injection — which is what you want if your host renders tool results straight -off the message, or installs middleware of its own. +to see tagging pay off. + +Set `MCP_AGENT_STATE=0` (in the environment or `.env`) to build the plain agent +instead. Every value then goes through the transcript, with no capture and no +injection. That is what you want if your host renders tool results straight off +the message, or installs middleware of its own. It also checkpoints conversations, so both the transcript and `tool_state` belong to a `thread_id` rather than to the caller: @@ -500,19 +518,21 @@ belong to a `thread_id` rather than to the caller: | a `postgres://` URL | PostgreSQL, via the `[checkpointing-postgres]` extra | anything that restarts, or runs more than one replica | Embedding it in your own process instead? `build_agent(..., checkpointer=...)` -takes any LangGraph saver and makes no assumptions about it — configure the -connection pool, schema and lifecycle however you already do, and the -environment variable never comes into it. Omit it and each call gets a fresh -in-process saver, which is right for building one agent and wrong for building -several: two agents with separate savers cannot see each other's threads. If -you rebuild agents and expect conversations to survive, hold one `Checkpointing` -(an async context manager) and pass its `saver()` every time. +takes any LangGraph saver and makes no assumptions about it. Configure the +connection pool, schema and lifecycle however you already do; the environment +variable never comes into it. + +Omit it and each call gets a fresh in-process saver. That is right for building +one agent and wrong for building several, because two agents with separate +savers cannot see each other's threads. If you rebuild agents and expect +conversations to survive, hold one `Checkpointing` (an async context manager) +and pass its `saver()` every time. ### 4a. Extending the bundled agent -Before assembling your own (below), check whether the seams on `build_agent` -cover you. A host with its own system prompt, its own local tools, or its own -callbacks does not need to fork anything: +Before assembling your own agent (below), check whether the options on +`build_agent` cover you. A host with its own system prompt, its own local tools +or its own callbacks does not need to fork anything: ```python from mcp_state import SESSION_STATE_PROMPT @@ -522,8 +542,8 @@ built = await build_agent( model, api_key, # Replaces the bundled default. It is used verbatim, so append the - # session-state fragment yourself when state is on: it is what tells the - # model how breadcrumbs, @state: handles and host-filled parameters + # session-state fragment yourself when state is on. The fragment tells the + # model how state notes, @state: handles and host-filled parameters # work, and asks it to carry the provenance the state notes record into # its answers. system_prompt=MY_PROMPT + "\n\n" + SESSION_STATE_PROMPT, @@ -532,49 +552,60 @@ built = await build_agent( ) ``` -The bundled default composes exactly this way — `mcp_agent.main.BASE_PROMPT` -plus `SESSION_STATE_PROMPT` — and the plain agent (`MCP_AGENT_STATE=0`) gets -`BASE_PROMPT` alone, so the prompt never describes machinery that is not -wired. Assembling your own agent instead ([4b](#4b-wiring-it-into-your-own-agent))? -The fragment is host-agnostic: append it to your prompt whenever the three -state pieces are installed. +The bundled default is built exactly this way, from +`mcp_agent.main.BASE_PROMPT` plus `SESSION_STATE_PROMPT`. The plain agent +(`MCP_AGENT_STATE=0`) gets `BASE_PROMPT` alone, so the prompt never describes +machinery that is not wired. Assembling your own agent instead +([4b](#4b-wiring-it-into-your-own-agent))? The fragment works with any host, so +append it to your prompt whenever the three state pieces are installed. -`extra_tools` are yours, not MCP tools: they are neither bound to session state -nor rewritten. `middleware` layers over `StateCaptureMiddleware` rather than +`extra_tools` are yours, not MCP tools. They are neither bound to session state +nor rewritten. `middleware` runs on top of `StateCaptureMiddleware` rather than replacing it, so capture and handles keep working. Both agents, with session state on or off, also carry -`mcp_agent.interrupted_tool_calls.repair_interrupted_tool_calls`. A run -cancelled while a tool is running (a client closing the stream, a pod rolled -mid-turn) checkpoints the model's tool call with no result, and providers then -reject every later turn on that thread. The repair closes such a call with an -error `ToolMessage` at the start of the next turn, so the thread heals instead. - -It returns a `BuiltAgent` — `agent`, `connections`, `tools` (as loaded, before -binding), and `required`, the per-toolset credential-header -declaration discovered alongside the connections. Take `required` from here -rather than looking it up again: a second lookup can disagree with what the -agent was actually wired with. - -`run_turn` returns a `TurnResult` — `history`, `new_messages`, `answer`, -`sidecar` (the thread's `tool_state`), `citations` (ids the model put on -`reference` content blocks) and `interrupts` (the questions the turn stopped -on; see [5g](#5g-questions-from-the-agent-interrupt)). It also takes a -`config`, merged into the runnable config passed to `ainvoke`, for attaching -per-turn callbacks or metadata; `thread_id` always wins over anything set in its -`configurable`. +`mcp_agent.interrupted_tool_calls.repair_interrupted_tool_calls`. A run can be +cancelled while a tool is running: a client closes the stream, or a pod is +rolled mid-turn. The checkpoint then holds the model's tool call with no result, +and providers reject every later turn on that thread. The repair closes such a +call with an error `ToolMessage` at the start of the next turn, so the thread +heals instead. + +`build_agent` returns a `BuiltAgent` with four fields: + +- `agent`; +- `connections`; +- `tools`, as loaded, before binding; +- `required`, the credential headers each toolset declares, discovered with the + connections. + +Take `required` from here rather than looking it up again. A second lookup can +disagree with what the agent was actually wired with. + +`run_turn` returns a `TurnResult`: + +- `history` and `new_messages`; +- `answer`; +- `sidecar`, the thread's `tool_state`; +- `citations`, the ids the model put on `reference` content blocks; +- `interrupts`, the questions the turn stopped on (see + [5g](#5g-questions-from-the-agent-interrupt)). + +`run_turn` also takes a `config`, merged into the runnable config passed to +`ainvoke`, for attaching per-turn callbacks or metadata. `thread_id` always wins +over anything set in its `configurable`. **Streaming the same turn.** `mcp_agent.streaming.stream_turn` takes the same arguments and is an async generator, for a surface that shows a token before the turn ends. It yields, in order of arrival: -| event | what it carries | +| Event | What it carries | | --- | --- | -| `AnswerChunk` | `text` — a piece of the answer | -| `ToolStarted` | `id`, `name`, `arguments` the model wrote | +| `AnswerChunk` | `text`, a piece of the answer | +| `ToolStarted` | `id`, `name`, and the `arguments` the model wrote | | `ToolFinished` | `id`, `name`, `content`, `artifact`, plus `received` (receipts for what session state supplied) and `published` (`{field: state key}` for what it stored) | -| `StateChanged` | `state` — session state after a tool wrote to it, accumulated | -| `TurnFinished` | `result` — the identical `TurnResult`, always last | +| `StateChanged` | `state`, the session state after a tool wrote to it, accumulated | +| `TurnFinished` | `result`, the identical `TurnResult`, always last | ```python async for event in stream_turn(agent, "clip chirps to my area", thread_id): @@ -587,10 +618,11 @@ async for event in stream_turn(agent, "clip chirps to my area", thread_id): sources = result.citations ``` -A caller wanting one answer can consume the stream and keep only `TurnFinished` -— which is what makes this a superset of `run_turn` rather than a fork of it. +A caller wanting one answer can consume the stream and keep only +`TurnFinished`. That is what makes this a superset of `run_turn` rather than a +fork of it. -Three things it does that a loop of your own would have to get right: +It gets three things right that a loop of your own would have to: - Tool results reach the token channel as well as the update channel. So answer text is `AIMessageChunk` from the model node and nothing else. @@ -601,10 +633,11 @@ 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 `[agent]` extra: -`with_credential_support` comes from `mcp_agent`, and it is what builds one -client per connection. -Three pieces, all three required: +You only need this if you are *not* using the bundled agent. It needs the +`[agent]` extra, because `with_credential_support` comes from `mcp_agent` and is +what builds one client per connection. + +There are three pieces, and all three are required: ```python from langchain.agents import create_agent @@ -620,12 +653,12 @@ from mcp_state import ( with_server_name, ) -# Loaded per server, so each tool records where it came from. The adapter -# stamps the serving server on metadata, but not in this form; without this an -# undeclared capture cannot be keyed `//` 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. +# Loaded per server, so each tool records where it came from. langchain.mcp +# records the serving server on metadata, but not in this form. Without this, +# an undeclared capture cannot be keyed `//` 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 session, +# a call that overlapped another user's would carry that user's credentials. tools = [ with_server_name(tool, server) for server, client in with_credential_support(connections, None).items() @@ -637,8 +670,8 @@ agent = create_agent( model, [ *bind_all_injected(tools), - # `CheckpointHistory(saver)` as the second argument if you keep - # conversations — see "Reading a key as it stood earlier" below. + # Pass `CheckpointHistory(saver)` as the second argument if you keep + # conversations. See "Reading a key as it stood earlier" below. make_inspect_state(state_keys(published)), ], system_prompt=MY_PROMPT + "\n\n" + SESSION_STATE_PROMPT, @@ -646,42 +679,45 @@ agent = create_agent( ) ``` -- **`StateCaptureMiddleware`** — moves large values out of tool returns into - `tool_state`, leaving a `[state updated: …]` breadcrumb in their place. It - declares `mcp_state.AgentState` as its `state_schema`, so adding it is what - puts the `tool_state` namespace and its reducer on the graph — you do not - pass `state_schema` yourself. That reducer bounds the namespace at - `MAX_TOOL_STATE_BYTES` (8 MB of stored values), evicting the oldest writes; - nothing else does, and capture writes on every tool call. -- **`bind_all_injected`** — rewrites tool schemas so a model can name a stored - value, substitutes it at call time, and refuses the calls it cannot serve. -- **`make_inspect_state`** — an `inspect_state` tool, for when the *model* needs - to read a stored value rather than pass it on. Everything in `tool_state` is - readable; `state_keys(published)` is passed so a read that misses can say - "declared, but no tool has published it yet" instead of "no such key". - -**`inspect_state` is a coroutine, so drive the graph asynchronously** — -`await agent.ainvoke(...)`, or `stream_turn`, not `agent.invoke(...)`. A tool +- **`StateCaptureMiddleware`** moves large values out of tool returns into + `tool_state`, and leaves a `[state updated: …]` note in their place. It + declares `mcp_state.AgentState` as its `state_schema`. So adding it is what + puts the `tool_state` namespace and its reducer on the graph, and you do not + pass `state_schema` yourself. + + That reducer caps the namespace at `MAX_TOOL_STATE_BYTES` (8 MB of stored + values), evicting the oldest writes first. Nothing else limits it, and capture + writes on every tool call. +- **`bind_all_injected`** rewrites tool schemas so that a model can name a + stored value. It substitutes the value at call time, and refuses the calls it + cannot serve. +- **`make_inspect_state`** makes an `inspect_state` tool, for when the *model* + needs to read a stored value rather than pass it on. Everything in + `tool_state` is readable. `state_keys(published)` is passed so that a read + that misses can say "declared, but no tool has published it yet" instead of + "no such key". + +**`inspect_state` is a coroutine, so drive the graph asynchronously.** Use +`await agent.ainvoke(...)` or `stream_turn`, not `agent.invoke(...)`. A tool that reads a key as it stood at an earlier turn has to reach the host's conversation store, and there is no synchronous way in. A graph invoked -synchronously does not fail when it is built: it fails when the model first +synchronously does not fail when it is built. It fails when the model first calls the tool, which is in front of a user. -The fourth piece is soft but do not skip it: append `SESSION_STATE_PROMPT` to -your system prompt. The machinery works without it, but the model then meets -breadcrumbs, `@state:` handles and handle-only parameters with no -explanation — and nothing asks it to carry the provenance into its answers. +The fourth piece is optional, but do not skip it: append `SESSION_STATE_PROMPT` +to your system prompt. The machinery works without it. But the model then meets +state notes, `@state:` handles and handle-only parameters with no +explanation, and nothing asks it to carry provenance into its answers. -**Reading a key as it stood earlier.** Optional, and only worth wiring if you -keep conversations. A key holds one value, so a tool called a second time -displaces what the first one published — and a model asked to compare the two +**Reading a key as it stood earlier.** This is optional, and only worth wiring +if you keep conversations. A key holds one value, so a tool called a second time +replaces what the first call published. Ask the model to compare the two, and it reads the key, gets a well-formed value, and compares the current one with itself. `make_inspect_state` takes a second argument for that: anything satisfying `mcp_state.ThreadHistory`, which is one async method returning what state held -at a given turn. If you check-point with LangGraph, `mcp_agent` has the -adapter: +at a given turn. If you checkpoint with LangGraph, `mcp_agent` has the adapter: ```python from mcp_agent.history import CheckpointHistory @@ -693,44 +729,49 @@ It takes the *saver*, not the agent, because tools are built before the graph they run in. `mcp_agent.with_session_state` does this for you whenever it is given a checkpointer. -Omit it and `inspect_state` reads the present and nothing else. A `turn=` -argument then answers that the deployment retains no turn history. - -Pass one and the model can ask for a key as it stood at turn *n*. It is also -told which of two things happened when there is no value: the conversation -never had that turn, or the turn has been pruned. Pruned means the value -existed and is gone. That is an answer. Reading the current value instead -would be a wrong answer stated confidently. - -Your own store works just as well: implement `snapshot(thread_id, turn)` -returning `Snapshot(state={key: entry}, retained=, total=)`. All three matter. `state` of `None` -with the turn inside `total` is what says *pruned* rather than *never -happened*, and `retained` is what a pruned answer offers instead of a value: -which turns are still worth asking about. It takes the turn so that only one -turn's state has to travel — on a thread carrying geometries the difference -between that and every turn's is an order of magnitude of memory. The count a -read reports is of turns that *wrote* the key, so every turn it names is one -your `snapshot` can serve. +Omit it, and `inspect_state` reads the present and nothing else. A `turn=` +argument then answers that the deployment keeps no turn history. + +Pass one, and the model can ask for a key as it stood at turn *n*. When there is +no value, it is also told which of two things happened: the conversation never +had that turn, or the turn has been pruned. Pruned means the value existed and +is gone. That is an answer. Reading the current value instead would be a wrong +answer stated confidently. + +Your own store works just as well. Implement `snapshot(thread_id, turn)`, +returning `Snapshot(state={key: entry}, retained=, +total=)`. All three fields matter: + +- `state` of `None`, with the turn inside `total`, is what says *pruned* rather + than *never happened*. +- `retained` is what a pruned answer offers instead of a value: the turns that + are still worth asking about. +- `total` is what tells those two cases apart. + +It takes the turn so that only one turn's state has to travel. On a thread +carrying geometries, the difference between that and every turn's state is an +order of magnitude of memory. The count a read reports is of turns that *wrote* +the key, so every turn it names is one your `snapshot` can serve. **Rendering a tool call in your own host.** A handle *is* in the arguments the -model produced, but only as the `@state:` string — which names a value +model produced, but only as the `@state:` string. That names a value without saying what it held or which tool published it, and a reader cannot expand it. `receipts_of(message.artifact)` returns `{parameter: Receipt}` for everything -session state supplied — the key it came from and the tool that published it. -The entry that key holds adds one thing more: `inputs`, where each argument of +session state supplied: the key it came from, and the tool that published it. +The entry that key holds adds one more thing, `inputs`: where each argument of the call that *produced* it came from, either another key or `"model"`. + `authored(entry)` narrows that to the parameters the model wrote, which is the -part worth showing — "this value rests on something the model chose" is what +part worth showing. "This value rests on something the model chose" is what decides how much to trust a result. `mcp_agent.host.step_input` is the worked -example; that module holds the host-side helpers and imports no UI framework, -so it is reachable from a base install. +example. That module holds the host-side helpers and imports no UI framework, +so a base install can reach it. `inputs` is recorded and never enforced. Nothing refuses a call on it, and -`NotAuthored` does not consult it — a value the model wrote, laundered through -a tool that echoed it, still resolves. What changes is that you can see it. +`NotAuthored` does not consult it. A value the model wrote, laundered through a +tool that echoed it, still resolves. What changes is that you can see it. **If your agent has state of its own**, pass a `state_schema` subclassing `mcp_state.AgentState`. LangChain merges a middleware's schema with the one you @@ -745,16 +786,16 @@ agent = create_agent(model, tools, state_schema=HostState, middleware=[...]) ``` > **The one thing that bites.** Handle resolution is a LangGraph -> `InjectedState` mechanism, so it runs wherever a tool executes — but +> `InjectedState` mechanism, so it runs wherever a tool executes. But > **capture is agent middleware**. Assemble a bare `StateGraph`/`ToolNode` -> instead of `create_agent` and you get resolution with no capture: nothing is +> instead of `create_agent`, and you get resolution with no capture. Nothing is > ever stored, so there is never anything to name, and no error tells you. > **Compile with a checkpointer.** `tool_state` lives on graph state, so -> without one every turn starts empty — capture runs, the next turn finds -> nothing to name, and no error says so. With one, state and transcript both belong to the -> `thread_id` and persist for free. `create_agent(..., checkpointer=...)`; -> `InMemorySaver` is enough for local dev, `AsyncPostgresSaver` for anything +> without one every turn starts empty. Capture runs, the next turn finds nothing +> to name, and no error says so. With one, state and transcript both belong to +> the `thread_id` and persist for free. Pass `create_agent(..., checkpointer=...)`. +> `InMemorySaver` is enough for local dev, and `AsyncPostgresSaver` for anything > that restarts or scales past one replica. > > A checkpointed agent should also carry @@ -766,22 +807,22 @@ agent = create_agent(model, tools, state_schema=HostState, middleware=[...]) > **If you also render UI views.** Capture moves the payload off the tool > message, so rebuild each view's data with > `restore_structured(message.artifact, tool_state)` rather than reading -> `ToolMessage.artifact` directly. It is a no-op on an uncaptured message, so -> call it unconditionally. See `view_props` in `mcp_agent/host.py` for a -> worked version, and "Sharp edges and limits" in +> `ToolMessage.artifact` directly. It does nothing to an uncaptured message, so +> call it unconditionally. See `view_props` in `mcp_agent/host.py` for a worked +> version, and "Sharp edges and limits" in > [SESSION-STATE.md](./SESSION-STATE.md). Capture is by size (`DEFAULT_CAPTURE_BYTES`, 2 kB) as well as by declaration. `StateCaptureMiddleware(published, capture_undeclared=None)` turns the size path -off if you want capture strictly as declared. +off, if you want capture strictly as declared. ### 4c. Naming things, and the one tag worth adding -**A `ToolResult` data key is a public name.** Every field but `message` is -captured into session state under `//`, and that key is -what the agent's model reads when it decides which stored value a later call -should use — a call that may land in a different toolset. Nothing else crosses -between two toolsets — no shared vocabulary, no imports, no registry. +**A `ToolResult` data key is a public name.** Every field except `message` is +captured into session state under `//`. The agent's model +reads that key when it decides which stored value a later call should use, and +that call may be in a different toolset. Nothing else passes between two +toolsets: no shared vocabulary, no imports, no registry. So name for what the value is, not what type it is: @@ -795,7 +836,8 @@ the two are identical JSON. A model handed the wrong one produces confident nonsense, and nothing in this system will notice. **`NotAuthored` is the one tag.** It says a model must not write this -parameter's value — nothing about types, nothing about session state: +parameter's value. It says nothing about types, and nothing about session +state: ```python from typing import Annotated @@ -817,31 +859,33 @@ Use it where a plausible-looking invention is worse than no answer: a be *the* one under discussion. A 2000-vertex boundary and four numbers are both "geometry", and only you know which of them a model could produce. -It degrades in three steps rather than requiring anything: +It requires nothing of a client. How much it does depends on how much of this +the client implements: -| client | effect | +| Client | Effect | | --- | --- | -| ignores `_meta` | the parameter behaves normally; the model fills it | -| reads the description | advisory — the served schema says the value must already exist | +| ignores `_meta` | the parameter behaves normally, and the model fills it | +| reads the description | advisory: the served schema says the value must already exist | | implements `mcp_state` | the schema accepts only `@state:` | -Tagging something that is not a parameter fails at `build_server` rather than +Tagging something that is not a parameter fails at `build_server`, rather than going unnoticed until a client connects. Toolsets advertise what they publish -and what they will not author on `/health` (`state.produces`, -`state.not_authored`), and the index aggregates them, so you can see a +and what they will not write on `/health` (`state.produces`, +`state.not_authored`), and the index aggregates them. So you can see a deployment's data flow without speaking MCP. --- ## 5. Serving the agent over HTTP (`mcp_agent_api`) -Needs the `[api]` extra, which is `[agent]` plus `ag-ui-protocol`. An API -deployment installs no UI framework; the web client it serves is a prebuilt -bundle. +This needs the `[api]` extra, which is `[agent]` plus `ag-ui-protocol`. An API +deployment installs no UI framework, because the web client it serves is a +prebuilt bundle. -Your frontend, this runtime's agent, and +The setup is your frontend, this runtime's agent, and [AG-UI](https://github.com/ag-ui-protocol/ag-ui) over Server-Sent Events between -them. Three layers, and you enter at the one you already have an application at: +them. There are four layers, and you enter at whichever one you already have an +application for: | Module | What it is | What you supply | | --- | --- | --- | @@ -851,21 +895,21 @@ them. Three layers, and you enter at the one you already have an application at: | `mcp_agent_api.ui` | the built web client, as two more routes | where your routes are | Each layer is the one below it plus a decision. `create_app` calls -`create_router`, which calls `agui_events`. Entering at the top therefore costs -nothing you cannot undo by dropping a layer later. +`create_router`, which calls `agui_events`. So entering at the top costs nothing +you cannot undo by dropping a layer later. A runnable version of all of it, with a backend laid out the way a deployment is, is in [`examples/agui-events/`](../examples/agui-events/). -**You may not need a frontend at all.** `[api]` ships one — see -[5f](#5f-the-bundled-web-client) — and `create_app` serves it, so the first -deployable thing here is a container, not a project. +**You may not need a frontend at all.** `[api]` ships one (see +[5f](#5f-the-bundled-web-client)) and `create_app` serves it. So the first thing +you can deploy here is a container, not a project. **This one holds the provider key.** `PROVIDER_MODEL` and `PROVIDER_API_KEY` are read from the environment at startup and belong to the deployment. A visitor is never asked for one. The credential headers in -[5e](#5e-credentials) are the separate case: those a visitor may supply, and -the client keeps them in the browser. +[5e](#5e-credentials) are a separate case: a visitor may supply those, and the +client keeps them in the browser. ### 5a. The whole service @@ -874,22 +918,24 @@ uv run uvicorn mcp_agent_api.app:app --port 8000 ``` `MCP_URL`, `PROVIDER_MODEL` and `PROVIDER_API_KEY` come from the environment or -a `.env`; `MCP_AGENT_STATE` and `MCP_AGENT_CHECKPOINT` mean here exactly what -they mean in [4](#4-session-state-keeping-large-values-out-of-the-model), so an +a `.env`. `MCP_AGENT_STATE` and `MCP_AGENT_CHECKPOINT` mean here exactly what +they mean in [4](#4-session-state-keeping-large-values-out-of-the-model). So an API deployment gets session state and resumable threads on the same terms as the -bundled host. `MCP_AGENT_API_CORS_ORIGINS` is the one setting the API adds, -comma-separated -(`https://a,https://b`) rather than JSON because that is what someone writes in a -Helm values file. Unset — a UI on the same origin, or reached through a dev -server's proxy — adds no CORS middleware at all. +bundled host. + +`MCP_AGENT_API_CORS_ORIGINS` is the one setting the API adds. It is +comma-separated (`https://a,https://b`) rather than JSON, because that is what +someone writes in a Helm values file. Leave it unset for a UI on the same +origin, or one reached through a dev server's proxy, and no CORS middleware is +added at all. It allows one run per thread at a time ([5c](#5c-the-routes)), using the lock -that matches `MCP_AGENT_CHECKPOINT`: in-process for the in-memory checkpointer, -a PostgreSQL advisory lock for a Postgres one. With a factory of your own it -uses the in-process lock unless you pass `run_lock=`. +that matches `MCP_AGENT_CHECKPOINT`. That is an in-process lock for the +in-memory checkpointer, and a PostgreSQL advisory lock for a Postgres one. With +a factory of your own, it uses the in-process lock unless you pass `run_lock=`. -Building the agent yourself instead (extra tools, your own system prompt, a -checkpointer you already own) is a factory: +To build the agent yourself instead (extra tools, your own system prompt, a +checkpointer you already own), pass a factory: ```python from mcp_agent.main import build_agent @@ -903,16 +949,16 @@ async def build(): app = create_app(build) ``` -**The factory is async and awaited during startup**, not called by `create_app`: -connecting to MCP servers needs a running loop and there is none at import. Until -it returns the routes answer `503`, and a failure inside it stops the process — -a container that starts, reports healthy and answers 503 forever is harder to -notice than one that refuses to start. +**The factory is async and awaited during startup**, not called by +`create_app`. Connecting to MCP servers needs a running loop, and there is none +at import. Until the factory returns, the routes answer `503`. A failure inside +it stops the process, because a container that starts, reports healthy and +answers 503 forever is harder to notice than one that refuses to start. That shapes what you report to an orchestrator. Readiness should reflect the -agent being built; liveness deliberately should not, or a restart lands on a -process that was merely still connecting. `create_app` hands back a `FastAPI` and -it is still yours, so both are yours to add: +agent being built. Liveness deliberately should not, or a restart lands on a +process that was merely still connecting. `create_app` hands back a `FastAPI` +that is still yours, so both probes are yours to add: ```python app = create_app(build) @@ -927,8 +973,8 @@ async def readiness(): ### 5b. Mounting the routes into your own application -A deployment with its own FastAPI application — its own auth, its own -middleware, its own everything — takes the router and keeps all of it: +A deployment with its own FastAPI application (its own auth, its own +middleware, its own everything) takes the router and keeps all of it: ```python from mcp_agent_api.routes import create_router @@ -936,21 +982,21 @@ from mcp_agent_api.routes import create_router app.include_router(create_router(lambda: app.state.built, prefix="/agent")) ``` -**The agent arrives through a callable**, called per request rather than passed -once, for the same reason `create_app` takes a factory: the router is built and -mounted before anything has connected. Raising from it is how you say "not yet", -and that surfaces as `503`. An agent rebuilt behind it — on a model change, on a -reconnect — is picked up without remounting. - -It may return anything with `.agent`, `.connections`, `.tools` and -`.required`, which `build_agent`'s `BuiltAgent` already is. Those are read by -attribute rather than unpacked because `BuiltAgent` is a `NamedTuple` and a -consumer's equivalent may order its fields differently — positional unpacking -would silently pair the wrong ones. - -**`turn_context` is where what wraps a run goes** — tracing callbacks, a -correlation id, per-request metadata. A context manager, entered inside the -stream and given the request and both ids; whatever it yields becomes the +**The agent arrives through a callable**, called on each request rather than +passed once. The reason is the same as for `create_app`'s factory: the router is +built and mounted before anything has connected. Raising from the callable is +how you say "not yet", and that surfaces as `503`. An agent rebuilt behind it, +on a model change or a reconnect, is picked up without remounting. + +The callable may return anything with `.agent`, `.connections`, `.tools` and +`.required`, which `build_agent`'s `BuiltAgent` already has. They are read by +attribute rather than unpacked. `BuiltAgent` is a `NamedTuple`, and a consumer's +equivalent may order its fields differently, so positional unpacking would +silently pair the wrong ones. + +**`turn_context` is where anything that wraps a run goes**: tracing callbacks, a +correlation id, per-request metadata. It is a context manager, entered inside +the stream and given the request and both ids. Whatever it yields becomes the turn's runnable config: ```python @@ -988,7 +1034,7 @@ For Postgres this is an advisory lock per thread, on one extra connection per process. If a lock query fails, the claim falls back to the in-process lock and the failure is logged. For another store, implement `mcp_agent.run_lock.RunLock` (four methods). With the in-process lock, runs on different replicas are not -excluded. +kept apart. **`owner` records who started each thread.** It is a function from the request to an opaque identifier, or `None` for a caller with none. The routes only @@ -1005,44 +1051,46 @@ app.include_router(create_router(provider, owner=caller)) The first run on a thread records the caller under the `owner` key of its checkpoints' metadata. Later runs leave it alone, so the owner is whoever sent -the first message, not whoever wrote last. A caller with no identity is recorded -as `""`; a thread from before you passed `owner` has no key at all. +the first message, not whoever wrote last. A caller with no identity is +recorded as `""`. A thread from before you passed `owner` has no key at all. + `GET /threads` then lists the caller's own threads, and refuses a caller with no -identity. Nothing is enforced: any thread is still readable by whoever holds its -id. The listing filters the checkpoint metadata, which has no index, so on -Postgres it reads the whole `checkpoints` table. +identity. Nothing is enforced: whoever holds a thread's id can still read it. +The listing filters the checkpoint metadata, which has no index, so on Postgres +it reads the whole `checkpoints` table. **The AG-UI types come from AG-UI.** `messages` on `POST /runs`, and the -transcript `GET /threads/{id}` hands back, are `ag_ui.core.Message` — the -protocol's own discriminated union, which includes the `activity` role this -server emits, so a client echoing its history back validates. The stream is -documented as `ag_ui.core.Event`: every event type the protocol defines, -discriminated on `type`, under `text/event-stream` rather than a nominal -`application/json`. Nothing is re-described by hand, so none of it can drift -from the protocol. - -One consequence worth knowing: the protocol requires an `id` on every message, -so a client that omits one now gets a `422`. Any string does — a fresh uuid per -message is the obvious choice — and nothing here reads it: history is the -server's, and the id you post is discarded rather than stored. - -**The read routes document their own shapes.** `ThreadResponse`, `TurnsResponse`, -`StateValueResponse` and the `StateEntryInfo` all three share are exported from -`mcp_agent_api`, so a Python client validates against them rather than -re-declaring them, and the generated OpenAPI carries them instead of a bare -`object`. +transcript `GET /threads/{id}` hands back, are `ag_ui.core.Message`. That is +the protocol's own discriminated union. It includes the `activity` role this +server emits, so a client echoing its history back validates. + +The stream is documented as `ag_ui.core.Event`: every event type the protocol +defines, discriminated on `type`, under `text/event-stream` rather than a +nominal `application/json`. Nothing is re-described by hand, so none of it can +drift from the protocol. + +One consequence is worth knowing. The protocol requires an `id` on every +message, so a client that omits one now gets a `422`. Any string will do, and a +fresh uuid per message is the obvious choice. Nothing here reads it: history is +the server's, and the id you post is discarded rather than stored. + +**The read routes document their own shapes.** `ThreadResponse`, +`TurnsResponse`, `StateValueResponse`, and the `StateEntryInfo` all three share, +are exported from `mcp_agent_api`. So a Python client validates against them +rather than re-declaring them, and the generated OpenAPI carries them instead +of a bare `object`. They are attached through FastAPI's `responses=` rather than `response_model=`, -and that is deliberate. A response model would re-serialise. Two details would -not survive that. `seq` is omitted from a state entry until it is known, -because a client sorting by it must never be sorting nulls. `tool: null` is -meaningful and has to stay. Documenting without re-serialising keeps both. +and that is deliberate. A response model would re-serialise, and two details +would not survive that. `seq` is left out of a state entry until it is known, +because a client sorting by it must never sort nulls. And `tool: null` is +meaningful, so it has to stay. Documenting without re-serialising keeps both. ### 5c. The routes | | | | --- | --- | -| `POST /runs` | one turn, streamed as AG-UI SSE — the whole conversation is here; `resume` answers a question | +| `POST /runs` | one turn, streamed as AG-UI SSE. The whole conversation happens here; `resume` answers a question | | `GET /threads` | the caller's own threads, newest first; only with `owner` | | `GET /threads/{id}` | the thread's messages, activities and open questions, so a page reload restores it, and whether a run holds it now | | `GET /threads/{id}/idle` | answers once no run holds the thread, or `running: true` after a wait; ask again | @@ -1051,23 +1099,29 @@ meaningful and has to stay. Documenting without re-serialising keeps both. | `GET /views/{toolset}/{view}` | the HTML for a `ui://` bundle a tool declared | | `GET /connections` | what the agent connected to, its tools, and the credential headers it wants | -`POST /runs` accepts a `RunAgentInput` from `@ag-ui/client` as posted, and also a -hand-written `{"threadId": …, "messages": […]}`: the fields that model requires -but this endpoint never reads are optional here. A client that omits `threadId` -learns the one it was given from `RUN_STARTED`, which is always the first event. - -**History is the server's.** Only the trailing user message of the request is -read; the rest of `messages` is ignored and the checkpointer's transcript is the -truth. That diverges from AG-UI's client-is-authoritative convention on purpose — -the values session state exists to keep out of the model's context would -otherwise have to live in the browser and be posted back every turn. The stream -says so rather than leaving a client to discover it: a `MESSAGES_SNAPSHOT` -closes every run with the thread as the server holds it. It closes rather than -opens the run because a snapshot drops every local message it does not name, and -one sent before the turn was checkpointed would take the user's own question off -their screen. Ids line up — the question keeps the client's `id`, the answer -carries the id the thread will store — so a client reconciles in place rather -than rebuilding its list. +Every route answers `503` while the agent is not built. + +`POST /runs` accepts a `RunAgentInput` from `@ag-ui/client` as posted. It also +accepts a hand-written `{"threadId": …, "messages": […]}`, because the fields +that model requires but this endpoint never reads are optional here. A client +that omits `threadId` learns the one it was given from `RUN_STARTED`, which is +always the first event. + +**History is the server's.** Only the last user message of the request is read. +The rest of `messages` is ignored, and the checkpointer's transcript is the +truth. That departs from AG-UI's convention that the client is authoritative, +on purpose. Otherwise the values that session state exists to keep out of the +model's context would have to live in the browser and be posted back every turn. + +The stream says so, rather than leaving a client to discover it. A +`MESSAGES_SNAPSHOT` closes every run, with the thread as the server holds it. +It closes the run rather than opening it, because a snapshot drops every local +message it does not name. One sent before the turn was checkpointed would take +the user's own question off their screen. + +The ids line up: the question keeps the client's `id`, and the answer carries +the id the thread will store. So a client reconciles in place rather than +rebuilding its list. **One run per thread at a time.** Each run reads the thread when it starts and writes it back when it ends, so two overlapping runs would lose one turn. A @@ -1076,46 +1130,52 @@ anything streams, with `{"reason": "run_in_progress"}` in the detail. The `409`s for unanswered questions on the same route have a string detail. A refused run is not queued. A run with `resume` is claimed the same way. -`GET /threads/{id}` reports `running`. `GET /threads/{id}/idle` is held until no +`GET /threads/{id}` reports `running`. `GET /threads/{id}/idle` waits until no run holds the thread and then answers `{"running": false}`, or answers `{"running": true}` after 25 seconds. A client waiting for another run calls it until it answers `false`, then reads the thread again. The bundled web client does this ([5f](#5f-the-bundled-web-client)). -**The read routes are what the stream deliberately leaves out.** The state -channel carries `{tool, bytes, inputs}` per key and never the payload, so a client that -has decided it wants the 38 kB geometry comes to `/state/{key}` for it. The key -is qualified by its publishing toolset and tool -(`dataset-search/search_datasets/area_of_interest`) and those slashes are part +**The read routes serve what the stream deliberately leaves out.** The state +channel carries `{tool, bytes, inputs}` for each key and never the payload. A +client that has decided it wants the 38 kB geometry comes to `/state/{key}` for +it. The key is qualified by the toolset and tool that published it +(`dataset-search/search_datasets/area_of_interest`), and those slashes are part of the key, not path separators. -**Per-turn state needs no new storage.** The state channel is cumulative — the -patches take a client to every key the thread holds — so neither "which keys did *this* -turn add" nor "what did this turn run on" is answerable from the stream. Both -are answerable from what LangGraph already keeps: an immutable checkpoint per -super-step, every past value retained. `/turns` and `?turn=N` read that back. -`/turns` reports `turns` (retained) alongside `total` (asked), because a missing -turn has two meanings and a client cannot otherwise tell them apart: one the -thread never had is a `404`, and one the checkpointer has since pruned is a -`410`. - -**A thread id is the only credential on a state read.** `/threads/{id}/state/{key}` -returns whatever that thread published, to whoever names the thread. That is what -lets an anonymous conversation draw its own map, and it means the id must be -treated as a secret — it leaks through logs, referrers and browser history like -any other URL component. If a thread belongs to a user, put your own dependency -in front of the router ([5b](#5b-mounting-the-routes-into-your-own-application)). +**Per-turn state needs no new storage.** The state channel is cumulative: the +patches take a client to every key the thread holds. So the stream cannot answer +"which keys did *this* turn add" or "what did this turn run on". What LangGraph +already keeps can: an immutable checkpoint per super-step, with every past value +retained. `/turns` and `?turn=N` read that back. + +`/turns` reports `turns` (the ones retained) alongside `total` (how many there +have been), because a missing turn has two meanings that a client cannot +otherwise tell apart. A turn the thread never had is a `404`. A turn the +checkpointer has since pruned is a `410`. + +**A thread id is the only credential on a state read.** +`/threads/{id}/state/{key}` returns whatever that thread published to whoever +names the thread. That is what lets an anonymous conversation draw its own map. +It also means the id must be treated as a secret, since it leaks through logs, +referrers and browser history like any other part of a URL. If a thread belongs +to a user, put your own dependency in front of the router +([5b](#5b-mounting-the-routes-into-your-own-application)). **`GET /connections` is the one route that is not about a conversation.** It answers the two questions a client has before there is one: what can this agent -do, and what does it need from me. Credential headers are reported with a -`supplied` flag rather than a value — a deployment holding one shared key -should not make every visitor paste it, and a visitor with an account of their -own can still override it, because a request header beats the environment. -The flag is resolved per request, the way a run resolves it, so a deployment -that sets a per-user credential header for a signed-in caller — a dependency in -front of the router ([5b](#5b-mounting-the-routes-into-your-own-application)) — -should set it on this route too, and those callers are not asked for it. +do, and what does it need from me? + +Credential headers are reported with a `supplied` flag rather than a value. A +deployment holding one shared key should not make every visitor paste it. A +visitor with an account of their own can still override it, because a request +header beats the environment. + +The flag is resolved per request, the same way a run resolves it. Say a +deployment sets a per-user credential header for a signed-in caller, through a +dependency in front of the router +([5b](#5b-mounting-the-routes-into-your-own-application)). It should set the +header on this route too, and then those callers are not asked for it. ### 5d. The event stream @@ -1140,54 +1200,59 @@ async for event in agui_events( ``` AG-UI covers tokens, tool calls and state natively. It has no vocabulary for the -things this runtime exists to make visible, so those ride `ACTIVITY_*` — an -activity *is* a message in AG-UI, so a client rendering messages in order shows -them in the right place with no correlation code: +things this runtime exists to make visible, so those ride `ACTIVITY_*` events. +In AG-UI an activity *is* a message. So a client rendering messages in order +shows them in the right place, with no code to correlate them: | `activityType` | content | | --- | --- | -| `state.consumed` | `toolCallId`, `tool`, `received` — each receipt's fields plus a `display` line | +| `state.consumed` | `toolCallId`, `tool`, `received`: each receipt's fields plus a `display` line | | `state.published` | `toolCallId`, `tool`, `published` | -| `mcp.view` | `toolCallId`, `tool`, `uri` — the `ui://` bundle, fetched separately and cached | -| `answer.citations` | `ids` — after the answer, since that is where they belong | +| `mcp.view` | `toolCallId`, `tool`, `uri`: the `ui://` bundle, fetched separately and cached | +| `answer.citations` | `ids`, sent after the answer, since that is where they belong | -Every activity carries a `display` string as well as its fields. A minimal client -prints it; a bespoke one styles the fields and ignores it. For receipts that -string is `mcp_agent.host.step_input`'s output, so the wire says exactly what -the `mcp_agent.host` helpers render: +Every activity carries a `display` string as well as its fields. A minimal +client prints it. A custom one styles the fields and ignores it. For receipts, +that string is the output of `mcp_agent.host.step_input`, so the wire says +exactly what the `mcp_agent.host` helpers render: ``` @state:dataset-search/search_datasets/area_of_interest · 1 feature(s), 4 vertices · from search_datasets · query written by the model ``` -**Read the fields, never that string.** A receipt is `{key, tool}` — the state +**Read the fields, never that string.** A receipt is `{key, tool}`: the state key the value came from, and the tool that published it. The trailing clause -appears only when the producing call was itself given a model-authored -argument, and it names the parameter rather than repeating its value. +appears only when the producing call was itself given an argument the model +wrote, and it names the parameter rather than repeating its value. -`state.published` carries `{toolCallId, tool, published: {field: key}}`, which is -enough to link a key in a state panel back to the call that wrote it without any -bookkeeping of your own — the example UI's cross-highlighting is that mapping and -nothing else. +`state.published` carries `{toolCallId, tool, published: {field: key}}`. That +is enough to link a key in a state panel back to the call that wrote it, with no +bookkeeping of your own. The example UI's cross-highlighting is that mapping +and nothing else. -`STATE_DELTA` carries metadata only — `tool`, `bytes`, `inputs`, and `seq` -once known — never the stored value, which a frontend fetches when it actually wants -to draw it. It sits under `toolState` inside AG-UI's state object. +`STATE_DELTA` carries metadata only (`tool`, `bytes`, `inputs`, and `seq` once +known), never the stored value. A frontend fetches the value when it actually +wants to draw it. The metadata sits under `toolState` inside AG-UI's state +object. **Patched rather than snapshotted, and that is the point.** A `STATE_SNAPSHOT` -replaces the whole `state` object, and that object is shared: everything the +replaces the whole `state` object, and that object is shared. Everything the client keeps in it would go every time a tool wrote. Every operation sent here names a path under `toolState` instead, so the client's own keys are never -touched. Each run opens with one `add` of the whole namespace — the -resynchronisation point, carrying the thread's state and not merely this turn's -writes — and what follows is one `add` or `remove` per key that moved. State -that has not moved sends no event at all. +touched. -Two details a client meets. **Escaping**: a state key is `toolset/name` and `/` -is JSON Pointer's own separator, so `gazet/candidates` arrives as -`/toolState/gazet~1candidates` (RFC 6901, `~` as `~0`). **`seq`**: assigned when -a write is merged, so mid-turn entries omit it and the delta closing the turn -adds it. +Each run opens with one `add` of the whole namespace. That is the point where +the client resynchronises, and it carries the thread's state, not merely this +turn's writes. What follows is one `add` or `remove` for each key that changed. +State that has not changed sends no event at all. + +A client meets two details: + +- **Escaping.** A state key is `toolset/name`, and `/` is JSON Pointer's own + separator. So `gazet/candidates` arrives as `/toolState/gazet~1candidates` + (RFC 6901, with `~` as `~0`). +- **`seq`** is assigned when a write is merged. So mid-turn entries leave it + out, and the delta that closes the turn adds it. Four protocol rules shape the event order. Each is checked against `@ag-ui/client`'s own verifier rather than read off the specification: @@ -1201,24 +1266,25 @@ Four protocol rules shape the event order. Each is checked against ### 5e. Credentials **From the environment.** `resolve_credentials(required, flags, dotenv_extra)` -resolves each header a connected toolset advertises: an explicit flag first, then -`X_DEMO_TOKEN` for `x-demo-token` in the process environment, then the same key -from a `.env`. The CLI exposes it as repeatable `--header NAME=VALUE`, and `GET /connections` -reports it so a client can skip asking for anything the deployment already -supplies. +resolves each header a connected toolset advertises. It takes an explicit flag +first, then the process environment (`X_DEMO_TOKEN` for `x-demo-token`), then +the same key from a `.env`. The CLI exposes it as a repeatable +`--header NAME=VALUE`. `GET /connections` reports it, so a client can skip +asking for anything the deployment already supplies. **From the request.** `POST /runs` calls `credentials_for(request.headers, -built.required)`, which takes off the request only those headers a connected +built.required)`. That takes off the request only the headers a connected toolset actually declared, and falls back to the environment for the rest. -Forwarding everything would put this request's `Authorization` — which belongs to -*your* API, not to a toolset — onto an outbound MCP call, and the MCP -authorization spec is explicit that a server must not transit a token addressed -to somebody else. + +Forwarding everything would put this request's `Authorization` onto an outbound +MCP call. That header belongs to *your* API, not to a toolset, and the MCP +authorization spec says plainly that a server must not pass on a token +addressed to somebody else. A deployment that sets the environment fallback gives every caller that account. -That is right for local development and for a single shared service credential, -and wrong for per-user access: such a deployment leaves the variable unset and -lets each request carry its own header. +That is right for local development and for a single shared service credential. +It is wrong for per-user access, where a deployment leaves the variable unset +and lets each request carry its own header. --- @@ -1226,32 +1292,35 @@ lets each request carry its own header. `[api]` installs a built single-page client alongside the routes, and `create_app` serves it at the root. A container running -`uvicorn mcp_agent_api.app:app` is a chat over your toolsets: the transcript, -tool calls as they happen, `ui://` views sized to what they drew, and the -session-state panel with each value a click away. +`uvicorn mcp_agent_api.app:app` is a chat over your toolsets. It shows the +transcript, tool calls as they happen, `ui://` views sized to what they drew, +and the session-state panel with each value a click away. + +Every tool's result is in the transcript, folded away. It is drawn the same way +whether or not that tool ships a view, because a view is a nicer reading of a +result, not a more important one. -Every tool's result is in the transcript, folded away and drawn the same way -whether or not that tool ships a view — a view is a nicer reading of a result, -not a more important one. Opening the session-state panel adds the receipts beside them: -one switch rather than two, because the panel and the receipts describe the -same thing from two ends — what the tools exchanged without the model reading -it. Closed, which is how it starts, the page is a chat. +Opening the session-state panel also shows the receipts beside the results. +That is one switch rather than two, because the panel and the receipts describe +the same thing from two ends: what the tools exchanged without the model reading +it. The panel starts closed, and closed, the page is a chat. With an `owner` hook, a collapsible column on the right lists the caller's threads (`GET /threads`), each by its first question and when it was started. Without one, or for a caller with no identity, the column is not drawn. -**clear** in the header starts a new thread: nothing is deleted, the old thread -keeps its own `?thread=` URL, and what the new one buys is empty session state, -which is per thread. +**clear** in the header starts a new thread. Nothing is deleted, and the old +thread keeps its own `?thread=` URL. What the new one gives you is empty session +state, which is per thread. -**Two windows on one conversation.** A window opened while another window's -run holds the thread, or refused because of one, shows "Answering in another -window" and disables send. A question that was refused goes back into the -input, unsent. When the run ends, the window reloads the thread. A window that -becomes visible reloads the thread if it has new messages. +**Two windows on one conversation.** Say one window opens while another +window's run holds the thread, or sends a question and is refused because of +it. It shows "Answering in another window" and disables send. A question that +was refused goes back into the input, unsent. When the run ends, the window +reloads the thread. A window that becomes visible reloads the thread if it has +new messages. -Configure it from the environment — text and one colour: +Configure it from the environment, with text and one colour: | | | | --- | --- | @@ -1274,28 +1343,28 @@ mount_ui(app, api="/api") # the page at /, pointed at your prefix mount_ui(app, api="/api", path="/chat") # or under a path of its own ``` -`api` is where *the browser* reaches the routes. The client speaks those seven -routes and the AG-UI wire and nothing else, so serving it is a question about -your routes rather than about your application — what forces a fork is -diverging from the routes. +`api` is where *the browser* reaches the routes. The client speaks those routes +and the AG-UI wire and nothing else. So serving it is a question about your +routes rather than about your application. What forces a fork is diverging from +the routes. Three things to know before you deploy it: - **The page is the agent's front door.** `create_app(ui=False)` turns it off - where the front end is yours; `ui=True` insists on it, and fails at import - if the installation has none, which is better than a home page that 404s. + where the front end is yours. `ui=True` insists on it, and fails at import if + the installation has none, which is better than a home page that 404s. - **The provider key is the deployment's.** Anyone who can open the page can spend it. Put authentication in front of the application ([5b](#5b-mounting-the-routes-into-your-own-application)) if that is not what you want. -- **Assets are compressed in the process**, once, at mount. Not - `GZipMiddleware`, which would also compress `POST /runs` — buffering a token - stream is the one thing a chat must not do. +- **Assets are compressed in the process**, once, at mount. Not by + `GZipMiddleware`, which would also compress `POST /runs`, and buffering a + token stream is the one thing a chat must not do. -The source is [`js/agent-ui`](../js/agent-ui) in this repository, built by +The source is [`js/agent-ui`](../js/agent-ui) in this repository. It is built by `./scripts/build-js` into `src/mcp_agent_api/ui` and carried in the wheel from -there. Anything structural — a different layout, a map beside the transcript — -is a change to it. +there. Anything structural, such as a different layout or a map beside the +transcript, is a change to it. ### 5g. Questions from the agent (`interrupt`) @@ -1315,10 +1384,10 @@ A question has 2 to 10 options by default. For other limits, set The tool description states the limits in force, and the runtime does not add a second `interrupt` tool. -**The prompt.** The tool description alone is not sufficient. A model with -options to offer can write them into its reply and not call the tool. The -default system prompt thus ends with `INTERRUPT_GATE_PROMPT` -(from `mcp_agent.interrupt_gate`) when the agent has the tool. If you give your own +**The prompt.** The tool description alone is not enough. A model with options +to offer can write them into its reply and not call the tool. So the default +system prompt ends with `INTERRUPT_GATE_PROMPT` (from +`mcp_agent.interrupt_gate`) when the agent has the tool. If you give your own `system_prompt`, add it yourself: ```python @@ -1339,8 +1408,8 @@ interrupt( ``` If the arguments are not correct (fewer than 2 options, more than the maximum, -or two options with the same value), the tool does not stop the run. It returns an -error message, and the model can call it again. +or two options with the same value), the tool does not stop the run. It returns +an error message, and the model can call it again. **The run stops.** The turn ends with `TurnResult.interrupts`. Each item has the LangGraph interrupt `id` and a `value`: @@ -1367,8 +1436,9 @@ LangGraph interrupt `id` and a `value`: ``` The schema uses the MCP elicitation enum shape. For `multiple=True`, `choice` is -an array of `anyOf` items, and the answer is a list: `{"choice": ["a", "b"]}`. Use `options_of(schema)` from -`mcp_agent.interrupt_gate` to get the `(value, label)` pairs. +an array of `anyOf` items, and the answer is a list: `{"choice": ["a", "b"]}`. +Use `options_of(schema)` from `mcp_agent.interrupt_gate` to get the +`(value, label)` pairs. `answer_model(options, multiple)` builds the pydantic model the schema comes from: `choice` is a titled `Literal` for each option. Use it to validate an @@ -1444,23 +1514,25 @@ number. ## 6. Migrating off the in-repo workspace -If your repo currently vendors `packages/mcp-runtime`, `packages/mcp-cli`, +If your repo currently vendors `packages/mcp-runtime`, `packages/mcp-cli` and `packages/mcp-agent`: 1. Delete those three directories. -2. Drop them from root `pyproject.toml` `dependencies` and `[tool.uv.sources]`; - add the `mcp-toolsets-runtime` dependency from [Install](#1-install). It - needs no `[tool.uv.sources]` entry of its own — it resolves from PyPI. +2. Drop them from the root `pyproject.toml`'s `dependencies` and + `[tool.uv.sources]`, and add the `mcp-toolsets-runtime` dependency from + [Install](#1-install). It needs no `[tool.uv.sources]` entry of its own, + because it resolves from PyPI. 3. Remove `packages/*` from `[tool.uv.workspace] members` if nothing else lives there. 4. In each toolset `ui/`, delete the vendored `src/host.ts` and depend on `@developmentseed/mcp-view` ([3b](#3b-the-view-side-bridge-developmentseedmcp-view)). Delete the repo-root `public/elements/McpView.jsx`; nothing uses it. -5. `uv lock`, run your lint/tests, and smoke-test a toolset server and the API. +5. Run `uv lock` and your lint and tests, then smoke-test a toolset server and + the API. -Imports don't change, so application code is untouched — this is a dependency and -build-wiring change only. +Imports don't change, so application code is untouched. This changes only +dependencies and build wiring. --- @@ -1474,55 +1546,56 @@ so the two move together. What that touches depends on which persona you are. uv lock --upgrade-package mcp-toolsets-runtime ``` -A `mcp<2` bound of your own blocks that resolve; the runtime now pins +A `mcp<2` bound of your own blocks that resolve, because 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 ` 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. +something, such as a store that is down or a quota exceeded, now tells it +nothing. Return a `ToolError` 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`: +DNS-rebinding protection for a server built for `127.0.0.1` or `localhost`, so 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 +`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: +**Persona 3, your own agent:** `MultiServerMCPClient` is gone. In its place is +one fastmcp `Client` per connection from `with_credential_support`, with tools +loaded through `list_tools`. `list_tools` 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 other things change around it: - 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. + `mcp_state.tool_meta(tool)`, never `metadata["_meta"]`. The old read finds + nothing and fails nothing, which silently turns off `NotAuthored`, + publications and views. - 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 + your own must accept `**kwargs`, because fastmcp passes `follow_redirects`. +- `McpError` is now `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 +**Serving a built server yourself:** `build_server` returns a `ToolsetServer`. +That is 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 +of your own is now `mcp.server.mcpserver.MCPServer`. 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`. +now `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 +`CREDENTIAL_HEADERS` and `NotAuthored` are written the same way. Only the exception point above applies. A grep that finds the rest: diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 3fd6f98..14abd55 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,55 +1,57 @@ # Session state: what the model never sees -Some tool inputs and outputs are too large for the model to be handling: -a clip geometry, an item collection, a raster footprint. Asking a model to -produce one burns tokens on a value it can only copy imperfectly; letting one -back into the transcript burns tokens on every subsequent turn. +Some tool inputs and outputs are too large for the model to handle: a clip +geometry, an item collection, a raster footprint. Asking a model to produce one +spends tokens on a value it can only copy imperfectly. Letting one back into +the transcript spends tokens on every turn after it. `mcp_state` moves such a value from the tool that produced it to the tool that -needs it, through agent state, without it passing through the model. +needs it. It travels through agent state and never passes through the model. ## The promise **A large value is stored once, named cheaply, and never re-enters the -conversation.** The model is told a value exists and what produced it; it -decides which one a call should use; the client substitutes the payload on the +conversation.** The model is told that a value exists and what produced it. It +decides which value a call should use. The client puts the payload in on the way to the server. -Three things follow from "the model decides": +Three things follow from "the model decides". 1. **The model must be able to say what it wants, cheaply.** Every parameter - that could hold a structured value gains a second accepted form — the string - `"@state:dataset-search/search_datasets/area_of_interest"`, a **handle** - naming something already stored. About ten tokens instead of 38 kB. -2. **What is stored must be legible.** A key is - `//`, and the listing a model chooses from adds the - value's shape and the tool that published it. Nothing labels a value with - what it *means*: an area of interest and a coverage footprint are the same - JSON, and the only thing that tells them apart is the name its tool gave it. -3. **A wrong choice must be correctable.** A handle naming nothing, or a value - a model wrote where it may not, comes back as a refusal addressed to the - model — a tool *result*, listing what session state actually holds — not as - an exception that ends the run. + that could hold a structured value accepts a second form: a **handle**, a + string naming something already stored, such as + `"@state:dataset-search/search_datasets/area_of_interest"`. That is about + ten tokens instead of 38 kB. +2. **What is stored must be legible.** A key is `//`. + The listing a model chooses from adds the value's shape and the tool that + published it. Nothing labels a value with what it *means*. An area of + interest and a coverage footprint are the same JSON, and only the name its + tool gave it tells them apart. +3. **A wrong choice must be correctable.** Say a handle names nothing, or the + model wrote a value where it may not. Either one comes back as a refusal + addressed to the model, not as an exception that ends the run. The refusal + is a tool *result*, and it lists what session state actually holds. A tool may add one thing: **`NotAuthored`** on a parameter, meaning *a model -must not write this value*. That narrows the parameter's schema so a handle is -the only form it accepts. It names no type, so no two toolsets have to agree on -anything, and it is not a claim about session state at all — a client that -ignores it runs the tool exactly as before. +must not write this value*. It narrows the parameter's schema so that a handle +is the only form it accepts. It names no type, so no two toolsets have to agree +on anything. Nor is it a claim about session state: a client that ignores it +runs the tool exactly as before. -All of this is client-side, so it exists only where `mcp_state` is wired in. -The same servers connected to an MCP host that does none of it behave exactly -as they always did. +All of this happens in the client, so it exists only where `mcp_state` is wired +in. Connect the same servers to an MCP host that does none of it, and they +behave exactly as they always did. ### What a handle is -A string, in place of a value, on the way to a tool: +A handle is a string that stands in for a value on the way to a tool: ``` @state:dataset-search/search_datasets/area_of_interest ``` -`offer_handles` rewrites the parameter's JSON Schema so both forms validate: +`offer_handles` rewrites the parameter's JSON Schema so that both forms +validate: ```json {"anyOf": [ @@ -58,28 +60,29 @@ A string, in place of a value, on the way to a tool: ]} ``` -`dereference` swaps it for the stored value before the call goes out, so the -server receives the real thing and never knows. A parameter its server tagged -`NotAuthored` gets the handle arm **alone** — no `anyOf`, no literal accepted. +`dereference` swaps the handle for the stored value before the call goes out. +The server receives the real thing and never knows. A parameter that its server +tagged `NotAuthored` gets the handle form **alone**: no `anyOf`, and no literal +accepted. -The model learns which keys exist from the `[state updated: …]` breadcrumb -capture leaves on a tool result, and can read any of them with `inspect_state`. +The model learns which keys exist from the `[state updated: …]` note that +capture leaves on a tool result. It can read any of them with `inspect_state`. ### A handle only counts as a whole argument -`dereference` substitutes a handle only where one *is* an argument, never -inside one. A model writing `{"request": {"area": "@state:…"}}` on a tool -taking an opaque `dict` gets no substitution — the server would receive the -literal string. +`dereference` substitutes a handle only where the handle *is* an argument, +never inside one. Take a tool whose parameter is an opaque `dict`. A model that +writes `{"request": {"area": "@state:…"}}` there gets no substitution, and the +server would receive the literal string. -That is caught rather than sent: `unresolved()` walks the arguments for any -`@state:` left standing, and the call is refused with the path named +That is caught rather than sent. `unresolved()` walks the arguments for any +`@state:` left standing. The call is refused, with the path named (`request.area`) and the available keys listed. The same check catches a handle naming a key that does not exist. ## How a parameter is decided -Once, at connect, for every parameter of every connected tool: +This is decided once, at connect, for every parameter of every connected tool: ```mermaid flowchart TD @@ -92,20 +95,20 @@ flowchart TD B -->|no| LEAVE["Left alone. A string or a number
is cheaper to generate than to name."] ``` -**"Structured" is a test on the declared type, not on any value's size** — -this runs at connect, when no tool has produced anything to measure. `object` -and `array` qualify; `string`, `number` and `boolean` do not, since naming a -short value costs a model no less than emitting it. A parameter with no stated -type, or one behind a `$ref` or an `anyOf`, also qualifies: unconstrained means -it could hold anything. The test errs towards yes, because a false yes costs a -few schema tokens and a false no silently withdraws the mechanism from a -parameter that needed it. - -**Nothing is withheld at connect.** A `NotAuthored` parameter whose value -nothing has published yet still gets offered — the producer may run later in -the same turn, and a client cannot know at connect what will have run by the -time a call is made. The call is answered with a refusal instead, which the -model can act on. +**"Structured" is a test on the declared type, not on the size of any value.** +It runs at connect, when no tool has produced anything to measure. `object` and +`array` qualify. `string`, `number` and `boolean` do not, since naming a short +value costs a model as much as writing it. A parameter with no stated type also +qualifies, as does one behind a `$ref` or an `anyOf`: unconstrained means it +could hold anything. The test leans towards yes. A false yes costs a few schema +tokens. A false no silently takes the mechanism away from a parameter that +needed it. + +**Nothing is withheld at connect.** Take a `NotAuthored` parameter whose value +nothing has published yet. It is still offered, because the producer may run +later in the same turn, and at connect a client cannot know what will have run +by the time a call is made. The call gets a refusal instead, which the model can +act on. ## How a returned value is captured @@ -115,7 +118,7 @@ After every tool call: flowchart TD F["A field in a tool's structuredContent"] --> S{"Secret-shaped name?
token, api_key, password"} - S -->|yes| DROP["Never stored, at any size —
and dropped from the artifact
whenever capture rewrites it."] + S -->|yes| DROP["Never stored, at any size.
Also dropped from the artifact
whenever capture rewrites it."] S -->|no| D{"Declared by the server?"} D -->|yes| DECL["Stored under the declared
toolset/tool/field key."] @@ -125,36 +128,36 @@ flowchart TD Z -->|no| KEEP["Left in the tool's result.
Too small to be worth moving."] ``` -"Declared" needs no work from a tool author: **every data key of a -`ToolResult` — every field but `message` — is one**. Being structured data -rather than prose is the whole declaration, and `with_state_meta` stamps the -resulting keys into the tool's `_meta` for a client to read back. +"Declared" needs no work from a tool author. **Every data key of a `ToolResult` +is declared**, meaning every field except `message`. Being structured data +rather than prose is the whole declaration. `with_state_meta` stamps the +resulting keys into the tool's `_meta`, for a client to read back. -The payload is gone from the transcript; what stands in its place is whatever -the tool put in `message`, plus a `[state updated: …]` breadcrumb naming the -key. The size gate is an argument, not a constant: -`StateCaptureMiddleware(capture_undeclared=…)` takes a different threshold, or -`None` to capture only what a server declared. +The payload leaves the transcript. In its place stands whatever the tool put in +`message`, plus a `[state updated: …]` note naming the key. The size gate is an +argument, not a constant. Pass `StateCaptureMiddleware(capture_undeclared=…)` a +different threshold (the default is 2,048 bytes), or `None` to capture only what +a server declared. ### A data key is a public name -The key a value lands under is what a model reads to decide whether to reuse -it. `dataset-search/search_datasets/area_of_interest` says which toolset, which -call, and what the value is — and the last part is a name its author chose. +A model reads the key a value lands under to decide whether to reuse it. +`dataset-search/search_datasets/area_of_interest` says which toolset, which +call, and what the value is. Its author chose that last part. -Name it for what the value **is**, not for its type. `geometry` is a poor name -because a footprint is also a geometry; `area_of_interest` and `footprint` are -good ones, because a model reading either knows which tool it belongs in. This -is the whole of the cross-toolset contract: two toolsets that share no code and -no imports agree on nothing but this string, and a model bridges them. +Name it for what the value **is**, not for its type. `geometry` is a poor name, +because a footprint is also a geometry. `area_of_interest` and `footprint` are +good names, because a model reading either knows which tool it belongs in. This +is the whole of the contract between toolsets. Two toolsets that share no code +and no imports agree on nothing but this string, and a model bridges them. ## Receipts: what a call actually ran against A handle names a value without describing it. By the time a host renders the -call, `@state:dataset-search/search_datasets/area_of_interest` is just a -string — nothing in the transcript says what it held or who put it there. +call, `@state:dataset-search/search_datasets/area_of_interest` is just a string. +Nothing in the transcript says what it held or who put it there. -So every parameter session state supplies leaves a **receipt** on the tool +So every parameter that session state supplies leaves a **receipt** on the tool message's artifact, under `injected_state`: ```json @@ -162,35 +165,36 @@ message's artifact, under `injected_state`: "tool": "search_datasets"}} ``` -This is a **host-side** record: LangChain sends a tool message's `content` to -the model, never its `artifact`, so nothing here costs context. It rides the -message into the checkpointer and comes back on a later turn; -`receipts_of(message.artifact)` is how a host reads it, and what `mcp_agent` -builds its tool-step input from. `mcp_agent_api` forwards each one as a -`state.consumed` activity — still no context cost, because that goes to the +This record is for the host. LangChain sends a tool message's `content` to the +model and never its `artifact`, so nothing here costs context. The receipt rides +the message into the checkpointer and comes back on a later turn. A host reads +it with `receipts_of(message.artifact)`, which is also what `mcp_agent` builds +its tool-step input from. `mcp_agent_api` forwards each one as a +`state.consumed` activity. That costs no context either, because it goes to the client and never back to the model. **Nothing is echoed to the model.** It wrote the key itself, so repeating it -would buy nothing the transcript does not already hold. +would add nothing the transcript does not already hold. -How the model learns any of this exists is a system prompt, and it ships as a -reusable fragment: `mcp_state.SESSION_STATE_PROMPT` explains the breadcrumbs, -the handles, the narrowed parameters and `inspect_state`, and asks the model to -carry provenance into its answers — "clipped with the area of interest that +The model learns that any of this exists from a system prompt, which ships as a +reusable fragment. `mcp_state.SESSION_STATE_PROMPT` explains the notes, the +handles, the narrowed parameters and `inspect_state`. It also asks the model to +carry provenance into its answers: "clipped with the area of interest that search_datasets returned", not just "clipped". The bundled agent appends it to -its own instruction; a host with its own prompt does the same (see +its own instructions. A host with its own prompt does the same (see `docs/CONSUMING.md`). ## Provenance: what a call was given A tool owns what it returns. Nothing here inspects a return to decide whether a -value was genuinely derived or merely echoed back — an equality test against the -call's arguments would catch the echo and miss every transformation, producing a -label that is *sometimes* right with no way for a reader to know which time. +value was really derived or merely echoed back. An equality test against the +call's arguments would catch the echo and miss every transformation. The label +it produced would be *sometimes* right, and a reader could never tell which +time. -What the client knows for certain is where each **argument** came from. A handle -is a reference to a value some tool produced; anything else the model wrote this -turn. So every entry records the call that produced it: +What the client knows for certain is where each **argument** came from. A +handle refers to a value some tool produced, and the model wrote everything +else this turn. So every entry records the call that produced it: ```python class StateEntry(TypedDict): @@ -205,14 +209,14 @@ class StateEntry(TypedDict): turns_written: NotRequired[int] ``` -Parameter names and state keys, never values, so it stays cheap however large -the call was. Absent where the call took no arguments, which is not the same -claim as an empty object. +`inputs` holds parameter names and state keys, never values, so it stays cheap +however large the call was. It is absent where the call took no arguments, +which is a different claim from an empty object. ### Why this is the hole worth plugging -`NotAuthored` stops a model writing a value *into that parameter*. It does not -stop this: +`NotAuthored` stops a model from writing a value *into that parameter*. It does +not stop this: 1. The model invents `[12.4, 55.6, 12.7, 55.8]`. 2. It passes that to some tool with an ordinary, untagged `bbox` parameter. @@ -220,10 +224,10 @@ stop this: 4. Capture stores it as `gazet/get_aoi/bbox`, `tool="get_aoi"`. 5. The model passes `@state:gazet/get_aoi/bbox` to a `NotAuthored` parameter. -The invented value now wears a tool's name and is indistinguishable from a -gazetteer lookup. `inputs` is what tells them apart — the first case records -`{"bbox": "model"}`, the second `{"place": "model"}` with a bbox the tool -actually computed. +The invented value now wears a tool's name, and nothing distinguishes it from a +gazetteer lookup. `inputs` tells them apart. The invented one records +`{"bbox": "model"}`. The genuine lookup records `{"place": "model"}`, with a +bbox the tool actually computed. ### A chain, not a taint @@ -235,15 +239,16 @@ raster-ops/clip_raster/bounds given {dataset_id: model, aoi: dataset-search/ dataset-search/…/area_of_interest given {query: model} ``` -Nothing propagated a flag and nothing compared two values. That is also why -there is no false-positive policy to get wrong: taint propagation has to decide -how far a mark spreads and gets it wrong for somebody, while a chain decides -nothing and lets each reader stop where it wants to. +No flag was propagated and no two values were compared. That is also why there +is no false-positive policy to get wrong. Taint propagation has to decide how +far a mark spreads, and gets that wrong for somebody. A chain decides nothing, +and lets each reader stop where it wants to. -**Anything in this package stops at one level** — the call that produced the -entry being read. Deeper is available and a host is free to walk it, but at -depth "the model wrote something upstream" is true of every value in a session: -it wrote the query that found the dataset. +**Everything in this package stops at one level**: the call that produced the +entry being read. A host is free to walk deeper, but the deeper it goes the less +it learns. At enough depth, "the model wrote something upstream" is true of +every value in a session, since the model wrote the query that found the +dataset. ### What reads it @@ -253,125 +258,130 @@ it wrote the query that found the dataset. @state:gazet/get_aoi/bbox — 4 item(s), from get_aoi (you wrote: bbox) ``` -Only model-authored parameters are named. A parameter filled from state is the -unremarkable case and would cost tokens on every line. +Only parameters the model wrote are named. A parameter filled from state is the +unremarkable case, and naming it would cost tokens on every line. -**The host**, in a tool step: `… · from get_aoi · bbox written by the model`. -**The wire**, as `inputs` on each key of the state channel and on -`GET /threads/{id}/state/{key}`. **The model**, via `SESSION_STATE_PROMPT`, -which asks it to prefer a value that does not carry the caveat and to say so -when a result depends on one. +Three other readers use it: -**Nothing refuses on it.** Enforcing was considered and rejected: it turns -visibility into a guarantee at the price of a tool going uncallable whenever -its only producer was itself called with a model-authored argument, and that -lands on a user with no way to clear it. A wrong value the user can see beats a -right one they cannot obtain. +- **The host**, in a tool step: `… · from get_aoi · bbox written by the model`. +- **The wire**, as `inputs` on each key of the state channel and on + `GET /threads/{id}/state/{key}`. +- **The model**, through `SESSION_STATE_PROMPT`. It asks the model to prefer a + value that does not carry the caveat, and to say so when a result depends on + one. + +**Nothing refuses on it.** Enforcing it was considered and rejected. It would +turn visibility into a guarantee, at a price: a tool would become uncallable +whenever its only producer had itself been called with an argument the model +wrote. That lands on a user with no way to clear it. A wrong value the user can +see beats a right one they cannot get. ## Reading a key as it stood earlier -A key holds one value. When a later call republishes it the earlier value -leaves state, so a model asked "how does this compare with the one you found -first" reads the key, gets a well-formed answer, and compares the current value -with itself. The read succeeds, so nothing looks wrong. +A key holds one value. When a later call republishes it, the earlier value +leaves state. Ask the model "how does this compare with the one you found +first", and it reads the key, gets a well-formed answer, and compares the +current value with itself. The read succeeds, so nothing looks wrong. ```mermaid sequenceDiagram autonumber participant Model participant Capture as StateCaptureMiddleware - participant State as tool_state — one value per key - participant Saver as checkpointer — every past value + participant State as tool_state (one value per key) + participant Saver as checkpointer (every past value) - Note over Model,Saver: turn 1 — "where is Copenhagen" + Note over Model,Saver: turn 1, "where is Copenhagen" Model->>Capture: get_aoi("Copenhagen") - Capture->>State: bbox = [12.4, …] — turn 1, turns_written 1 + Capture->>State: bbox = [12.4, …], turn 1, turns_written 1 Capture-->>Model: [state updated: gazet/get_aoi/bbox] State-->>Saver: checkpointed - Note over Model,Saver: turn 2 — "and Aarhus" + Note over Model,Saver: turn 2, "and Aarhus" Model->>Capture: get_aoi("Aarhus") - Capture->>State: bbox = [9.9, …] — turn 2, turns_written 2 + Capture->>State: bbox = [9.9, …], turn 2, turns_written 2 Capture-->>Model: … this replaces what gazet/get_aoi/bbox held at
turn 1, still readable with inspect_state(key, turn=1) State-->>Saver: checkpointed - Note over Model,Saver: turn 3 — "how does that compare with the first" + Note over Model,Saver: turn 3, "how does that compare with the first" Model->>State: inspect_state(key) State-->>Model: [9.9, …]
[2 turns of this conversation wrote this key …] Model->>Saver: inspect_state(key, turn=1) - Saver-->>Model: [12.4, …] — as turn 1 ended + Saver-->>Model: [12.4, …] as turn 1 ended ``` -**Three surfaces say an earlier value exists**, because a read is not the route -the model always takes. Passing `@state:` to a tool resolves to the -present and triggers no read at all, so the capture breadcrumb carries it — -`… This replaces what gazet/get_aoi/bbox held at turn 1, which is still -readable with inspect_state(key, turn=1)`. So does the listing a refusal puts -up, where a handle is chosen: `… from get_aoi, written in 2 turns`. And a read -of such a key ends with a line saying how many turns wrote it. - -Only an *earlier* turn is named. A key rewritten within the turn doing the -writing leaves nothing a reader can reach. - -**Turns, not writes, and not values.** Turns because that is the unit the read -addresses: a second write inside one turn adds nothing, since that turn ends -holding one value however many times it was written. Not values, because -whether two turns differ needs the value each ended with, and an entry holds no -history — deciding it from the previous write alone flags a key that changed -and changed back, and stays silent on one that repeated a value before moving. -So the note says an earlier turn **may** differ, and reading it settles that. - -**`inspect_state(key, turn=N)` reads the key as that turn left it.** Nothing -new is stored: a host running under a checkpointer already retains every past -`tool_state`. What it supplies is a `ThreadHistory` — +**Three places say that an earlier value exists**, because the model does not +always take the route of a read. Passing `@state:` to a tool resolves to +the present value and involves no read at all. So: + +- The capture note says it: `… This replaces what gazet/get_aoi/bbox held at + turn 1, which is still readable with inspect_state(key, turn=1)`. +- So does the listing a refusal shows, where a handle is chosen: + `… from get_aoi, written in 2 turns`. +- A read of such a key ends with a line saying how many turns wrote it. + +Only an *earlier* turn is named. A key rewritten within the same turn leaves +nothing a reader can reach. + +**It counts turns. It does not count writes or values.** Turns, because that is +the unit a read addresses. A second write inside one turn adds nothing, since +that turn ends holding one value however many times it was written. Not values, +because telling whether two turns differ needs the value each one ended with, +and an entry holds no history. Judging from the previous write alone would get +both directions wrong. It would flag a key that changed and changed back, and +stay silent on one that repeated a value before moving. So the note says an +earlier turn **may** differ, and reading it settles the question. + +**`inspect_state(key, turn=N)` reads the key as that turn left it.** Nothing new +is stored, because a host running under a checkpointer already keeps every past +`tool_state`. What the host supplies is a `ThreadHistory`: ```python class ThreadHistory(Protocol): async def snapshot(self, thread_id: str, turn: int) -> Snapshot: ... -class Snapshot: - state: Mapping[str, StateEntry] | None # that turn's, or None +class Snapshot(NamedTuple): + state: TurnState | None # that turn's, or None retained: frozenset[int] # the turns still held, numbers only total: int # how many the thread has had ``` -Keys and entries, nothing else, so `mcp_state` still knows nothing about -threads, checkpoints or retention. It asks for one turn rather than all of -them because session state holds precisely the values too big for a -transcript, and handing back every turn's to answer about one multiplies the -largest thing here by the length of the conversation. `total` alongside the -state is what lets it tell a turn the thread never had from one that has been -pruned: +It carries keys and entries and nothing else, so `mcp_state` still knows nothing +about threads, checkpoints or retention. It asks for one turn rather than all of +them, because session state holds exactly the values too big for a transcript. +Handing back every turn's state to answer about one would multiply the largest +thing here by the length of the conversation. `total` lets it tell a turn the +thread never had from one that has been pruned: -| | what the model is told | +| | What the model is told | | --- | --- | | retained | the value, labelled with the turn it came from | | never existed | how many turns the conversation has had, to count against | | pruned | **the value existed and is gone** | -The last is why the distinction is carried. "I cannot answer that, the earlier -value is no longer retained" is a good answer; comparing a value with itself is -not. +The last row is why the distinction is worth carrying. "I cannot answer that, +the earlier value is no longer retained" is a good answer. Comparing a value +with itself is not. -`mcp_agent` supplies `CheckpointHistory`, built from the checkpointer -`with_session_state` is already handed, so a host that passes one gets this and -a host that does not keeps today's behaviour. The same question over HTTP is -`GET /threads/{id}/state/{key}?turn=N`, and both are one derivation of what a -turn is (`mcp_agent.history`). +`mcp_agent` supplies `CheckpointHistory`, built from the checkpointer that +`with_session_state` is already given. A host that passes one gets this, and a +host that does not keeps the behaviour it has today. Over HTTP, the same +question is `GET /threads/{id}/state/{key}?turn=N`. Both derive what a turn is +in one place, `mcp_agent.history`. --- # The scenarios -Four, and they differ only in what the two servers said about themselves. -Every one is exercised for real by `examples/session-state/demo.py`. +There are four, and they differ only in what the two servers said about +themselves. `examples/session-state/demo.py` runs every one of them for real. ## A. A value crosses two toolsets that share nothing `dataset-search` returns an `area_of_interest` data key. `raster-ops` has a -`clip_raster` whose `aoi` is tagged `NotAuthored`. Neither imports the other; -neither names the other. +`clip_raster` tool whose `aoi` is tagged `NotAuthored`. Neither imports the +other, and neither names the other. ``` search_datasets(query="rainfall") @@ -382,24 +392,25 @@ clip_raster(dataset_id="chirps-daily", → "Clipped chirps-daily to a 2000-vertex area of interest." ``` -The 38 kB geometry was never in the transcript, and `clip_raster` could not -have been called with one the model wrote: its `aoi` accepts nothing but a -handle. The model spent about ten tokens on the key. +The 38 kB geometry was never in the transcript. Nor could `clip_raster` have +been called with a geometry the model wrote, because its `aoi` accepts nothing +but a handle. The model spent about ten tokens on the key. -## B. Nothing declared anywhere — a third-party server +## B. Nothing declared anywhere: a third-party server -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 -alone. +This is a raw `MCPServer`, with no `mcp_runtime` and no `_meta`. Its +`describe_geometry` takes a structured parameter. The client offers the handle +form alongside the literal one, and the model points it at the same stored +value. Its `elevation_profile` returns a 54 kB array that nobody declared, which +is captured on size alone. Nothing on that server changed, and nothing about it was known in advance. ## C. The value has not been produced yet The model calls `clip_raster` before running anything that publishes. The -schema accepts only a handle, so it writes one — and the key names nothing. +schema accepts only a handle, so the model writes one, and the key names +nothing. ``` clip_raster was not called. 'aoi' takes a value that already exists in this @@ -407,16 +418,17 @@ session; you cannot write one. Nothing has been published to session state yet, so run the tool that produces this first. ``` -Returned as a tool result, so the assistant message's `tool_calls` are all -answered and the transcript stays well-formed. The model runs a publisher and -retries. This is also what happens when a model batches a publisher and its -consumer into one step: LangGraph runs both against the state as it stood at -the *start* of the step, so the consumer cannot see the publication happening -beside it. +This comes back as a tool result. Every one of the assistant message's +`tool_calls` is answered, so the transcript stays well-formed. The model runs a +publisher and tries again. + +The same thing happens when a model puts a publisher and its consumer in one +step. LangGraph runs both against the state as it stood at the *start* of the +step, so the consumer cannot see the value being published beside it. ## D. The model tries to write the value anyway -Schemas are a request to a model, not a guarantee from it. A literal in a +A schema is a request to a model, not a guarantee from it. A literal in a `NotAuthored` parameter is caught before the call leaves the client: ``` @@ -428,50 +440,53 @@ reference to a value some tool already produced. Pass @state: naming one of ## What tagging buys you -Nothing at all is required. A server that says nothing still has its large -returns captured and its structured parameters reachable by handle. +Nothing at all is required. A server that declares nothing still has its large +returns captured, and its structured parameters can still take a handle. -`NotAuthored` buys one thing, and it is the thing a schema cannot infer: -**a model may not author this value.** A 2000-vertex catchment boundary and a -four-number bounding box are both "geometry", and only the tool knows which of -them a model could plausibly produce — and for which of them a plausible-looking -invention is worse than no answer at all. +`NotAuthored` buys one thing, and it is the thing a schema cannot infer: **a +model may not write this value.** A 2000-vertex catchment boundary and a +four-number bounding box are both "geometry". Only the tool knows which of them +a model could plausibly produce. It also knows for which of them a +plausible-looking invention is worse than no answer at all. -It degrades in three steps, by how much the client implements: +How much it does depends on how much of this the client implements: -| client | effect | +| Client | Effect | |---|---| -| ignores `_meta` | the parameter behaves normally; the model fills it | -| reads the description | advisory — the served schema says the value must already exist | +| ignores `_meta` | the parameter behaves normally, and the model fills it | +| reads the description | advisory: the served schema says the value must already exist | | implements `mcp_state` | the schema accepts only `@state:` | ## Sharp edges and limits -**A handle inside an argument is not substituted.** See "A handle only counts -as a whole argument" above. The refusal names the path, and points the model at -`inspect_state` to read the value and write the field itself. +**A handle inside an argument is not substituted.** See [A handle only counts +as a whole argument](#a-handle-only-counts-as-a-whole-argument) above. The +refusal names the path. It points the model at `inspect_state`, to read the +value and write the field itself. **State is bounded.** `MAX_TOOL_STATE_BYTES` (8 MB of serialised values) evicts -oldest-first. A key that has been evicted resolves to nothing, and a handle -naming it is refused like any other missing key. +the oldest values first. A key that has been evicted resolves to nothing, and a +handle naming it is refused like any other missing key. -**Keys are last-write-wins.** Two calls of the same tool publishing the same -field overwrite each other; `seq` records the order, and the listing a model -reads is newest-first. The value that was displaced is not in state any more — -see [Reading a key as it stood earlier](#reading-a-key-as-it-stood-earlier) for -how a model reaches it and how it learns there was one. +**The last write to a key wins.** When two calls of the same tool publish the +same field, the second overwrites the first. `seq` records the order, and the +listing a model reads is newest first. The value that was replaced is no longer +in state. [Reading a key as it stood +earlier](#reading-a-key-as-it-stood-earlier) covers how a model reaches it, and +how it learns there was one. **A count is not a turn number.** An entry knows how many turns wrote its key, -not which — that would be a list growing with the conversation, on every entry. -Only the breadcrumb names a turn. Guessing at a read is cheap: a turn that did -not write the key answers with what it *did* hold, and one the thread never had -says how many it has. +not which ones. A list of them would grow with the conversation, on every entry. +Only the capture note names a turn. Guessing at a read is cheap. A turn that +did not write the key answers with what it *did* hold, and a turn the thread +never had says how many it has. **Undeclared captures need the host's help to be keyed consistently.** -`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 `/`. +`langchain.mcp` records the serving server under its own `mcp.server` metadata, +which is not the name these keys are built from. So a host that wants +three-part keys for third-party servers records the name itself: it applies +`with_server_name` at load and passes `owners(tools)` into the middleware. +Without that, the keys fall back to `/`. **Nothing knows what a value means.** Only its name says. A tool handed a footprint where it wanted an area of interest will produce confident nonsense, @@ -479,16 +494,17 @@ and no part of this will notice. Name data keys accordingly. ## Trust -**Every connected server is trusted.** Participating is unilateral and free, so +**Every connected server is trusted.** Taking part is unilateral and free, so "does not follow the spec" is not a boundary. A hostile server can declare data -keys under a name chosen to be mistaken for another toolset's — the model picks -values by name, so a plausible name is the attack. Undeclared capture widens -this: a large value from any connected server can be reached by a handle -without declaring anything. The model has to name it, which the transcript -records, but that is visibility rather than control. +keys under a name chosen to be mistaken for another toolset's. The model picks +values by name, so a plausible name is the attack. + +Undeclared capture widens this. A handle can reach a large value from any +connected server without that server declaring anything. The model has to name +it, and the transcript records that, but this is visibility rather than control. That is fine while every server behind the index is yours, which is the only -configuration this is built for today. The moment an index aggregates -third-party servers, the missing control is **per-connection**, not per-key: -filter which server names may take part at all, once, where `publications` and +setup this is built for today. Once an index aggregates third-party servers, the +missing control is **per connection**, not per key. It should filter which +servers may take part at all, once, where `publications` and `bind_all_injected` are applied. From 05754ed0e405b07c00bcf89b3d5fd5debd01fb52 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Fri, 16 Oct 2026 14:26:17 +0100 Subject: [PATCH 3/5] docs: drop a pointer to the in-repo packages nobody has any more Co-Authored-By: Claude Opus 5.5 --- docs/CONSUMING.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index 589d1e0..b7109fd 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -75,9 +75,7 @@ dependencies = [ uv lock && uv sync ``` -Imports are the same as in the old in-repo workspace packages: -`from mcp_runtime.server import build_server`, `from mcp_agent.main import ...`, -and so on. `uv.lock` pins the exact version that resolved. That pin, not a +`uv.lock` pins the exact version that resolved. That pin, not a number in this document, is what makes your builds reproducible. **Upgrade** with `uv lock --upgrade-package mcp-toolsets-runtime`. From 521e6f0e998d8a9632e17ea6718a7fc4009f8a6c Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Fri, 16 Oct 2026 14:43:05 +0100 Subject: [PATCH 4/5] docs: drop history a reader no longer needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-repo workspace migration (CONSUMING §6) and its README mentions, release numbers cited as evidence, "now" and "has always" against versions nobody runs, and a bug log in the agui-events example. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 5 ++--- README.md | 7 +++---- docs/CONSUMING.md | 33 +++++---------------------------- docs/SESSION-STATE.md | 4 ++-- examples/agui-events/README.md | 16 +++------------- 5 files changed, 15 insertions(+), 50 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff49132..6bf69fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ The `pr-title` check fails the PR if the type is not one of these: | `chore` | anything else | no | release-please counts `docs` as user-facing, so a docs commit cuts a release -on its own (0.9.2 came from two). A `build` commit, so every dependency bump, +on its own. A `build` commit, so every dependency bump, waits for the next release something else cuts. Mark a **breaking change** with a `!`: @@ -62,8 +62,7 @@ shift every bump down one place: | a breaking change (`!`) | minor | | anything else that releases | patch | -So a `feat` is a **patch** here, not a minor. The two most recent releases show -it: 0.9.1 was a single `feat`, and 0.9.0 was a breaking change. +So a `feat` is a **patch** here, not a minor. This matters to consumers, who bound the dependency at the next minor. A change that breaks them has to carry the `!`, or release-please ships it inside diff --git a/README.md b/README.md index e494a28..6ea0225 100644 --- a/README.md +++ b/README.md @@ -251,8 +251,7 @@ As a consumer with uv, this is an ordinary dependency with no source override: dependencies = ["mcp-toolsets-runtime[api]"] ``` -Imports are unchanged from the old workspace packages, so -`from mcp_runtime.server import build_server` still works. `uv.lock` pins +`uv.lock` pins whatever resolved, and upgrading is `uv lock --upgrade-package mcp-toolsets-runtime`. @@ -261,8 +260,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, migrating -off the in-repo workspace, and upgrading across a breaking release. +session state into your own agent, serving that agent over HTTP, and +upgrading across a breaking release. ## Develop diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index b7109fd..7f196a0 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -82,7 +82,7 @@ with `uv lock --upgrade-package mcp-toolsets-runtime`. 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`. What 0.11 breaks, and what to check, is in -[7](#7-upgrading-from-010-to-011-mcp-2). +[6](#6-upgrading-from-010-to-011-mcp-2). The console scripts are `mcp-serve`, `mcp-serve-local`, `mcp-index` and `mcp-cli` (all in the base install), and `mcp-agent` (needs `[agent]`). The HTTP @@ -156,8 +156,7 @@ Declare nothing and it guesses from your layout: - `toolset.aws.yaml` if there is an `infra/` one; - both if you have both, and the Helm file if you have neither. -Every consumer got that before this was configurable. It is enough for a repo -laid out the way this package's own template is. +That is enough for a repo laid out the way this package's own template is. The rest of this section is what that scaffold contains. `mcp_runtime` discovers a toolset by convention. Given `TOOLSET=my-toolset`, it imports @@ -238,7 +237,7 @@ TOOLSET=my-toolset mcp-serve # serves this toolset's tools over MCP Use it when many toolsets share one domain and whatever routes them cannot rewrite a path away. A Kubernetes Ingress can, which is why the charts strip -`/` and the container has always seen `/mcp`. An AWS Application Load +`/` and the container sees `/mcp`. An AWS Application Load Balancer cannot. A forward action passes on the path it received, so each toolset has to answer on its own. @@ -1068,7 +1067,7 @@ nominal `application/json`. Nothing is re-described by hand, so none of it can drift from the protocol. One consequence is worth knowing. The protocol requires an `id` on every -message, so a client that omits one now gets a `422`. Any string will do, and a +message, so a client that omits one gets a `422`. Any string will do, and a fresh uuid per message is the obvious choice. Nothing here reads it: history is the server's, and the id you post is discarded rather than stored. @@ -1510,31 +1509,9 @@ option, and a skip button. The text box is disabled until the user answers or skips. The terminal agent (`mcp-agent`) prints numbered options and reads a number. -## 6. Migrating off the in-repo workspace - -If your repo currently vendors `packages/mcp-runtime`, `packages/mcp-cli` and -`packages/mcp-agent`: - -1. Delete those three directories. -2. Drop them from the root `pyproject.toml`'s `dependencies` and - `[tool.uv.sources]`, and add the `mcp-toolsets-runtime` dependency from - [Install](#1-install). It needs no `[tool.uv.sources]` entry of its own, - because it resolves from PyPI. -3. Remove `packages/*` from `[tool.uv.workspace] members` if nothing else lives - there. -4. In each toolset `ui/`, delete the vendored `src/host.ts` and depend on - `@developmentseed/mcp-view` - ([3b](#3b-the-view-side-bridge-developmentseedmcp-view)). Delete the repo-root - `public/elements/McpView.jsx`; nothing uses it. -5. Run `uv lock` and your lint and tests, then smoke-test a toolset server and - the API. - -Imports don't change, so application code is untouched. This changes only -dependencies and build wiring. - --- -## 7. Upgrading from 0.10 to 0.11 (mcp 2) +## 6. 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, diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 14abd55..e8f1861 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -36,7 +36,7 @@ A tool may add one thing: **`NotAuthored`** on a parameter, meaning *a model must not write this value*. It narrows the parameter's schema so that a handle is the only form it accepts. It names no type, so no two toolsets have to agree on anything. Nor is it a claim about session state: a client that ignores it -runs the tool exactly as before. +runs the tool exactly as it would untagged. All of this happens in the client, so it exists only where `mcp_state` is wired in. Connect the same servers to an MCP host that does none of it, and they @@ -366,7 +366,7 @@ with itself is not. `mcp_agent` supplies `CheckpointHistory`, built from the checkpointer that `with_session_state` is already given. A host that passes one gets this, and a -host that does not keeps the behaviour it has today. Over HTTP, the same +host that does not reads only the present value. Over HTTP, the same question is `GET /threads/{id}/state/{key}?turn=N`. Both derive what a turn is in one place, `mcp_agent.history`. diff --git a/examples/agui-events/README.md b/examples/agui-events/README.md index e25cec5..68bed22 100644 --- a/examples/agui-events/README.md +++ b/examples/agui-events/README.md @@ -129,8 +129,7 @@ emitted something the protocol disallows, or in an order it forbids, the run would fail in the client rather than render wrongly. Nothing here parses SSE by hand. -An earlier version of this example did hand-roll the reader, and a hand-rolled -reader can only demonstrate that a format is self-consistent. +A hand-rolled reader could only show that a format is self-consistent. ### Standard, and free with any AG-UI client @@ -188,7 +187,7 @@ The consequence for a consumer is concrete: **mutating `agent.messages` does not edit the thread.** Editing a message, branching, or dropping a turn are client-side illusions here. -The server now says so on the wire: a **`MESSAGES_SNAPSHOT`** closes every +The server says so on the wire: a **`MESSAGES_SNAPSHOT`** closes every run, carrying the thread as the server holds it, so a client that had diverged is corrected rather than drifting. @@ -207,7 +206,7 @@ id the thread already holds is not reused, because the message reducer matches o id and would replace that message rather than add one. `GET /threads/{id}` remains the way to read a thread without running one — and -this client now uses it. The thread id is in the URL as `?thread=`, so +this client uses it. The thread id is in the URL as `?thread=`, so **reloading the page brings the conversation back** rather than starting a fresh one. Two routes rebuild it: `/threads/{id}` for the transcript and `/threads/{id}/turns` for what state held at the end of each turn, joined on the @@ -321,15 +320,6 @@ activity to its structured content, and `mcp.view` to the bundle itself in an iframe. The summary line is the event, so the shape of a turn is readable without expanding anything. -## What running it has caught - -Both of these were live bugs found by this example rather than by reading the -code, which is the argument for keeping it runnable: - -- **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': '…'}]`. - ## What ships and what does not The **client does**: `js/agent-ui` builds into `mcp_agent_api/ui`, and From a0da3321542990b854bf3ae4d0fabee1da89fc7f Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Fri, 16 Oct 2026 15:51:57 +0100 Subject: [PATCH 5/5] docs: review fixes to wrapping and wording Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 8 ++++---- README.md | 11 +++++------ docs/CONSUMING.md | 17 ++++++++--------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6bf69fd..b6424e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,8 +30,8 @@ The `pr-title` check fails the PR if the type is not one of these: | `chore` | anything else | no | release-please counts `docs` as user-facing, so a docs commit cuts a release -on its own. A `build` commit, so every dependency bump, -waits for the next release something else cuts. +on its own. A `build` commit, which is what every dependency bump is, waits +for the next release that something else cuts. Mark a **breaking change** with a `!`: @@ -73,8 +73,8 @@ a range they already allow. 1. Merge PRs with conventional titles. 2. release-please keeps a rolling release PR. It bumps `pyproject.toml`, `js/mcp-view/package.json` and its lock, which the linked-versions plugin - keeps equal, and updates `CHANGELOG.md`. **That PR is editable**, so curate the changelog - before merging if a release warrants it. + keeps equal, and updates `CHANGELOG.md`. **That PR is editable**, so curate + the changelog before merging if a release warrants it. 3. Merging the release PR tags `vX.Y.Z` and cuts a GitHub Release. That publishes the wheel to PyPI and the bridge to npm. diff --git a/README.md b/README.md index 6ea0225..db11c54 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,9 @@ checkpointer, CORS and two health probes. A module-level `app` means ### The toolset plugin contract Writing a toolset with a coding agent? `uv run mcp-toolset skill --install` -puts the authoring skill in the repo and points `AGENTS.md` at it. It covers this section as a procedure, -and adds a mapping table for porting an existing codebase into tools. It ships -in the wheel, so it matches the pinned version. +puts the authoring skill in the repo and points `AGENTS.md` at it. It covers +this section as a procedure, and adds a mapping table for porting an existing +codebase into tools. It ships in the wheel, so it matches the pinned version. `mcp_runtime` finds a toolset by convention. Given the name `my-toolset` it imports `my_toolset.tools` and reads three module-level exports: @@ -181,7 +181,7 @@ Treat `ToolResult`, `NotAuthored` and the `ui/*` wire protocol as public API. Further reading: - [docs/SESSION-STATE.md](./docs/SESSION-STATE.md): the state contract, with - diagrams and four worked scenarios, including the trust assumption it rests on. + diagrams and four worked scenarios, and the trust assumption it rests on. - [examples/session-state/](./examples/session-state/): a runnable version of the whole thing, a third-party server included. Run `uv run python examples/session-state/demo.py`; no API key needed. @@ -251,8 +251,7 @@ As a consumer with uv, this is an ordinary dependency with no source override: dependencies = ["mcp-toolsets-runtime[api]"] ``` -`uv.lock` pins -whatever resolved, and upgrading is +`uv.lock` pins whatever resolved, and upgrading is `uv lock --upgrade-package mcp-toolsets-runtime`. The package is pre-1.0, where a minor release may break. Bound it at the next diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index 7f196a0..dceb610 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -75,9 +75,9 @@ dependencies = [ uv lock && uv sync ``` -`uv.lock` pins the exact version that resolved. That pin, not a -number in this document, is what makes your builds reproducible. **Upgrade** -with `uv lock --upgrade-package mcp-toolsets-runtime`. +`uv.lock` pins the exact version that resolved. That pin, not a number in +this document, is what makes your builds reproducible. **Upgrade** with +`uv lock --upgrade-package mcp-toolsets-runtime`. 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 @@ -150,14 +150,13 @@ a new toolset gets no deployment file at all. A missing template is an error rather than a warning, because the alternative is a toolset scaffolded without the one file its deployment reads. -Declare nothing and it guesses from your layout: +Declare nothing and the scaffolder guesses from your layout, which is enough +for a repo laid out the way this package's own template is: - `toolset.yaml` (Helm values) if there is a `charts/` directory; - `toolset.aws.yaml` if there is an `infra/` one; - both if you have both, and the Helm file if you have neither. -That is enough for a repo laid out the way this package's own template is. - The rest of this section is what that scaffold contains. `mcp_runtime` discovers a toolset by convention. Given `TOOLSET=my-toolset`, it imports `my_toolset.tools` and reads module-level exports: @@ -881,8 +880,8 @@ prebuilt bundle. The setup is your frontend, this runtime's agent, and [AG-UI](https://github.com/ag-ui-protocol/ag-ui) over Server-Sent Events between -them. There are four layers, and you enter at whichever one you already have an -application for: +them. There are three layers, plus the web client, and you enter at whichever +layer you already have an application for: | Module | What it is | What you supply | | --- | --- | --- | @@ -891,7 +890,7 @@ application for: | `mcp_agent_api.events` | one turn as AG-UI events | your transport | | `mcp_agent_api.ui` | the built web client, as two more routes | where your routes are | -Each layer is the one below it plus a decision. `create_app` calls +Each layer is the one below it plus a decision: `create_app` calls `create_router`, which calls `agui_events`. So entering at the top costs nothing you cannot undo by dropping a layer later.