diff --git a/README.md b/README.md index 212a023..f4e9fc9 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ modules, plus the view-side JS bridge: | Module | What it is | | --- | --- | -| `mcp_runtime` | Discovers a toolset's LangChain tools (`TOOLS`) and serves them as an MCP server; serves UI views (`VIEWS`) as `ui://` resources; derives server `instructions` from `CREDENTIAL_HEADERS`; advertises what each tool publishes into and takes from session state (`Kind`). Entry points: `mcp-serve` (one toolset), `mcp-serve-local` (several at once, for local dev), `mcp-index`. | +| `mcp_runtime` | Discovers a toolset's LangChain tools (`TOOLS`) and serves them as an MCP server; serves UI views (`VIEWS`) as `ui://` resources; derives server `instructions` from `CREDENTIAL_HEADERS`; advertises what each tool publishes into session state, and which parameters a model may not write (`NotAuthored`). Entry points: `mcp-serve` (one toolset), `mcp-serve-local` (several at once, for local dev), `mcp-index`. | | `mcp_state` | Session state for *any* agent driving MCP tools: the `tool_state` namespace, `StateCaptureMiddleware` (moves large payloads out of the transcript), `inspect_state` (the model reads one on demand), and `bind_injected` (fills declared parameters from state, and offers `@state:` handles on the rest). A filled parameter leaves a receipt, so a value the model never saw can still be traced to the tool that published it. Works against unmodified third-party servers. Requires the `[state]` extra. | | `mcp_cli` | Typer CLI to list and call tools on a running MCP service. Entry point: `mcp-cli`. | | `mcp_toolset` | Scaffolds a new toolset in a consumer repo (`mcp-toolset new [--with-ui] `), wired to this package + the npm view bridge. | @@ -33,16 +33,22 @@ module exporting: - `CREDENTIAL_HEADERS` *(optional)* — header names the tools read off the transport; used to derive the model-facing auth hint. -A tool may additionally tag a value with the `Kind` it is — on a `ToolResult` -data key to say what it publishes, on a parameter to say what it takes. The -tag is advertised in the tool's `_meta`, and lets an `mcp_state` client move a -large value — a geometry, an item collection — from the tool that produced it -to the tool that needs it *without the model generating or reading it*. -Resolution is by kind, so producer and consumer may be different toolsets on -different servers. See `mcp_runtime.kinds` for the shared vocabulary. +Every data key of a `ToolResult` — every field but `message` — is a value the +tool publishes. An `mcp_state` client captures each into session state under +`//` and lets a later tool be pointed at it by that key, +so a large value — a geometry, an item collection — moves from the tool that +produced it to the tool that needs it *without passing through the model*. +Producer and consumer may be different toolsets on different servers; the key +is the only thing they share, which is why **a data key is a public name**. + +A tool may also tag a parameter `NotAuthored`, which says only that a model +must not write the value — no type, nothing for another toolset to agree with. +An `mcp_state` client narrows that parameter until the only thing it accepts is +a reference to a value some tool already produced; a client that has never +heard of any of this is unaffected. Keeping a value out of the context is client-side work, so an external MCP host -does none of it: served to Claude.ai or ChatGPT, a tagged toolset behaves like +does none of it: served to Claude.ai or ChatGPT, a toolset behaves like any other. Tag for the agents that understand it, and size tool returns for the clients that don't. @@ -51,7 +57,7 @@ Tagging is an accelerator, not a requirement: `mcp_state` moves values across and letting the model point a parameter at one with an `@state:` handle. What the tag buys is that the parameter leaves the model's schema entirely. -Treat `ToolResult`, `Kind`, and the `ui/*` wire protocol as **public API**. The +Treat `ToolResult`, `NotAuthored`, and the `ui/*` wire protocol as **public API**. The state contract, worked through as sequence diagrams — including the trust assumption it rests on — is in **[docs/SESSION-STATE.md](./docs/SESSION-STATE.md)**, with a runnable version diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md index 15b1067..edb3f29 100644 --- a/docs/CONSUMING.md +++ b/docs/CONSUMING.md @@ -122,7 +122,7 @@ TOOLS = [search] # required: non-empty list of tools 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 value with the `Kind` it is — see +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: @@ -223,7 +223,7 @@ user's call: | every tool returns a `ToolResult`, with a required `message` | `to_fastmcp` | | `CREDENTIAL_HEADERS` is a list of header names | `load_credential_headers` | | every `VIEWS` entry names a real tool, with a built bundle on disk | `load_views` | -| every `Kind` sits on a parameter that exists | `with_state_meta` | +| 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 @@ -417,12 +417,11 @@ The fragment is host-agnostic: 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 checked against it, so a local tool is never withheld. `middleware` layers -over `StateCaptureMiddleware` rather than replacing it, so capture and -injection keep working. +nor rewritten. `middleware` layers over `StateCaptureMiddleware` rather than +replacing it, so capture and handles keep working. It returns a `BuiltAgent` — `agent`, `connections`, `tools` (as loaded, before -binding), `withheld`, and `required`, the per-toolset credential-header +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. @@ -479,38 +478,40 @@ from mcp_state import ( StateCaptureMiddleware, bind_all_injected, make_inspect_state, - partition_usable, + owners, publications, state_keys, + with_server_name, ) -tools = await MultiServerMCPClient(connections).get_tools() +# Loaded per server, so each tool records where it came from. The adapter +# takes a `server_name` and stamps it nowhere; without this an undeclared +# capture cannot be keyed `//` like a declared one. +client = MultiServerMCPClient(connections) +tools = [ + with_server_name(tool, server) + for server in connections + for tool in await client.get_tools(server_name=server) +] published = publications(tools) -# Drop tools that can never be called, and say which and why. -agent_tools, withheld = partition_usable(bind_all_injected(tools)) -for item in withheld: - log.warning("withholding %s", item) - agent = create_agent( model, - [*agent_tools, make_inspect_state(state_keys(published))], + [*bind_all_injected(tools), make_inspect_state(state_keys(published))], system_prompt=MY_PROMPT + "\n\n" + SESSION_STATE_PROMPT, - middleware=[StateCaptureMiddleware(published)], + middleware=[StateCaptureMiddleware(published, owners=owners(tools))], ) ``` - **`StateCaptureMiddleware`** — moves large values out of tool returns into - `tool_state`, leaving a `[state updated: …]` breadcrumb in their place, and - reports the reverse — what a tool was *given* from state — as a - `[state used: …]` note. It + `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 stored value can reach a - parameter, and fills it at call time. +- **`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 @@ -518,24 +519,27 @@ agent = create_agent( 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 silently filled parameters with no -explanation — and nothing asks it to carry the provenance the state notes -record into its answers. +breadcrumbs, `@state:` handles and handle-only parameters with no +explanation — and nothing asks it to carry the provenance into its answers. -**Rendering a tool call in your own host.** Both paths need help here, for -opposite reasons. A **declared** parameter is not in the arguments the model -produced at all, so showing those alone presents the call as having run -without the value that decided its result. A **handle** *is* there, but only as -the `@state:` string the model wrote — which names a value without saying -what it held or which tool published it, and a reader cannot expand it. +**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 +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 — key, `via`, kind, and publishing tool. -`supplied(receipts, arguments)` narrows that to the declared ones, being those -the arguments do not already show; select `via == BY_HANDLE` for the rest. -`mcp_agent.host.step_input` is the worked example and does both — that module -holds the host-side helpers and imports no UI framework, so it is reachable -from a base install. +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 +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 +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. + +`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. **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 @@ -549,36 +553,15 @@ class HostState(AgentState): agent = create_agent(model, tools, state_schema=HostState, middleware=[...]) ``` -**What ends up in `withheld`.** Almost always nothing. A tool is only dropped -when all three of these hold: a parameter is tagged -`Kind(..., model_generatable=False)`, the tool's own `inputSchema` marks it -**required**, and **nothing connected declares it publishes that kind**. Untagged -tools, tagged-and-generatable ones (the default), and `model_generatable=False` -on an *optional* parameter are all left callable — they degrade down the ladder -instead. So this is a wiring-error detector for the one flag you opted into, not -a routine filter, and it catches four things: - -| Why a tool was withheld | What to do | -| --- | --- | -| the toolset that publishes the kind isn't deployed | connect it, or drop the flag | -| the kind string is typo'd or has drifted | fix the string — it's checked as wiring, so nothing else catches it | -| something publishes a *near-miss* kind (footprint, not area of interest) | decide which kind is really meant | -| the producer is a third-party server that only ever *detects* its kind | a false positive — don't set `model_generatable=False` when the producer isn't yours | - -That last one is the sharp edge: the check runs at connect and reads -declarations only, so it cannot see a kind that a third-party server will -produce at runtime. Written up as scenario F in -[SESSION-STATE.md](./SESSION-STATE.md). - -> **The one thing that bites.** Injection is a LangGraph `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 injection with no capture: nothing is ever stored, -> so nothing is ever injected, and there is no error to tell you. +> **The one thing that bites.** Handle resolution is a LangGraph +> `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 +> 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, injection finds nothing, -> and no error says so. With one, state and transcript both belong to the +> 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 > that restarts or scales past one replica. @@ -595,59 +578,61 @@ 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. -### 4c. Tagging a tool (optional, and worth it) +### 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 *next* toolset's model reads when it decides which stored value a call +should use. Nothing else crosses between two toolsets — no shared vocabulary, +no imports, no registry. -Tag a value with the `Kind` it is — on a `ToolResult` data key to say what the -tool publishes, on a parameter to say what it takes: +So name for what the value is, not what type it is: ```python -from typing import Annotated, NotRequired +class SearchResult(ToolResult): + area_of_interest: NotRequired[dict] # not `geometry` +``` -from langchain_core.tools import tool -from mcp_runtime.declarations import Kind -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST -from mcp_runtime.tool_result import ToolResult +`geometry` is a poor name because a coverage footprint is also a geometry, and +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: -class SearchResult(ToolResult): - geometry: NotRequired[Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST)]] +```python +from typing import Annotated + +from langchain_core.tools import tool +from mcp_runtime.declarations import NotAuthored +from mcp_runtime.tool_result import ToolResult @tool async def clip_raster( dataset_id: str, - aoi: Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST)], + aoi: Annotated[dict, NotAuthored()], ) -> ToolResult: ... ``` -A kind names *what a value is* and nothing else. Matching is by kind, so the -producing and consuming toolsets can live in different repos on different -servers and neither names the other — the string is the entire contract, which -is why kinds live in `mcp_runtime.kinds` and are added by PR. - -Without the tag, the model points a parameter at a stored value by name -(`@state:`) — about ten tokens. With it, the parameter is **removed from -the model's schema entirely** and filled by the client: no tokens, no turn spent -choosing, and no way for the model to get it wrong or inline a bad value. You -also get the wiring checked at connect and typos caught at `build_server`. +Use it where a plausible-looking invention is worse than no answer: a +2000-vertex catchment boundary, an item collection, a bounding box that has to +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. -`Kind` takes one option, for the judgement only you can make: +It degrades in three steps rather than requiring anything: -```python -aoi: Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST, model_generatable=False)] -``` - -A 2000-vertex catchment boundary and a four-number bounding box are both -"geometry"; only the tool author knows which a model could plausibly produce. -It defaults to `True`, so a parameter whose kind nothing publishes stays visible -to the model and the tool keeps working. Set it `False` and the tool is withheld -instead — but only do that when the value's producer is one of *your* toolsets, -since the connect-time check reads declarations and cannot see a third-party -server's output coming. +| 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 | +| implements `mcp_state` | the schema accepts only `@state:` | -Toolsets advertise both halves on `/health` (`state.produces`, `state.consumes`) -and the index aggregates them, so you can see a deployment's data flow without -speaking MCP. +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 +deployment's data flow without speaking MCP. --- @@ -749,11 +734,11 @@ 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`, `.withheld` and +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 orders the five differently — unpacking one as the other -yields `required` where `withheld` belongs, and nothing complains. +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 @@ -800,7 +785,7 @@ 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=`, deliberately: a response model would re-serialise, and `seq` is *omitted* from a state entry until it is known — a client sorting by -it must never be sorting nulls — while `kind: null` is meaningful and has to +it must never be sorting nulls — while `tool: null` is meaningful and has to stay. Documenting without re-serialising keeps both. ### 5c. The routes @@ -832,10 +817,11 @@ carries the id the thread will store — so a client reconciles in place rather than rebuilding its list. **The read routes are what the stream deliberately leaves out.** The state -channel carries `{kind, tool, bytes}` 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 (`dataset-search/geometry`) -and that slash is part of the key, not a path separator. +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 +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* @@ -872,19 +858,17 @@ async for event in agui_events( thread_id=thread_id, run_id=run_id, tools={tool.name: tool for tool in built.tools}, - withheld=built.withheld, ): yield encoder.encode(event) # already `data: {...}\n\n` ``` AG-UI covers tokens, tool calls and state natively. It has no vocabulary for the -two things this runtime exists to make visible, so both ride `ACTIVITY_*` — an +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: | `activityType` | content | | --- | --- | -| `tools.withheld` | `tools` — each `Unsatisfiable` that dropped a tool (`tool`, `parameter`, `wants`), announced once per run | | `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 | @@ -896,19 +880,21 @@ string is `mcp_agent.host.step_input`'s output, so the wire says exactly what th bundled Chainlit host shows: ``` -← dataset-search/geometry · geojson.AreaOfInterest · 1 feature(s), 0 vertices · from search +@state:dataset-search/search_datasets/area_of_interest · 1 feature(s), 4 vertices · from search_datasets · query written by the model ``` -**Branch on `via`, never on that string.** `declaration` means the model never -saw the parameter; `handle` means it named the value with `@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. `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_DELTA` carries metadata only — `kind`, `tool`, `bytes`, and `seq` once -known — never the stored value, which a frontend fetches when it actually wants +`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. **Patched rather than snapshotted, and that is the point.** A `STATE_SNAPSHOT` diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index ba9175a..37eaa4f 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -10,173 +10,72 @@ needs it, through agent state, without it passing through the model. ## The promise -**Whenever a matching value is in session state, the client's best endeavour is -to get it into the tool call — and to do so in the cheapest way that will -work.** - -There is a ladder, tried in order. Each rung has a name, used throughout this -document: - -1. **FILL** — *fill it silently.* The parameter is removed from the model's - schema and filled from state. Costs nothing, and the model cannot get it - wrong. In the API this path is called **declaration**: a receipt records it - as `via: "declaration"` (`BY_DECLARATION`). -2. **NAME** — *let the model name it.* The parameter's schema is widened to - accept a second form: as well as the value itself, it will take the string - `"@state:dataset-search/geometry"` — a **handle** naming something already in - session state. The client swaps it for the real value on the way to the - server. Costs about ten tokens. In the API this path is called **handle**: - `via: "handle"` (`BY_HANDLE`). -3. **GENERATE** — *let the model generate it.* Ordinary MCP, exactly as if none - of this existed. -4. **WITHHOLD** — *don't offer the tool.* Only when the tool explicitly said a - model must not invent the value and nothing can supply it. - -**GENERATE** is the baseline, not a failure: most parameters of most tools -should land there, since a string, a number or an enum is cheaper for the model -to write than to name. The other three rungs are about the few that aren't. - -**NAME** is available on top of that on any MCP server, with no cooperation -whatsoever *from the server*. **FILL** is the one that needs a tag on the tool — -that is what removes the parameter from the model's schema. **That tag is -entirely optional** — see [What tagging buys you](#what-tagging-buys-you). - -A tag alone never costs you a tool. **WITHHOLD** needs the tag *plus* an -explicit `model_generatable=False`, and is the only rung that is opt-in. - -The ladder is climbed entirely by the client, so it exists only where -`mcp_state` is wired in. The same servers connected to an MCP host that does -none of this behave exactly as they always did. - -### The two paths, side by side - -FILL and NAME are the two rungs that put a stored value into a call. Everything -else in this document is about which of them a parameter gets, and what each -one costs. They differ on every axis that matters: - -| | **FILL** (`via: "declaration"`) | **NAME** (`via: "handle"`) | -| --- | --- | --- | -| What the consumer must do | Tag the parameter `Kind(...)` | Nothing | -| What the producer must do | Nothing *required* — see below | Nothing | -| What the model is offered | Parameter is **gone** from the schema | Parameter, also accepting `@state:` | -| Who picks the value | The client, by matching kind | The model, by naming a key | -| What the call carries | No such argument at all | `"@state:gazet/aoi"` as the argument | -| What it costs | Nothing | About ten tokens | -| Receipt on the artifact | Key, kind, publishing tool, and `via` | The same four fields | -| Told to the model in content | Yes — `[state used: …]` | No — the model wrote the key itself | -| Works on a third-party server | Only if it tags its parameters | **Yes, on any MCP server** | - -**Which side must be tagged, and which need not.** For FILL, the *consumer* is -what matters: the `Kind` tag on the parameter is what removes it from the -model's schema, and without it there is nothing to fill. The *producer* is a -softer requirement, because a stored value gets its kind two ways — from the -publishing tool's own tag, or from -[`detect_kind`](../src/mcp_state/detect.py) reading the value's shape, which -recognises GeoJSON, STAC item collections and bounding boxes from the -discriminators those formats define. - -That splits into two questions with two different answers: - -- **Which rung a parameter gets** is decided once at connect, from - *declarations only* — nothing has run yet, so a detected kind is a value that - may never appear. This is why the wiring check is stricter than runtime; see - [Sharp edges and limits](#sharp-edges-and-limits). -- **Which stored entry satisfies it** is decided per call, and matches on the - entry's kind however that kind arose — including a detected one. - -So a value from a server that has never heard of this project can satisfy a -tagged parameter at runtime, even though it could not have caused that -parameter to be tagged FILL in the first place. +**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 +way to the server. + +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. + +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. + +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. ### What a handle is -"Accepts a handle" is a change to the JSON Schema the *model* sees. A parameter -that could hold a structured value is rewritten into an `anyOf` — its original -schema, or a string beginning `@state:`: +A string, in place of a value, on the way to a tool: -What the server advertises: - -```json -{ - "geometry": { "type": "object", "description": "The geometry to describe." } -} +``` +@state:dataset-search/search_datasets/area_of_interest ``` -What the model is offered: +`offer_handles` rewrites the parameter's JSON Schema so both forms validate: ```json -{ - "geometry": { - "anyOf": [ - { "type": "object" }, - { - "type": "string", - "pattern": "^@state:", - "description": "A session-state reference, e.g. @state:dataset-search/geometry — the key from a [state updated: …] note. The value is substituted before the tool runs, so prefer this over repeating a large value." - } - ], - "description": "The geometry to describe." - } -} +{"anyOf": [ + {"type": "object", "…": "the server's own schema"}, + {"type": "string", "pattern": "^@state:", "description": "A session-state reference…"} +]} ``` -So the model may still send the whole object; it is *also* allowed to send -`"@state:dataset-search/geometry"` instead. It learns which keys exist from the -`[state updated: …]` breadcrumbs capture leaves in the transcript. Just before -the call, `dereference` replaces any argument starting with `@state:` with the -stored value, so **the server receives ordinary GeoJSON and never learns a -handle was involved** — which is why this needs no cooperation from it. +`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. -Two consequences worth knowing up front. Handles are offered on *type*, not on -size — this runs at connect, when nothing has been produced to measure — so a -small object gets the branch too, costing a few schema tokens. And because the -parameter is still in the model's schema, a model determined to inline a -geometry can; NAME makes the cheap path available, where FILL makes it the -only one. +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`. ### A handle only counts as a whole argument -Substitution replaces an argument, not a field inside one. A handle written -into a nested field — `request.area`, on a tool taking an opaque -`dict[str, Any]` — is not substituted, and a permissive schema will not reject -it either, so before this guard the literal `@state:…` string reached the -server and came back as a vendor error quoting our own prefix. - -The call is now refused instead, naming the path and what is in state: - -``` -submit_request was not called. Unresolved session-state references: - request.area: @state:gazet/aoi — a handle is substituted only where it is a - whole argument, never inside one, so this would have reached the tool as text. -Read a value with inspect_state and write the field yourself, or call the tool -that produces it first. - @state:gazet/aoi — geojson.AreaOfInterest, 1 feature(s), 2000 vertices, from get_aoi -``` - -The same check catches a handle naming a key nothing published. If a nested -field genuinely wants a stored value, that is a signal the tool should take it -as a parameter of its own and tag it with a `Kind` — a field inside an opaque -dict is reachable by neither path. - -**A refusal is a result, not an exception.** Every message above reaches the -model as the tool's own `ToolMessage`, with `status="error"` — it is addressed -to the model, names what would fill the parameter and which tool publishes it, -so the model can correct the call and try again within the same turn. - -That is not only politeness. A raised error ends the run and leaves an -assistant message whose `tool_calls` no `ToolMessage` answers, which most -providers reject outright — so one refusal would make the thread unusable for -every turn after it. And the way to provoke one is ordinary: a model that -batches a publisher and its consumer into a **single** assistant message has -both run in one step, against the state as it stood at the start, so the -consumer cannot see the publication happening beside it. Refused as a result, -the model reads the message and sequences the two calls itself. - -`mcp_state.StateRefusal` is the type, so a host can tell "the binding said no" -from "the tool failed". A wrapped tool's own errors are untouched: whatever it -declared for `handle_tool_error` still governs them. +`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. ---- +That is caught rather than sent: `unresolved()` walks the arguments for any +`@state:` left standing, and 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 @@ -184,39 +83,29 @@ Once, at connect, for every parameter of every connected tool: ```mermaid flowchart TD - P["A tool parameter"] --> D{"Tagged with a Kind?"} - - D -->|no| B{"Could it hold a structured value?
schema type object or array"} - D -->|yes| K{"Does any connected tool declare
that it publishes that kind?"} - - K -->|yes| HIDE["RUNG 1 — FILL
Removed from the model's schema.
Filled from state at call time."] - K -->|no| G{"model_generatable?"} - - G -->|"true — the default"| B - G -->|false| R{"Required by the tool's
own input schema?"} + P["A tool parameter"] --> N{"Tagged NotAuthored?"} - R -->|yes| W["RUNG 4 — WITHHOLD
The tool is withheld
from the agent."] - R -->|no| OMIT["Always omitted from the call.
The tool uses its own default."] + N -->|yes| ONLY["Accepts an @state:key handle
and nothing else."] + N -->|no| B{"Could it hold a structured value?
schema type object or array"} - B -->|yes| HANDLE["RUNG 2 — NAME
Stays in the schema, and also
accepts an @state:key handle."] - B -->|no| LEAVE["RUNG 3 — GENERATE
Left alone. A string or a number
is cheaper to generate than to name."] + B -->|yes| HANDLE["Keeps its own schema, and also
accepts an @state:key handle."] + B -->|no| LEAVE["Left alone. A string or a number
is cheaper to generate than to name."] ``` -Note the path from `model_generatable: true` back into the structured check. A -tagged parameter whose kind nobody publishes does not merely fall back to the -model — it falls back to **NAME**, so the model can still point it at a -stored value by name. Degrading never skips a rung. - **"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 while a false no would quietly remove the mechanism from a +few schema tokens and a false no silently withdraws the mechanism from a parameter that needed it. -Capture, below, is the opposite: it has the value in hand, so it weighs 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. ## How a returned value is captured @@ -229,458 +118,273 @@ flowchart TD S -->|yes| DROP["Never stored, at any size —
and dropped from the artifact
whenever capture rewrites it."] S -->|no| D{"Declared by the server?"} - D -->|yes| DECL["Stored under the declared key,
with the declared kind."] + D -->|yes| DECL["Stored under the declared
toolset/tool/field key."] D -->|no| Z{"Serialised size at least
DEFAULT_CAPTURE_BYTES?"} - Z -->|yes| DET["Stored under tool/field, kind
recognised from the value's shape."] + Z -->|yes| DET["Stored under the same three-part key,
if the host recorded which server
the tool came from."] 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. + 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 — which is how the model learns what it can point a handle at. 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. +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. -## Receipts: the other direction +### A data key is a public name -A FILL is invisible from the transcript alone. The parameter was removed from -the schema at connect, so the tool call the model produced does not mention it, -and the result says nothing about which stored value it ran against. +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. -So every parameter session state supplies — by **either** path — leaves a -**receipt** on the tool message's artifact, under `injected_state`: +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. -```json -{"aoi": {"key": "dataset-search/geometry", "via": "declaration", - "kind": "geojson.AreaOfInterest", "tool": "search_datasets"}} -``` +## Receipts: what a call actually ran against -The key, the kind, the tool that published it, and `via` — `declaration` for a -FILL, `handle` for a NAME. 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. A host is free to forward it -onwards — `mcp_agent_api` emits each one as a `state.consumed` activity — which -still costs no context, because that goes to the client and never back to the -model. +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. -What the model sees is a *second*, shorter copy — and only of the FILL ones — -in the message content, beside the `[state updated: …]` note: +So every parameter session state supplies leaves a **receipt** on the tool +message's artifact, under `injected_state`: -``` -Clipped chirps-daily to a 2000-vertex area of interest. - -[state used: aoi ← dataset-search/geometry, published by search_datasets] +```json +{"aoi": {"key": "dataset-search/search_datasets/area_of_interest", + "tool": "search_datasets"}} ``` -So the two paths are recorded identically; what differs is whether the model is -also told. Two things depend on it being told. Where several stored values -share a kind, resolution takes the most recent — without the note the model -cannot tell which it was given, so it can neither correct a wrong pick nor -describe the result accurately. And the transcript is what the model reads back -when asked how a result came about, so the note is what makes a chain of tools -traceable end to end. +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 +client and never back to the model. -A NAME resolution gets no such note, because the model wrote `@state:` -itself: the key is already in the tool call arguments, and repeating it would -buy nothing the transcript does not already hold. +**Nothing is echoed to the model.** It wrote the key itself, so repeating it +would buy nothing the transcript does not already hold. -How the model is told any of this exists is a system prompt, and it ships as a +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 filled parameters and `inspect_state`, and asks the model to -carry the provenance the notes record 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 `docs/CONSUMING.md` §4a/4b). - ---- +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 +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 +`docs/CONSUMING.md`). -# The scenarios +## Provenance: what a call was given -## A. Both ends tagged — nothing reaches the model +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. -`search_datasets` tags its `geometry` output; `clip_raster` tags its `aoi` -parameter with the same kind. Neither names the other, and they are served by -different MCP servers. +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: -```mermaid -sequenceDiagram - actor U as User - participant M as Model - participant A as Agent - participant S as tool_state - participant D as dataset-search - participant R as raster-ops - - U->>A: "clip ERA5 to my area of interest" - A->>M: messages plus tool schemas - Note over A,M: clip_raster advertises only dataset_id.
aoi was removed at connect, so the
model cannot see or generate it - - M-->>A: call search_datasets - A->>D: tools/call search_datasets - D-->>A: message plus geometry, 38 kB - A->>S: write dataset-search/geometry
kind=geojson.AreaOfInterest seq=1 - A->>M: "found 3 datasets" plus breadcrumb - - M-->>A: call clip_raster with dataset_id only - A->>S: resolve kind=geojson.AreaOfInterest - S-->>A: most recent match, seq=1 - Note over A: validated against clip_raster's own
aoi schema. A mismatch counts as absent - A->>R: tools/call clip_raster with dataset_id and aoi - R-->>A: "clipped to 4 tiles" - A->>M: "clipped to 4 tiles" plus
[state used: aoi ← dataset-search/geometry] - M-->>A: answer - A->>U: answer +```python +class StateEntry(TypedDict): + value: Any + tool: NotRequired[str] + seq: NotRequired[int] + #: parameter -> the tool_state key it came from, or "model" + inputs: NotRequired[dict[str, str]] ``` -**FILL.** The model spent zero tokens on the geometry and had no opportunity -to get it wrong. +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. -## B. Nothing tagged anywhere — a third-party server +### Why this is the hole worth plugging -A raw FastMCP server with no `_meta`, no `ToolResult`, and no import from this -project. `describe_geometry(geometry: dict)` advertises -`{"type": "object"}` — which matches every JSON object ever written, so no -client could work out that it wants an area of interest. +`NotAuthored` stops a model writing a value *into that parameter*. It does not +stop this: -```mermaid -sequenceDiagram - actor U as User - participant M as Model - participant A as Agent - participant S as tool_state - participant F as terrain (third-party) - - Note over A,M: describe_geometry's geometry parameter is
offered as an object OR an @state:key string - - U->>A: "describe the area I searched" - A->>M: messages plus tool schemas - Note over M: the breadcrumb from an earlier turn
named dataset-search/geometry - - M-->>A: call describe_geometry
geometry="@state:dataset-search/geometry" - A->>S: look up that key - S-->>A: the 38 kB value - A->>F: tools/call describe_geometry with the real object - Note over F: receives ordinary GeoJSON.
Has no idea any of this happened - F-->>A: "1 feature, 2000 vertices" - A->>M: "1 feature, 2000 vertices" - M-->>A: answer - A->>U: answer -``` - -**NAME.** About ten tokens instead of 38 kB, and the server was never -modified. Capture works the same way in reverse: `elevation_profile` returns a -55 kB array nobody declared, and it is stored on size alone. - -## C. Publisher tagged, consumer not +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. +3. That tool returns `bbox` in its `ToolResult`. +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 mixed case, and what the runnable example actually does. The geometry gets -a proper `geojson.AreaOfInterest` label from `dataset-search`; the third-party -consumer still needs the handle. +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. -This is worth stating on its own, because it is the asymmetry the whole design -turns on: **a well-labelled value does not tell you which parameter wants it.** -A value is a concrete thing that can be inspected. A parameter is a hole. So -the value side is inferable and the parameter side is not, and rather than -guess, NAME asks the model — which is the only party with the conversation in -front of it when more than one stored value would fit. +### A chain, not a taint -## D. Tagged, and the publisher simply has not run yet +Each recorded input names either the model or **another key**, and that key's +entry carries the same record. So a value's history is a walk over facts: -Not a wiring problem. The publisher *is* connected; nothing has called it. - -```mermaid -sequenceDiagram - actor U as User - participant M as Model - participant A as Agent - participant S as tool_state - participant D as dataset-search - participant R as raster-ops - - U->>A: "clip ERA5" - A->>M: messages plus tool schemas - M-->>A: call clip_raster with dataset_id only - A->>S: resolve kind=geojson.AreaOfInterest - S-->>A: no match - Note over A,R: required, so raster-ops is never called - A->>M: "clip_raster needs 'aoi', which is supplied from session
state (geojson.AreaOfInterest) rather than by you, and
nothing in this session has published it.
Run search_datasets first — it publishes this." - M-->>A: call search_datasets - Note over A,S: from here, scenario A +``` +raster-ops/clip_raster/bounds given {dataset_id: model, aoi: dataset-search/search_datasets/area_of_interest} + dataset-search/…/area_of_interest given {query: model} ``` -The error is written **for the model**, because the model is the only party -that can fix it. It can name the tool to run because the client resolved which -connected tools publish each kind once at connect -([`publishers`](../src/mcp_state/middleware.py)). Where *nothing* connected -publishes the kind, the last sentence says so instead — that is a wiring fault -rather than a recoverable turn, and scenario F is where it gets caught. +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. -## E. Tagged, nothing publishes the kind, model may generate +**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. -The default. The tag is dropped at connect, and the parameter falls all the way -back to NAME — visible to the model *and* handle-capable. `preview_extent` in -the runnable example takes a `geo.BoundingBox` nothing there publishes: +### What reads it + +**The listing**, which is what a refusal puts in front of the model: ``` -server advertises: ['bbox', 'dataset_id'] -offered to the model: ['bbox', 'dataset_id'] -bbox also accepts a handle: True +@state:gazet/get_aoi/bbox — 4 item(s), from get_aoi (you wrote: bbox) ``` -So a broken or absent producer costs you FILL and nothing else. The tool -still works, and state can still reach it. - -## F. Tagged `model_generatable=False`, nothing publishes the kind +Only model-authored parameters are named. A parameter filled from state is the +unremarkable case and would cost tokens on every line. -The one case where a tool is taken away. It is also the one place where connect -time and call time disagree, so it is worth being precise. - -```mermaid -sequenceDiagram - participant A as Agent - participant W as wiring check - participant M as Model - - Note over A,W: at connect - A->>W: partition_usable(tools) - W-->>A: clip_to_bbox.bbox wants geo.BoundingBox
— the tool cannot be called - A->>M: tool schemas, with clip_to_bbox omitted entirely - Note over M: never offered the tool,
so never wastes a turn on it -``` +**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. -Measured, both halves. `clip_to_bbox` differs from scenario E's -`preview_extent` in one flag and nothing else — an exact clip must not run on a -box a model sketched — and the value at call time is the bounds a third-party -server really returned, labelled by `detect_kind` rather than by any -declaration: +**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. -``` -Connect time (nothing DECLARES it publishes geo.BoundingBox): - withheld: ['clip_to_bbox.bbox wants geo.BoundingBox — the tool cannot be called'] - would the host offer it? no +--- -Call time (a value DETECTED as geo.BoundingBox is in state): - terrain/bounds = [-3.0, 51.0, -2.0004999999999997, 51.003] - the withheld tool, run anyway: 'Clipped chirps-daily to exactly [-3, 51, -2.0005, 51.003].' -``` +# The scenarios -**The wiring check is deliberately stricter than runtime.** It reads -declarations only, because at connect nothing has run and a *detected* kind is -a value that may never appear. So it withholds a tool that would in fact have -worked. +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`. -That is fail-safe — withholding a working tool beats offering a dead one — but -it has a practical consequence: +## A. A value crosses two toolsets that share nothing -> If the producer of a kind is a third-party server, do **not** put -> `model_generatable=False` on the consumer. Leave it generatable and you get -> scenario E, which is strictly better there. +`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. -Withholding is opt-in, too: it only happens if the host calls -`partition_usable`, which acts on the *fatal* findings alone — a declaration -that merely degrades to the model or to a tool default leaves the tool -callable. `unsatisfiable(tools)` returns all of them, fatal or not, without -acting on any; `raise_unsatisfiable(tools)` refuses to start, on the fatal ones -by default or on every one with `fatal_only=False`. +``` +search_datasets(query="rainfall") + → "Found 3 datasets… [state updated: dataset-search/search_datasets/area_of_interest]" ---- +clip_raster(dataset_id="chirps-daily", + aoi="@state:dataset-search/search_datasets/area_of_interest") + → "Clipped chirps-daily to a 2000-vertex area of interest." +``` -## What tagging buys you +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. -**Tool modifications are entirely optional.** Every scenario above except A and -F works on a server that has never heard of this project. Nothing needs -installing, declaring or configuring for state to move. +## B. Nothing declared anywhere — a third-party server -What a tag adds: +A raw FastMCP server, 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. -| | Untagged (NAME) | Tagged (FILL) | -| --- | --- | --- | -| Value reaches the tool | yes | yes | -| Payload in the transcript | no | no | -| Tokens spent | ~10, naming the key | **0** | -| Model turns spent choosing | one | **none** | -| Can the model get it wrong | yes — it picks | **no — it never sees the parameter** | -| Can the model inline a bad value | yes, the schema still allows it | **no** | -| Wrong-kind value rejected before the call | no | **yes**, by kind and by schema | -| Broken wiring caught before a user hits it | no | **yes**, at connect | -| `Kind` on a parameter that does not exist | n/a | **caught, at `build_server`** | +Nothing on that server changed, and nothing about it was known in advance. -So the tag is worth adding for a tool whose parameter genuinely holds a large -value, and costs nothing to omit. +## C. The value has not been produced yet -A typo in the *kind string* is a different failure, and that last row does not -cover it: `build_server` checks that a tag names a real parameter, never that -the kind is one anybody uses. A mistyped kind surfaces at connect instead, as a -kind nobody publishes — see [Sharp edges and limits](#sharp-edges-and-limits). +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. -### The tag +``` +clip_raster was not called. 'aoi' takes a value that already exists in this +session; you cannot write one. Nothing has been published to session state +yet, so run the tool that produces this first. +``` -One marker, both directions — on a `ToolResult` data key it says what the tool -publishes, on a parameter it says what the tool takes: +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. -```python -class SearchDatasetsResult(ToolResult): - geometry: NotRequired[Annotated[FeatureCollection, Kind(GEOJSON_AREA_OF_INTEREST)]] +## 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 +`NotAuthored` parameter is caught before the call leaves the client: -@tool -async def clip_raster( - dataset_id: str, - aoi: Annotated[FeatureCollection, Kind(GEOJSON_AREA_OF_INTEREST)], -) -> ClipResult | ToolError: ... +``` +clip_raster was not called. 'aoi' was given a value you wrote. It takes a +reference to a value some tool already produced. Pass @state: naming one of: + @state:dataset-search/search_datasets/area_of_interest — 1 feature(s), 2000 vertices, from search_datasets + @state:terrain/elevation_profile/samples — 1200 item(s), from elevation_profile ``` -`Kind` names the semantic type and nothing else. It says nothing about where a -value comes from — that is the client's decision, made against everything it -actually connected to. - -It takes one option, which is the single judgement the client cannot make for -itself: +## What tagging buys you -```python -aoi: Annotated[ - FeatureCollection, Kind(GEOJSON_AREA_OF_INTEREST, model_generatable=False) -] -``` +Nothing at all is required. A server that says nothing still has its large +returns captured and its structured parameters reachable by handle. -A 2000-vertex catchment boundary and a four-number bounding box are both -"geometry"; only the tool author knows which a model could plausibly produce. -It defaults to `True`, because a parameter that stays in the schema degrades to -ordinary MCP, and that is nearly always better than deleting a usable tool. +`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. -There is deliberately no `required=` option. A parameter with a Python default -is optional in the tool's own `inputSchema`, which is exactly the condition -under which a client may leave it out of a call — so `required` is read from -the schema. Nothing to keep in sync, and nothing to police. +It degrades in three steps, by how much the client implements: ---- +| 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 | +| implements `mcp_state` | the schema accepts only `@state:` | ## Sharp edges and limits -**Two publishers of the same kind.** Kind resolution takes the most recently -published entry, which is nearly always the one in play. Where two toolsets -genuinely publish the same kind, prefer making the kinds distinct — this is why -the vocabulary separates `GEOJSON_AREA_OF_INTEREST` from `GEOJSON_FOOTPRINT`. -Where they really are the same, dropping the tag is the escape hatch: NAME -lets the model choose, and it knows which one the user meant. - -**Substitution goes by prefix, not by the parameter's type.** `dereference` -swaps any argument starting with `@state:` for the stored value, including on a -parameter declared `string`, which then receives an object it will reject. That -is a legible error, but it comes from the tool rather than from up front. The -two failures that *are* caught before the call goes out are a handle naming a -key nothing published and one written inside an argument — see -[A handle only counts as a whole argument](#a-handle-only-counts-as-a-whole-argument). - -**Secret-shaped fields are never captured, at any size**, and are stripped from -the artifact too wherever capture rewrites a message — so a host reassembling -the return with `restore_structured` does not receive one either. What the -backstop does *not* do is keep one out of the model's context: a structured -return with no `message` field has its uncaptured fields serialised into the -content, secret-shaped ones included. It protects state, not context — a -toolset should not be returning them at all. - -**This needs `structuredContent`.** A server that returns its geometry as text -content has already put it in the transcript before the client sees it, and -nothing client-side can undo that. `ResourceLink` is the standards-aligned way -for a server to avoid that, and it is handle-passing by another name. - -**Kinds are just strings.** A consumer repo can mint its own without this -package knowing. The wiring check is on whether anything publishes a kind, not -on membership of `mcp_runtime.kinds`, so a typo still surfaces — as a kind -nobody publishes. - -**A UI view has to be handed back what capture took.** A captured tool message -is rewritten to breadcrumb text and the payload moves off the artifact into -`tool_state`. What stays on the artifact is the fields that were *not* captured -plus a `captured_state` map of `{field: stateKey}`, so a host rebuilds the -tool's whole return with `restore_structured(message.artifact, tool_state)` — -and what a host of your own should do. It is a no-op on an uncaptured message, -so there is nothing to branch on. - -Both bundled hosts do exactly this, from the same artifact: `mcp-agent-web` -renders the view directly, and `mcp_agent_api.events` puts the rebuilt data on -an `mcp.view` activity so a browser client can render it from the stream. The -API one has to hold that activity back until the state change lands, because a -publishing tool's writes reach `tool_state` on the *event after* the one that -carries the view. - -Rebuilding this way rather than diffing `tool_state` across turns is -deliberate: a diff cannot tell a re-emitted identical value from no change at -all, nor which of several tools in one turn a key belongs to. - -**A past turn's value is still there, one layer down.** State holds one value -per key by design, so a key a later turn overwrote reads back as the later -value — but the checkpointer keeps an immutable checkpoint per super-step, each -carrying the whole of `tool_state`, so nothing is actually lost. That is what -`mcp_agent_api`'s `GET /threads/{id}/turns` and `?turn=N` on the state route -serve, and a host of your own can read the same history through -`aget_state_history`. Two things follow. Retention belongs to the checkpointer, -so a pruned deployment can answer "that turn is gone" rather than serve it — -which is a different fact from "that turn never existed", and worth telling a -user apart. And it means **every past payload is addressable by thread id**, -not only the current one; see [Trust](#trust), where a thread id is already the -only credential on a state read. - -**Out of the model's context is not out of your traces.** The guarantee here is -about what reaches the *model*. Tracing sits somewhere else entirely: LangChain -passes a `ToolRuntime` into every tool call, and it carries the whole agent -state — messages plus all of `tool_state`. Anything hooking the tool boundary -(a Langfuse `CallbackHandler`, the OpenTelemetry LangChain instrumentation) -therefore records every stored payload on every subsequent call. A tool whose -argument was `{"id": "chirps"}` traced 34 kB in a session holding one 38 kB -geometry. - -This is upstream behaviour, not something this package introduces — a plain -agent with an unmodified tool and no `mcp_state` at all traces the same way, -including the full conversation. `bind_all_injected` makes no difference to it. -Two consequences worth planning for if you wire a tracing backend: the cost is -proportional to how much you have in state rather than to what a call did, and -payloads deliberately kept out of the transcript still leave the process. A -tool's *return* is exposed the same way, since `on_tool_end` fires before -capture rewrites the message. - -**State is only as durable as the checkpointer.** `tool_state` lives on the -graph state, so it persists exactly as far as whatever the agent was compiled -with. Under a checkpointer it belongs to the `thread_id` and survives across -turns for free — the bundled agent uses an in-process one by default and can -be pointed at PostgreSQL. Compile *without* one and every turn starts empty: -capture runs, injection finds nothing, and there is no error, because an empty -namespace is indistinguishable from a fresh session. +**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. ---- +**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. -## Trust - -Everything here is driven by declarations a server makes **about itself**, and -the client honours them unconditionally. Participating is unilateral and free, -so "does not follow the spec" was never a security boundary. A hostile server -can tag a parameter and be handed state, or declare that it publishes a kind -and have its return consumed by another server's tool. - -Undeclared capture widens this a little: a large value from any connected -server can be reached by handle, so a server need not declare anything to get -its output in front of another tool. The model has to name it, which the -transcript records — that is visibility, not control. - -That is acceptable while every server behind the index is yours, which is the -only configuration this is built for today. If an index ever aggregates -third-party servers, the control to add is **per-connection**: filter which -server names may take part at all, once, where `publications` and -`bind_all_injected` are applied. +**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. ---- +**Undeclared captures need the host's help to be keyed consistently.** +`langchain_mcp_adapters` accepts a `server_name` and records it nowhere on the +tool it builds, so a host that wants three-part keys for third-party servers +stamps it itself — `with_server_name` at load, `owners(tools)` into the +middleware. Without that they fall back to `/`. -All of this runs against three real MCP servers — two of ours and one raw -FastMCP — in [`examples/session-state/`](../examples/session-state/). The -console blocks under scenarios E and F are its section 7 verbatim, so a -degradation that stops behaving this way stops printing this way: +**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, +and no part of this will notice. Name data keys accordingly. -```bash -uv run python examples/session-state/demo.py -``` +## Trust -No API key, no network, nothing to start first. +**Every connected server is trusted.** Participating 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. + +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 +`bind_all_injected` are applied. diff --git a/examples/agui-events/README.md b/examples/agui-events/README.md index 24326e1..803a0bf 100644 --- a/examples/agui-events/README.md +++ b/examples/agui-events/README.md @@ -51,12 +51,14 @@ Ask for something the toolsets can do: | | | | --- | --- | -| **find rainfall datasets and clip chirps to that area** | the full turn: tool calls, both receipt paths, a publish, a `ui://` view | -| **anything about contours** | the tool the deployment cannot offer | +| **find rainfall datasets and clip chirps to that area** | the full turn: two tool calls, a publish, a receipt, a `ui://` view | +| **smooth the contours** | a `NotAuthored` parameter nothing has published for — the refusal, and the model reading it | +| **sketch a rough boundary around the Severn catchment and call it Severn** | the other side of it: an *untagged* parameter, so the model writes the polygon, and the panel shows you what it wrote | | **a few paragraphs about anything** | enough tokens to watch them land | Session state persists across turns on the thread, so the second time you ask -for a clip, `clip_raster` is filled from the geometry the first turn published. +for a clip, the model passes `@state:` for a geometry an earlier turn +published — the payload is fetched by the panel, never re-sent. ## Is this really AG-UI? @@ -93,8 +95,8 @@ keeps in it, so state moves by patch — see below. ### What a consumer has to know that the protocol does not tell it **The `activityType` values are ours.** AG-UI standardises the envelope, not the -vocabulary. There are five — `tools.withheld`, `state.consumed`, -`state.published`, `mcp.view`, `answer.citations` — and a client wanting to +vocabulary. There are four — `state.consumed`, `state.published`, `mcp.view`, +`answer.citations` — and a client wanting to style them switches on those strings. A client that does not know them is not stuck: every activity carries a `display` line, generated by the same code the bundled Chainlit host renders, so printing `content.display` is a complete @@ -105,10 +107,10 @@ is documented rather than advertised. **`STATE_DELTA` carries metadata, not state.** This is the sharpest difference. AG-UI's state channel is normally the agent's actual state, and clients render or patch it wholesale. Here each key carries only -`{kind, tool, bytes, seq}` — never the value, because the values are exactly +`{tool, bytes, seq, inputs}` — never the value, because the values are exactly what session state exists to keep out of the conversation. Fetching one is `GET /threads/{id}/state/{key}`, which is outside the protocol entirely. A stock -client showing "state" will show sizes and kinds and think it has everything. +client showing "state" will show sizes and think it has everything. The metadata sits under a **`toolState`** key rather than at the root of the state object, and every operation names a path inside it — `add` of the whole @@ -193,14 +195,44 @@ each receipt where it belongs, and the server emitted it before the answer's text message opened so it cannot land after the answer it explains. **The heavy value is never on the wire.** The state channel carries -`{kind, tool, bytes}` per key. The right-hand panel is built from that; clicking +`{tool, bytes, inputs}` per key. The panel renders `inputs` in full — each +argument of the producing call, and whether it came from the model or from +another key. The state-sourced half is what makes the chain walkable: every key +it names is another row in the same panel, one click away. Dropping it (as the +*model-facing* listing does, where every line costs context) would leave the +panel unable to answer "what does this rest on" without scrolling back to a +call that has long gone. + +`inputs` carries names and keys, never values — a model-authored argument can +be arbitrarily large, the state channel is re-sent every turn, and nothing +filters an argument the way `BLOCKED_KEY_PATTERN` filters a captured field. So +the value is not on the wire. + +The panel shows it anyway, because it does not need the wire to: it joins +`state.published`'s `toolCallId` back to the call in the transcript it already +holds (`producedArguments` in `src/chat.tsx`), and reads the argument off that. +A value sits on a chip after an `=`, against the `←` that marks one which came +from another key: both halves are monospace, so without the marks +`dataset_id chirps` reads as a single token. Long values fold into a +`
` — `sketch_area` is in the deployment +precisely so there is one to fold, since a model asked to draw a boundary +writes a few hundred characters of polygon into an untagged parameter, which is +the case worth seeing and the one that would otherwise fill the panel. + +It normalises what it stores rather than echoing it — closing the ring, +rounding, and stamping the name — which is what any real geometry tool does +and what keeps the card from showing the same polygon as both its value and +its input. It is also the case that defeats inferring provenance from a +*return*: the normalised boundary looks derived, the same tool without the +rounding looks like a passthrough, and the record is of what the call was +given precisely so nothing has to tell those apart. The right-hand panel is built from that; clicking a key fetches `GET /threads/{id}/state/{key}` and shows the 39 kB geometry that the transcript never held. **A minimal client can print `display` and stop.** Every activity carries a rendered line beside its fields, generated by the same code the bundled Chainlit host uses, so the wire and the bundled UI cannot drift. `src/chat.tsx` prints it; -a client with opinions reads `via`, `kind` and `key` instead. +a client with opinions reads `key` and `tool` instead. **No CORS anywhere.** Vite proxies `/api` to the Python process, so the browser sees one origin. CORS belongs to `mcp_agent_api.app`, not to the router. @@ -217,11 +249,12 @@ sees one origin. CORS belongs to `mcp_agent_api.app`, not to the router. | `service/servers.py` | the four MCP servers — the example's stand-in for a deployed index | | `service/settings.py` | one `BaseSettings`, read once | | `toolsets/clip_view` | the session-state example's `clip_raster`, plus `VIEWS` so `mcp.view` has a real `ui://` to report | -| `toolsets/contour_ops` | declares a kind nothing publishes, so it is withheld | +| `toolsets/contour_ops` | takes a value nothing here publishes, so its calls are refused | +| `toolsets/sketch_ops` | leaves a structured parameter open, so the model writes the value itself | The other two servers are [`examples/session-state`](../session-state)'s, -imported off the path: `dataset-search`, which publishes a 38 kB AOI tagged with -a `Kind`, and `terrain`, a raw FastMCP server that declares nothing at all. +imported off the path: `dataset-search`, which publishes a 38 kB area of +interest, and `terrain`, a raw FastMCP server that declares nothing at all. Everything collapses: a tool call opens to its arguments and full result, an activity to its structured content, and `mcp.view` to the bundle itself in an @@ -233,11 +266,6 @@ without expanding anything. Both of these were live bugs found by this example rather than by reading the code, which is the argument for keeping it runnable: -- **`agui_events(withheld=…)` was annotated `Sequence[str]`** while its - docstring took `BuiltAgent.withheld`, a `list[Unsatisfiable]`. The `display` - join raised, the blanket `except` turned it into a `RUN_ERROR` before the - first tool ran, and a deployment withholding one tool got no turn at all. - Fixed in 0.5.3. - **A tool answering in content blocks reached the wire as a Python repr.** `stream_turn` flattened `ToolMessage.content` with `str()`, so `TOOL_CALL_RESULT` carried `[{'type': 'text', 'text': '…'}]`. diff --git a/examples/agui-events/service/agent.py b/examples/agui-events/service/agent.py index a076965..c389334 100644 --- a/examples/agui-events/service/agent.py +++ b/examples/agui-events/service/agent.py @@ -7,8 +7,8 @@ own MCP servers first. What comes back is read by attribute, never by position: the routes want -``.agent``, ``.connections``, ``.tools``, ``.withheld`` and ``.required``, and -the runtime's `BuiltAgent` and dss's carry those five in different orders. +``.agent``, ``.connections``, ``.tools`` and ``.required``, and the runtime's +`BuiltAgent` and dss's may carry them in different orders. """ import logging @@ -17,6 +17,7 @@ from langgraph.checkpoint.memory import InMemorySaver from mcp_agent.main import BuiltAgent, with_session_state +from mcp_state import with_server_name from service import model, servers logger = logging.getLogger(__name__) @@ -31,19 +32,25 @@ async def build() -> BuiltAgent: connections = await servers.start() logger.info("connected %d MCP server(s)", len(connections)) - tools = await MultiServerMCPClient(connections).get_tools() + # Loaded per server so each tool records where it came from: the adapter + # takes a `server_name` and stamps it nowhere, and an undeclared capture + # needs it to be keyed // like a declared one. + client = MultiServerMCPClient(connections) + tools = [ + with_server_name(tool, server) + for server in connections + for tool in await client.get_tools(server_name=server) + ] chat, named = model.build() # `with_session_state` is the three-piece pattern docs/CONSUMING.md - # documents: the capture middleware, `bind_all_injected` reading back out, - # and `inspect_state` for a value the model was only told the key of. It - # also withholds any tool whose required parameter nothing can fill. - agent, withheld = with_session_state(chat, tools, InMemorySaver()) - logger.info( - "built on %s: %d tool(s), %d withheld", named, len(tools), len(withheld) - ) + # documents: the capture middleware, `bind_all_injected` rewriting schemas + # so a stored value can be named, and `inspect_state` for a value the model + # was only told the key of. + agent = with_session_state(chat, tools, InMemorySaver()) + logger.info("built on %s: %d tool(s)", named, len(tools)) # An in-process checkpointer, so threads live as long as this process. A # deployment passes a configured saver — `mcp_agent.main.Checkpointing` # builds one from MCP_AGENT_CHECKPOINT, PostgreSQL included. - return BuiltAgent(agent, connections, tools, withheld, None) + return BuiltAgent(agent, connections, tools, None) diff --git a/examples/agui-events/service/servers.py b/examples/agui-events/service/servers.py index 355acfa..cea2f66 100644 --- a/examples/agui-events/service/servers.py +++ b/examples/agui-events/service/servers.py @@ -5,14 +5,16 @@ module exists so the example is one command instead of five, and it is the only part of `service/` that a real service would not have. -Four servers on ephemeral ports: +Five servers on ephemeral ports: ``dataset-search`` - publishes a 38 kB area of interest, tagged with a `Kind` + publishes a 38 kB area of interest as a `ToolResult` data key ``raster-ops`` - consumes that kind, and carries a ``ui://`` view + takes one by handle, and carries a ``ui://`` view ``contour-ops`` - declares a kind nothing publishes, so its tool is withheld + takes a value nothing here publishes, so its calls are refused +``sketch-ops`` + leaves a structured parameter open, so the model writes the value itself ``terrain`` a raw FastMCP server that declares nothing at all """ @@ -30,7 +32,8 @@ TOOLSETS = { "dataset-search": "dataset_search.tools", "raster-ops": "clip_view.tools", # the example's clip_raster, plus a view - "contour-ops": "contour_ops.tools", # withheld: nothing publishes its kind + "contour-ops": "contour_ops.tools", # nothing here publishes what it takes + "sketch-ops": "sketch_ops.tools", # leaves its parameter open, so watch it } #: The one server with no idea this project exists. diff --git a/examples/agui-events/toolsets/clip_view/tools.py b/examples/agui-events/toolsets/clip_view/tools.py index 3c9b98f..8cfb5a4 100644 --- a/examples/agui-events/toolsets/clip_view/tools.py +++ b/examples/agui-events/toolsets/clip_view/tools.py @@ -15,8 +15,7 @@ from typing import Annotated, Any, NotRequired from langchain_core.tools import tool -from mcp_runtime.declarations import Kind -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST +from mcp_runtime.declarations import NotAuthored from mcp_runtime.tool_result import ToolError, ToolResult @@ -42,7 +41,7 @@ def _rings(geometry: dict[str, Any]) -> list[list[list[float]]]: @tool async def clip_raster( dataset_id: str, - aoi: Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST, model_generatable=False)], + aoi: Annotated[dict, NotAuthored()], ) -> ClipResult | ToolError: """Clip a dataset to the area of interest currently in play.""" features = aoi.get("features", []) diff --git a/examples/agui-events/toolsets/contour_ops/__init__.py b/examples/agui-events/toolsets/contour_ops/__init__.py index a03a3a6..c2ee713 100644 --- a/examples/agui-events/toolsets/contour_ops/__init__.py +++ b/examples/agui-events/toolsets/contour_ops/__init__.py @@ -1 +1 @@ -"""A toolset nothing can satisfy — the source of the `tools.withheld` activity.""" +"""A toolset nothing in this deployment can satisfy yet.""" diff --git a/examples/agui-events/toolsets/contour_ops/tools.py b/examples/agui-events/toolsets/contour_ops/tools.py index c70cd26..2f7f6cb 100644 --- a/examples/agui-events/toolsets/contour_ops/tools.py +++ b/examples/agui-events/toolsets/contour_ops/tools.py @@ -1,21 +1,26 @@ -"""A tool whose declared parameter nothing connected publishes. +"""A tool whose value nothing in this deployment has produced yet. -`geojson.ContourSet` is a kind no server here produces, and the tool says a -model must not invent one — so `partition_usable` withholds it, which is what -`tools.withheld` announces to a client. +`smooth_contours` says a model may not write its `contours`, and no server +here publishes a contour set — so the tool is offered, the model calls it, and +the binding refuses with a message naming what session state actually holds. + +That refusal is the whole demonstration. Nothing is hidden from the model and +no tool is taken away at connect: the deployment cannot know in advance what +will have run by the time a call is made, so it lets the call happen and +answers it with something the model can act on. """ from typing import Annotated from langchain_core.tools import tool -from mcp_runtime.declarations import Kind +from mcp_runtime.declarations import NotAuthored from mcp_runtime.tool_result import ToolResult @tool async def smooth_contours( - contours: Annotated[dict, Kind("geojson.ContourSet", model_generatable=False)], + contours: Annotated[dict, NotAuthored()], ) -> ToolResult: """Smooth a contour set nobody in this deployment can produce.""" return ToolResult(message=f"Smoothed {len(contours.get('features', []))} contours.") diff --git a/examples/agui-events/toolsets/sketch_ops/__init__.py b/examples/agui-events/toolsets/sketch_ops/__init__.py new file mode 100644 index 0000000..ec22521 --- /dev/null +++ b/examples/agui-events/toolsets/sketch_ops/__init__.py @@ -0,0 +1 @@ +"""A toolset that lets a model write the value, so you can watch it do it.""" diff --git a/examples/agui-events/toolsets/sketch_ops/tools.py b/examples/agui-events/toolsets/sketch_ops/tools.py new file mode 100644 index 0000000..4457b9e --- /dev/null +++ b/examples/agui-events/toolsets/sketch_ops/tools.py @@ -0,0 +1,114 @@ +"""The other side of `NotAuthored`: a structured parameter left open. + +`clip_view.clip_raster` tags its `aoi`, so its schema accepts a handle and +nothing else and a model cannot write a polygon into it. `sketch_area` does +not tag `boundary`, which is the ordinary case and the right one here — the +whole point of a sketch is that the model draws it. + +What that costs is visible rather than prevented. The boundary the model +writes is captured into session state like any other data key, and the entry +records that the call which produced it was given a `boundary` the model +wrote. So the panel shows a value resting on a few hundred characters of +invented geometry, and shows the geometry. + +That is the case worth seeing. A model inlining a large literal into an +untagged parameter is what session state exists to avoid, and it is not an +error — it is a judgement the tool's author made, and this is what it looks +like when it goes the other way. + +**The published boundary is normalised, not echoed.** Any real geometry tool +closes the ring and rounds the coordinates, and this one does too — which +matters here beyond realism. A tool that returned its argument unchanged would +publish a value byte-identical to the one the model wrote, and a reader would +see the same polygon twice in one card with nothing to say why. It is also the +case that defeats inferring provenance from a *return*: compare the output to +the arguments and a normalised boundary looks derived, while the same tool +without the rounding looks like a passthrough. The record is of what the call +was given, precisely so nothing has to make that call. +""" + +from typing import Any, NotRequired + +from langchain_core.tools import tool + +from mcp_runtime.tool_result import ToolError, ToolResult + +#: Rough metres per degree at mid-latitudes. Good enough for a sketch, which +#: is the only thing this claims to be. +_METRES_PER_DEGREE = 111_320 + + +class SketchResult(ToolResult): + """The boundary the model drew, and how big it turned out to be.""" + + boundary: NotRequired[dict[str, Any]] + area_km2: NotRequired[float] + + +def _normalised(ring: list[list[float]]) -> list[list[float]]: + """One ring, closed and rounded to a sketch's worth of precision. + + Four decimal places is about 11 m, which is finer than anything drawn by + hand deserves. Closing the ring is not cosmetic: a polygon whose first and + last positions differ is invalid GeoJSON, and a model writing one out by + hand forgets regularly. + """ + rounded = [[round(x, 4), round(y, 4)] for x, y in ring] + if rounded[0] != rounded[-1]: + rounded.append(list(rounded[0])) + return rounded + + +def _ring_area_km2(ring: list[list[float]]) -> float: + """The shoelace area of one ring, in square kilometres.""" + total = 0.0 + for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1], strict=False): + total += x1 * y2 - x2 * y1 + degrees = abs(total) / 2 + return degrees * (_METRES_PER_DEGREE**2) / 1_000_000 + + +@tool +async def sketch_area(name: str, boundary: dict[str, Any]) -> SketchResult | ToolError: + """Record a rough area you have drawn yourself, as a GeoJSON polygon. + + Write the polygon out in full: this takes a sketch, not a reference to + something another tool produced. + """ + features = boundary.get("features") or [] + rings = [ + ring + for feature in features + if isinstance(feature, dict) + for ring in (feature.get("geometry") or {}).get("coordinates") or [] + if isinstance(ring, list) and len(ring) >= 3 + ] + if not rings: + return ToolError( + error="empty_boundary", + detail="Expected a FeatureCollection with at least one polygon ring.", + ) + tidied = [_normalised(ring) for ring in rings] + area = sum(_ring_area_km2(ring) for ring in tidied) + return SketchResult( + message=( + f"Recorded {name!r} — {len(tidied)} ring(s), " + f"{sum(len(ring) for ring in tidied)} vertices, about {area:,.0f} km²." + ), + boundary={ + "type": "FeatureCollection", + "name": name, + "features": [ + { + "type": "Feature", + "properties": {"name": name}, + "geometry": {"type": "Polygon", "coordinates": [ring]}, + } + for ring in tidied + ], + }, + area_km2=round(area, 1), + ) + + +TOOLS = [sketch_area] diff --git a/examples/agui-events/web/package-lock.json b/examples/agui-events/web/package-lock.json index fce4a25..ca671fe 100644 --- a/examples/agui-events/web/package-lock.json +++ b/examples/agui-events/web/package-lock.json @@ -9,7 +9,8 @@ "@ag-ui/client": "^0.0.57", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-markdown": "^10.1.0" + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/react": "^19.2.0", @@ -1645,6 +1646,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -1875,6 +1888,32 @@ "yallist": "^3.0.2" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -1899,6 +1938,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -2097,6 +2237,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -2647,6 +2908,24 @@ "node": ">=0.10.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -2680,6 +2959,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", diff --git a/examples/agui-events/web/package.json b/examples/agui-events/web/package.json index ff5a92d..166676d 100644 --- a/examples/agui-events/web/package.json +++ b/examples/agui-events/web/package.json @@ -12,7 +12,8 @@ "@ag-ui/client": "^0.0.57", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-markdown": "^10.1.0" + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/react": "^19.2.0", diff --git a/examples/agui-events/web/src/agui.ts b/examples/agui-events/web/src/agui.ts index 79c3482..e2b2d70 100644 --- a/examples/agui-events/web/src/agui.ts +++ b/examples/agui-events/web/src/agui.ts @@ -8,17 +8,30 @@ /** One session-state value, as the state route returns it. */ export type StateValue = { key: string; - kind: string | null; tool?: string; + /** Where each argument of the producing call came from: another state key, + * or "model" for one the model wrote itself. */ + inputs?: Record | null; seq?: number | null; /** The turn it was read at, or `null` for "as state stands now". */ turn: number | null; value: unknown; }; +/** What the stream says about one stored value, per key. + * + * Never the value: it is in session state because it was too big for the + * transcript. Enough to decide whether to fetch it from + * `GET /threads/{id}/state/{key}`. + */ +export type StateSummary = Record< + string, + { tool?: string; bytes?: number; inputs?: Record } +>; + /** One session-state value in full — the payload the stream left out. * - * `STATE_SNAPSHOT` carries `{kind, tool, bytes}` per key. This is the route a + * `STATE_SNAPSHOT` carries `{tool, bytes, inputs}` per key. This is the route a * client follows once it has decided it wants the 39 kB geometry, and it is * outside the AG-UI vocabulary entirely: the protocol has a state channel but * no notion of a value too large to put on it. @@ -64,7 +77,7 @@ export async function readThread(threadId: string) { return (await response.json()) as { threadId: string; messages: { id: string; role: string; content?: string | null }[]; - state: Record; + state: StateSummary; }; } @@ -85,7 +98,7 @@ export async function readTurns(threadId: string) { turn: number; question: string; checkpointId: string | null; - state: Record; + state: StateSummary; }[]; }; } diff --git a/examples/agui-events/web/src/chat.tsx b/examples/agui-events/web/src/chat.tsx index 72924aa..0e2d2ba 100644 --- a/examples/agui-events/web/src/chat.tsx +++ b/examples/agui-events/web/src/chat.tsx @@ -1,17 +1,165 @@ import { HttpAgent, type Message } from "@ag-ui/client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { readState, readThread, readTurns } from "./agui"; /** Session state as the stream describes it: no payloads, one line per key. */ type StateEntry = { - kind: string | null; tool?: string; bytes?: number; seq?: number; + /** Parameter -> the state key it came from, or "model". Absent when the + * producing call took no arguments. */ + inputs?: Record; }; +/** Every argument of the call that produced an entry, in a stable order. + * + * Both halves, not just the model's. The listing a *refusal* shows the model + * names only what the model wrote, because every line there costs context and + * "this one came from state" is the unremarkable case. A panel has neither + * constraint and a reader has no memory of the call, which by now has scrolled + * away — dropping half the record here would leave the chain unreadable from + * the one surface built to show it. + * + * One level, deliberately: this reads the call that produced the entry and + * follows nothing further. The reader follows it by clicking, since every + * state-sourced input names a key that is itself a row in this panel. */ +function producedBy(entry: StateEntry): [string, string][] { + // Model-authored first: it is the caveat, and a reader scanning a column of + // these is looking for it rather than for the unremarkable half. + return Object.entries(entry.inputs ?? {}).sort( + ([a, from], [b, other]) => + Number(other === "model") - Number(from === "model") || + a.localeCompare(b), + ); +} + +/** `state key -> the arguments of the call that produced it`. + * + * The value a model wrote is deliberately *not* on the wire: `inputs` carries + * parameter names and state keys and nothing else, because an argument can be + * arbitrarily large and the state channel is re-sent every turn. A client does + * not need it to be — it already holds the call. `state.published` names the + * `toolCallId`, the transcript holds that call, and this is the join. + * + * Read across every message rather than one turn's, so a key published three + * turns ago still resolves. + */ +function producedArguments( + all: readonly Message[], +): Record> { + const calls: Record> = {}; + for (const message of all) { + for (const call of (message as any).toolCalls ?? []) { + try { + calls[call.id] = JSON.parse(call.function.arguments || "{}"); + } catch { + calls[call.id] = {}; + } + } + } + const found: Record> = {}; + for (const message of all) { + if ((message as any).activityType !== "state.published") continue; + const content = (message as any).content; + const args = calls[content?.toolCallId]; + if (!args) continue; + for (const key of Object.values(content?.published ?? {})) { + found[key] = args; + } + } + return found; +} + +/** How much of a model-authored value fits on a line before it is folded. */ +const INLINE = 56; + +/** Whether a value is a string holding JSON — an object or an array. + * + * Providers differ on whether a structured argument arrives as an object or + * as the text of one, and the server coerces either. Rendering the text form + * with `JSON.stringify` escapes it a second time, which helps nobody. Only + * objects and arrays qualify: a model that wrote the string "4" wrote a + * string, and quoting it is the honest rendering. */ +function isJsonText(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "object" && parsed !== null; + } catch { + return false; + } +} + +/** A value as the tool received it, indented. */ +function pretty(value: unknown): string { + return isJsonText(value) + ? JSON.stringify(JSON.parse(value), null, 2) + : JSON.stringify(value, null, 2); +} + +/** The value the model wrote, shown whole or folded. + * + * The case worth seeing is the expensive one — a model inlining a large + * literal into an untagged parameter — and that is exactly the case that + * would fill the panel. `
` because collapsing is what the element is + * for, and the keyboard and screen-reader behaviour comes with it. + */ +function Wrote({ value }: { value: unknown }) { + if (value === undefined) { + return written by the model; + } + // Quoted, so a string reads as a value rather than as a second identifier + // beside the parameter — except where it is the text of an object, which + // `JSON.stringify` would escape twice. + const whole = isJsonText(value) ? value : JSON.stringify(value); + if (whole.length <= INLINE) { + return ( + <> + {whole} + · written by the model + + ); + } + return ( +
+ + {whole.slice(0, INLINE)}… + · written by the model + · {whole.length} chars + +
{pretty(value)}
+
+ ); +} + +/** A state key that may wrap, preferring its own separators. + * + * `//` has no spaces, so a narrow column breaks it + * mid-word — `datas / ets` — unless it is told where the seams are. `` + * marks them; `overflow-wrap: break-word` remains the fallback for a segment + * too long to fit on its own. */ +function Key({ value }: { value: string }) { + const parts = value.split("/"); + return ( + <> + {parts.map((part, index) => ( + + {index > 0 ? ( + <> + / + + ) : null} + {part} + + ))} + + ); +} + /** Every key the thread holds, which is what the state channel describes. */ type Snapshot = Record; @@ -52,8 +200,31 @@ function applyDelta(state: Snapshot, delta: Operation[]): Snapshot { return next; } -/** Where a key came from, read off the `state.published` that announced it. */ -type Origin = { toolCallId: string; tool: string; activityId: string }; +/** The activity messages naming one tool call — `mcp.view` and both state + * halves alike. + * + * A turn's `published` origins reach only `state.published`, because that is + * the one activity keyed by what it wrote. A view is published under no key + * and a `state.consumed` writes none, so neither is in that map. Every + * activity carries the `toolCallId` it belongs to, and an activity is a + * message, so reading the messages is what covers all three. + */ +function activitiesOf(all: readonly Message[], toolCallId: string): string[] { + return all + .filter( + (message) => + (message as any).role === "activity" && + (message as any).content?.toolCallId === toolCallId, + ) + .map((message) => String(message.id)); +} + +/** Where a key came from, read off the `state.published` that announced it. + * + * The announcing activity is not held here: both hover paths resolve + * activities through `activitiesOf`, which finds all three rather than only + * the one that named a key. */ +type Origin = { toolCallId: string; tool: string }; /** One question and what it did. * @@ -229,13 +400,18 @@ export function Chat() { const [opened, setOpened] = useState<{ key: string; turn: number | null; - kind: string | null; value?: unknown; error?: string; } | null>(null); const [folded, setFolded] = useState(false); const [linked, setLinked] = useState(NOTHING); const [running, setRunning] = useState(false); + // The message currently receiving tokens, or null. Bracketed by the stream's + // own TEXT_MESSAGE_START/END rather than inferred from the transcript: "the + // newest assistant message" is a different claim, and it is wrong twice — + // before this turn has written anything it names the last turn's answer, and + // a tool call with no preamble is an assistant message with no text. + const [writing, setWriting] = useState(null); const busy = useRef(false); const [question, setQuestion] = useState( "find rainfall datasets and clip chirps to that area", @@ -294,7 +470,10 @@ export function Chat() { questionId: message.id, from: index, state: (past?.history[n]?.state ?? {}) as Snapshot, - published: origins(all.slice(0, starts[n + 1]?.index ?? all.length), index), + published: origins( + all.slice(0, starts[n + 1]?.index ?? all.length), + index, + ), })); agent.setMessages(all); @@ -336,11 +515,7 @@ export function Chat() { continue; } for (const key of Object.values(content?.published ?? {})) { - found[key] = { - toolCallId: content.toolCallId, - tool: content.tool, - activityId: String(message.id), - }; + found[key] = { toolCallId: content.toolCallId, tool: content.tool }; } } return found; @@ -379,7 +554,9 @@ export function Chat() { const patch = (change: (turn: Turn) => Turn) => setTurns((held) => - held.map((turn, index) => (index === held.length - 1 ? change(turn) : turn)), + held.map((turn, index) => + index === held.length - 1 ? change(turn) : turn, + ), ); try { @@ -389,11 +566,18 @@ export function Chat() { await agent.runAgent(undefined, { onEvent: ({ messages }) => { setMessages([...messages]); - patch((turn) => ({ ...turn, published: origins(messages, turn.from) })); + patch((turn) => ({ + ...turn, + published: origins(messages, turn.from), + })); }, + // Where the caret goes. The protocol brackets one assistant message's + // text with these two, which is exactly what the caret claims. + onTextMessageStartEvent: ({ event }) => setWriting(event.messageId), + onTextMessageEndEvent: () => setWriting(null), // Session state arrives on AG-UI's standard `state` channel as // patches, every one of them under `toolState`. Each entry carries - // `{kind, tool, bytes, seq}`; see the README. + // `{tool, bytes, seq, inputs}`; see the README. // // Applied rather than merged: the operations say what changed, // including a key leaving, which a merge could not express. The one @@ -417,6 +601,10 @@ export function Chat() { } finally { busy.current = false; setRunning(false); + // A run that fails between START and END never sends the END, which + // would otherwise leave the caret blinking on a message nothing is + // writing to. + setWriting(null); } } @@ -441,7 +629,7 @@ export function Chat() { setFolded(false); try { const got = await readState(agent.threadId, key, at); - setOpened({ key, turn: got.turn, kind: got.kind, value: got.value }); + setOpened({ key, turn: got.turn, value: got.value }); } catch (error) { // A turn the checkpointer has pruned answers 410 with a sentence saying // so. Showing it beats a blank panel: "gone" and "never existed" are @@ -449,13 +637,17 @@ export function Chat() { setOpened({ key, turn: at ?? null, - kind: null, error: (error as Error).message, }); } } - /** Light a key, and with it the call and activity that produced it. */ + /** Light a key, and with it the call and every activity about that call. + * + * The same set `litByCall` lights, deliberately: one relationship should + * light identically whichever end of it is hovered, or the pair reads as two + * coincidences rather than one link. + */ function litByKey(key: string) { const origin = turn?.published[key]; setLinked( @@ -463,13 +655,18 @@ export function Chat() { ? { keys: [key], calls: [origin.toolCallId], - activities: [origin.activityId], + activities: activitiesOf(messages, origin.toolCallId), } : { ...NOTHING, keys: [key] }, ); } - /** Light a call, and with it every key it wrote and the activity saying so. */ + /** Light a call, and with it every key it wrote and every activity about it. + * + * Every activity, not only the ones announcing a key: a call's `mcp.view` + * is the row hardest to attribute by eye, since several tools in a turn + * each produce one and the rows are identical but for the URI. + */ function litByCall(toolCallId: string) { const wrote = Object.entries(turn?.published ?? {}).filter( ([, origin]) => origin.toolCallId === toolCallId, @@ -477,23 +674,17 @@ export function Chat() { setLinked({ keys: wrote.map(([key]) => key), calls: [toolCallId], - activities: wrote.map(([, origin]) => origin.activityId), + activities: activitiesOf(messages, toolCallId), }); } - // The one answer still being written, so the caret marks where tokens are - // landing rather than trailing every reply the thread has ever held. - const writing = running - ? messages.reduce( - (last, message) => (message.role === "assistant" ? message.id : last), - null, - ) - : null; - const entries = Object.entries(turn?.state ?? {}).sort( ([leftKey, left], [rightKey, right]) => (left.seq ?? 0) - (right.seq ?? 0) || leftKey.localeCompare(rightKey), ); + // What the model actually wrote, recovered from the calls the transcript + // holds. Nothing on the wire carries it; see `producedArguments`. + const wroteFor = useMemo(() => producedArguments(messages), [messages]); return (
@@ -527,8 +718,15 @@ export function Chat() { ** or a table with one row. react-markdown re-parses each delta, so it degrades to plain text rather than showing syntax, and renders no raw HTML, which matters when the - text came from a model. */} - {String(message.content ?? "")} + text came from a model. + + GFM because a model asked to compare things answers with a + table, and tables are not CommonMark — without this the + pipes are the output. Strikethrough and bare URLs come with + it. */} + + {String(message.content ?? "")} + {(message as any).toolCalls?.map((call: any) => ( //
rather than state: collapsing is what the // element is for, and the keyboard and screen-reader @@ -562,18 +760,29 @@ export function Chat() { linked.activities.includes(String(message.id)) ? "lit" : "" }`} onMouseEnter={() => { + // Whatever the activity is, not only `state.published`: + // the question a reader has in front of a view or a + // consumed receipt is which call it belongs to, and the + // `toolCallId` answering it is on all three. `keys` stays + // empty for the two that publish nothing. const content = (message as any).content; - if ((message as any).activityType !== "state.published") return; + if (!content?.toolCallId) return; setLinked({ - keys: Object.values(content?.published ?? {}), - calls: [content?.toolCallId], - activities: [String(message.id)], + keys: Object.values(content.published ?? {}), + calls: [content.toolCallId], + activities: activitiesOf(messages, content.toolCallId), }); }} onMouseLeave={() => setLinked(NOTHING)} > {(message as any).activityType} + {(message as any).content?.tool ? ( + <> + {" "} + {(message as any).content.tool} + + ) : null} {shown((message as any).content)} {(message as any).content?.uri ? ( @@ -650,8 +859,7 @@ export function Chat() { ) : (

What the tools exchanged without the model reading it. The stream - carries this much per key and no payload; the value is a fetch - away. + carries this much per key and no payload; the value is a fetch away.

)} @@ -664,19 +872,50 @@ export function Chat() { onMouseEnter={() => litByKey(key)} onMouseLeave={() => setLinked(NOTHING)} > - +
+ + {producedBy(entry).length > 0 ? ( + <> +

+ inputs to {entry.tool} +

+
    + {producedBy(entry).map(([parameter, from]) => ( +
  • + {parameter} + + {from === "model" ? " = " : " ← "} + + {from === "model" ? ( + + ) : ( + + )} +
  • + ))} +
+ + ) : null} +
); })} @@ -719,7 +958,6 @@ export function Chat() { {opened.turn === null ? "as state stands now" : `as it stood at the end of turn ${opened.turn}`} - {opened.error ? null : ` · ${opened.kind ?? "untyped"}`}

{opened.error ? (

{opened.error}

diff --git a/examples/agui-events/web/src/style.css b/examples/agui-events/web/src/style.css index 720c49c..d0f32df 100644 --- a/examples/agui-events/web/src/style.css +++ b/examples/agui-events/web/src/style.css @@ -31,7 +31,10 @@ body { margin: 0; background: var(--paper); color: var(--ink); - font: 15px/1.55 ui-sans-serif, system-ui, sans-serif; + font: + 15px/1.55 ui-sans-serif, + system-ui, + sans-serif; } code, @@ -182,6 +185,12 @@ details[open] > summary::before { font-weight: 600; } +/* The tool an activity belongs to, in the colour of the call rather than of + the activity: it is a pointer at something blue, and reads as one. */ +.activity code { + color: var(--tool); +} + details > *:not(summary) { display: block; margin: 0.4rem 0 0 1em; @@ -251,6 +260,12 @@ details pre { .said table { border-collapse: collapse; + /* A comparison table is wider than a chat column. Scroll it on its own, + like `pre` above, rather than letting it set the width of the page. */ + display: block; + width: max-content; + max-width: 100%; + overflow-x: auto; } .said th, @@ -317,14 +332,30 @@ aside p { font-size: 13px; } +/* The card a stored value gets. It holds the button *and* the record of what + the producing call was given, so both read as one thing — the button alone + cannot, since the record contains buttons of its own. */ +.card { + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--paper); + padding: 0.5rem 0.7rem; + overflow: hidden; +} + .key { display: flex; flex-direction: column; gap: 0.15rem; width: 100%; + padding: 0; + border: 0; + border-radius: 0; + background: none; text-align: left; - background: var(--paper); - overflow-wrap: anywhere; + /* `break-word` rather than `anywhere`: a key carries at its own + separators, and this is only the fallback for a segment too long to fit. */ + overflow-wrap: break-word; } /* --- turns --------------------------------------------------------------- @@ -378,6 +409,143 @@ aside p { border-left-width: 3px; } +/* Where the call that produced a value got each of its arguments. Both halves + are here: the model's, and the keys it named — the second is what makes the + chain walkable from the panel, which is the surface a reader has left once + the call itself has scrolled away. */ +/* Says what the lines under a value are. Without it they read as more + attributes of the value, when they are the arguments of a different thing — + the call that produced it. Named for the wire field so a reader who opens + the state route recognises what they are looking at. */ +.inputs-label { + margin: 0.45rem 0 0.25rem; + padding: 0.4rem 0 0; + border-top: 1px solid var(--line); + font-size: 10px; + letter-spacing: 0.04em; + color: var(--dim); +} + +/* The tool keeps its own casing — it is an identifier, not a caption. */ +.inputs-label code { + font-size: 10px; + color: var(--ink); +} + +.inputs { + margin: 0; + padding: 0; + list-style: none; + font-size: 11px; + line-height: 1.45; + color: var(--dim); +} + +.inputs li + li { + margin-top: 0.25rem; +} + +/* The parameter and the value are both monospace, so without a mark between + them `dataset_id chirps` reads as one token. `=` for what the model wrote, + `←` for what came from another key — two relations, told apart. */ +.rel { + color: var(--dim); +} + +/* These are prose, not identifiers: let them wrap at spaces like prose. + `overflow-wrap` inherits, so a value's break-word would otherwise reach + them and split "written". */ +.inputs li { + overflow-wrap: normal; + word-break: normal; +} + +.inputs code { + font-size: 11px; + overflow-wrap: break-word; +} + +/* The parameter, against the origin beside it. */ +.param { + color: var(--ink); +} + +/* The value the model wrote. Shown because "dataset_id written by the model" + leaves a reader to go hunting for the thing they were just warned about — + and set on a chip, because it sits beside a parameter in the same monospace + and would otherwise read as a continuation of the name. */ +.wrote { + padding: 0 0.25rem; + border-radius: 0.2rem; + background: var(--sunk); + font-size: 11px; + color: var(--ink); + overflow-wrap: break-word; +} + +/* A value too long to sit on a line. The case worth seeing is the expensive + one, which is also the one that would fill the panel. */ +.folded { + display: inline; +} + +.folded summary { + display: inline; + cursor: pointer; + list-style: none; +} + +.folded summary::-webkit-details-marker { + display: none; +} + +.folded summary::before { + content: "▸ "; + color: var(--dim); +} + +.folded[open] summary::before { + content: "▾ "; +} + +.folded pre { + margin: 0.3rem 0 0; + padding: 0.4rem 0.5rem; + max-height: 14rem; + overflow: auto; + border-radius: 0.3rem; + background: var(--sunk); + font-size: 10px; + line-height: 1.4; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +/* An argument the model wrote rather than one a tool supplied. Not an error — + a caveat, so it reads as one. */ +.authored { + color: var(--activity); +} + +/* The key an argument came from: another row in this panel, one click away. */ +.from { + display: inline; + padding: 0; + border: 0; + border-radius: 0; + background: none; + color: var(--tool); + cursor: pointer; + font: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + text-align: left; + overflow-wrap: break-word; +} + +.from:hover { + text-decoration: underline; +} + /* Marks a key this turn wrote, against the ones it inherited. */ .new { margin-right: 0.3rem; diff --git a/examples/session-state/README.md b/examples/session-state/README.md index 8e303e5..fcdce04 100644 --- a/examples/session-state/README.md +++ b/examples/session-state/README.md @@ -1,165 +1,145 @@ # Session state, end to end -A runnable demonstration of [`docs/SESSION-STATE.md`](../../docs/SESSION-STATE.md): -a value produced by a tool on one MCP server reaching a tool on another, -without passing through the model — including a server that has never heard of -this project. +Three real MCP servers, one agent, one scripted conversation — and a 38 kB +geometry that moves from the tool that produced it to the tools that need it +without ever entering the transcript. ```bash uv run python examples/session-state/demo.py ``` -No API key, no network, nothing to start first. It serves three MCP servers on -ephemeral local ports and drives a scripted chat model, so the only thing faked -is the model. +No API key and no network: the chat model is a stub replaying a fixed script. +Everything else is real — three uvicorn servers, MCP over HTTP, declarations +travelling as `_meta`. -## What's here +## What is here | | | | --- | --- | -| `toolsets/dataset_search/tools.py` | Publishes a 38 kB area of interest, tagged `Kind(GEOJSON_AREA_OF_INTEREST)` | -| `toolsets/raster_ops/tools.py` | Takes one, tagged with the same kind and `model_generatable=False`. Two more tools take a kind nothing publishes, to show what degrading looks like | -| `foreign_server.py` | **Raw FastMCP. No `ToolResult`, no `Kind`, no import from `mcp_runtime`.** | -| `demo.py` | Serves all three, connects an agent, reports what happened | +| `demo.py` | Starts the servers, drives the agent, prints seven sections | +| `toolsets/dataset_search/tools.py` | Publishes a 38 kB `area_of_interest` data key | +| `toolsets/raster_ops/tools.py` | Three tools that differ only in who may write their parameter | +| `foreign_server.py` | **Raw FastMCP. No `ToolResult`, no import from `mcp_runtime`.** | -The two toolsets follow the plugin contract and neither imports the other; the -only thing they share is a kind string. The foreign server shares nothing at -all — it is there to prove the mechanism does not depend on cooperation. +`dataset_search` and `raster_ops` are separate packages on separate servers. +Neither imports the other, and neither names the other. The only thing they +share is a **state key** — a name a model reads. The foreign server shares +nothing at all and still takes part. -## The two paths +## What it shows -**Declared, when a server tags a parameter.** `clip_raster` takes an -`aoi: Annotated[dict, Kind(...)]`, so the client matches the kind, fills the -value, and removes the parameter from the model's schema entirely: +**A name is the whole contract.** `search_datasets` returns an +`area_of_interest` data key, which lands in session state as: ``` -clip_raster - server advertises: ['aoi', 'dataset_id'] - model is offered: ['dataset_id'] -``` - -The model emitted `dataset_id` and nothing else. It could not have got the -geometry wrong, because it never saw that there was one. - -**Undeclared, when nothing is tagged.** The foreign server's -`describe_geometry(geometry: dict)` declares nothing, so the client cannot know -that parameter wants an area of interest — a structured parameter's schema is -`{"type": "object"}`, which matches every object ever written. Instead it adds -a second accepted form: - -``` -describe_geometry - server advertises: ['geometry'] - model is offered: ['geometry'] - geometry: object, or an @state: handle +dataset-search/search_datasets/area_of_interest ``` -The model passes `@state:dataset-search/geometry` — about ten tokens, read off -the `[state updated: …]` breadcrumb — and the client swaps in the payload -before the call. The foreign server receives an ordinary GeoJSON object and -has no idea any of this happened. +The model reads that off a `[state updated: …]` breadcrumb and passes +`@state:` to `clip_raster` on the other server. About ten tokens, and +the 38 kB payload never enters the conversation. -That is the trade: the declared path costs zero tokens and the model *cannot* -get it wrong; the undeclared path costs ten tokens, works against anything, and -the model chooses — which is also the only party with the conversation in front -of it when there is more than one candidate. +The field is called `area_of_interest` rather than `geometry` deliberately. +A coverage footprint is also a geometry, and the two are identical JSON — the +name is the only thing that says which is which, and it is what the model reads +when choosing. -## What else the run shows - -**Capture does not need a declaration either.** `elevation_profile` returns a -55 kB `samples` array that nothing declared. It is captured on size alone: +**Three parameters, three different answers about who may write them.** ``` -elevation_profile/samples — 54.7 kB, from elevation_profile - kind=unrecognised (captured on size) +clip_raster + aoi: an @state: handle and nothing else +preview_extent + bbox: a value, or an @state: handle +clip_to_bbox + bbox: an @state: handle and nothing else ``` -`unrecognised` is honest — a list of `{distance_m, elevation_m}` is not a shape -the detectors know. It can still be handed to a tool by name; it just cannot be -matched to a parameter automatically. That is exactly what a `Kind` tag buys. +`preview_extent` renders a rough preview, so a model sketching a box is a fine +answer and its parameter is left alone. `clip_to_bbox` clips to an *exact* +extent, where a guessed box is worse than no answer — same JSON, different call, +so it is `NotAuthored`. Only the tool's author can make that distinction. -**A filled parameter says where it came from.** `clip_raster`'s `aoi` was never -in the model's schema, so nothing in the call it produced mentions it. The -result carries the join instead: +**Nothing is required of a server.** The foreign server's +`describe_geometry(geometry: dict)` declares nothing at all. Its parameter is +structured, so it gains the handle branch anyway and the model points it at the +same stored value. Its `elevation_profile` returns a 55 kB array nobody +declared, captured on size alone: ``` -Clipped chirps-daily to a 2000-vertex area of interest. -[state used: aoi ← dataset-search/geometry, published by search_datasets] +terrain/elevation_profile/samples + 54.7 kB, from elevation_profile (captured on size) ``` -`describe_geometry` gets no such note — the model wrote `@state:…` itself, so -the key is already in its tool call. +**A call names its value, and the record says what that was.** The transcript +holds `@state:dataset-search/search_datasets/area_of_interest` — a key, not a +payload. The receipt on the tool message's artifact says which tool published +it and what shape it was, which is what a host renders and what the model is +*not* charged for. -**The payload never entered the transcript.** +**Every value says what its call was given.** Section 6 walks one: ``` -area of interest: 38.5 kB -whole transcript: 896 bytes -a vertex (-2.5) in it: no +raster-ops/clip_raster/bounds + from clip_raster, given {'dataset_id': 'model', 'aoi': 'dataset-search/…/area_of_interest'} + ...of which the model wrote: dataset_id + dataset-search/search_datasets/area_of_interest + from search_datasets, given {'query': 'model'} + ...of which the model wrote: query ``` -Both tools ran against all 2000 vertices. - -## What degrading looks like +Each recorded argument names either the model or *another key*, so a value's +history is a walk over facts rather than a flag anything propagated. Nothing +refuses on it — it exists so that a value the model chose is visible where it +is later reused, which is the one place the transcript cannot help, because the +call that produced it has scrolled away. -Section 7 runs the two cases where a tagged parameter asks for a kind **nothing -connected declares it publishes**. `preview_extent` and `clip_to_bbox` both take -a `geo.BoundingBox`, and differ in one flag: a preview may run on a box a model -sketched, an exact clip may not. - -**A model-generatable tag costs you nothing but the fill.** The declaration is -dropped and the parameter falls back to the general path — in the schema, and -handle-capable: +**The binding refuses what it cannot serve.** Section 7 runs both failures for +real. A model writing its own geometry into `clip_raster`: ``` -server advertises: ['bbox', 'dataset_id'] -offered to the model: ['bbox', 'dataset_id'] -bbox also accepts a handle: True +clip_raster was not called. 'aoi' was given a value you wrote. It takes a +reference to a value some tool already produced. Pass @state: naming one of: + @state:dataset-search/search_datasets/area_of_interest — 1 feature(s), 2000 vertices, from search_datasets + … ``` -**`model_generatable=False` takes the tool away**, and this is the one place -connect time and call time disagree: +And `clip_to_bbox` called with nothing in state at all: ``` -Connect time (nothing DECLARES it publishes geo.BoundingBox): - withheld: ['clip_to_bbox.bbox wants geo.BoundingBox — the tool cannot be called'] - would the host offer it? no - -Call time (a value DETECTED as geo.BoundingBox is in state): - terrain/bounds = [-3.0, 51.0, -2.0004999999999997, 51.003] - the withheld tool, run anyway: 'Clipped chirps-daily to exactly [...].' +clip_to_bbox was not called. 'bbox' takes a value that already exists in this +session; you cannot write one. Nothing has been published to session state yet, +so run the tool that produces this first. ``` -That bounding box is what the *foreign* server returned from -`describe_geometry`, labelled by `detect_kind` reading its shape rather than by -any declaration. The wiring check never sees it, because at connect nothing has -run and a detected kind is a value that may never appear — so it withholds a -tool that would have worked. Fail-safe, and the reason not to put -`model_generatable=False` on a consumer whose producer is somebody else's -server. +Both arrive as an error *result*, not an exception: the assistant message's +tool calls are all answered, the transcript stays well-formed, and the model +reads the message and retries. + +**Nothing is taken away at connect.** `clip_to_bbox` is offered even though +nothing here publishes a bounding box, because a producer might run later in the +same turn. The client cannot know at connect what will have run by the time a +call is made, so it lets the call happen and answers it with something +actionable. ## Things to try -- **Break the wire.** Change the kind on `clip_raster`'s `aoi` in - `raster_ops/tools.py` to something nothing publishes. Section 2 reports it and - the tool is withheld from the agent, rather than failing when a user finally - triggers it — and because the scripted run needs that tool, the demo says so - and stops. -- **Let the model try instead.** Drop `model_generatable=False` from that same - broken declaration: the tool comes back, with `aoi` visible to the model - again — the behaviour of a client implementing none of this. -- **Publish the missing kind.** Tag a `bbox` field on `search_datasets`'s result - with `Kind(BBOX)`. Section 2 goes quiet, `clip_to_bbox` is offered, and both - section 7 cases stop being degradations. -- **Remove the tag entirely.** Delete the `Kind` from `clip_raster`'s `aoi` and - it falls back to the general path: the parameter reappears in the schema, now - with a handle branch, and the model has to point it at the geometry the same - way `describe_geometry` does. -- **Raise the capture threshold.** `StateCaptureMiddleware(published, - capture_undeclared=None)` turns undeclared capture off; the foreign server's - 55 kB array then stays in the transcript, which is the cost of not capturing. - -## Not installed - -This directory is outside `src/`, so it is not in the wheel and reaches nobody -who installs the package. It is here to be read and run from a checkout. +- **Rename a data key.** Change `area_of_interest` to `geometry` in + `dataset_search/tools.py` and watch the key the model has to recognise get + less informative. Nothing breaks — that is the point, and the risk. +- **Drop the `NotAuthored()` on `clip_raster`'s `aoi`.** Section 2 will show + it accepting a literal again. The scripted model still passes a handle, but + nothing now stops it inlining a geometry. +- **Add `NotAuthored()` to `preview_extent`'s `bbox`.** It joins `clip_to_bbox` + in section 2, and a call with nothing in state is refused rather than + previewing a guessed box. +- **Turn undeclared capture off** — `StateCaptureMiddleware(published, + capture_undeclared=None)` in `demo.py`. The foreign server's 55 kB array goes + back into the transcript, and section 5 shows the difference. + +## See also + +- [`docs/SESSION-STATE.md`](../../docs/SESSION-STATE.md) — the contract in full +- [`docs/CONSUMING.md`](../../docs/CONSUMING.md) — wiring this into your own agent +- [`examples/agui-events/`](../agui-events/) — the same machinery over HTTP, + with a browser client diff --git a/examples/session-state/demo.py b/examples/session-state/demo.py index 0c6653f..9db969b 100644 --- a/examples/session-state/demo.py +++ b/examples/session-state/demo.py @@ -1,29 +1,25 @@ """Run the whole session-state contract against three real MCP servers. -Two are built with this runtime and declare what they exchange; the third +Two are built with this runtime and declare what they publish; the third (``foreign_server.py``) is raw FastMCP and declares nothing at all. The demo -connects one agent to all three and drives a conversation that exercises both -paths: - -- **Declared.** ``search_datasets`` publishes an area of interest, tagged with - a ``Kind``. ``clip_raster`` takes one, tagged with the same ``Kind``. The - client matches them, and ``aoi`` never appears in the model's schema. -- **Undeclared.** The foreign server's ``describe_geometry`` takes a structured - parameter nobody declared. The client offers it as an ``@state:`` - handle, and the model points it at the same geometry by name. Its - ``elevation_profile`` returns a large array nobody declared, captured on - size alone. - -Then the two degradation cases, from the same connected servers. ``raster-ops`` -carries two tools taking a ``geo.BoundingBox``, which nothing here declares it -publishes, and they differ only in whether a model may invent one: -``preview_extent`` keeps its parameter and gains a handle branch, while -``clip_to_bbox`` is withheld from the agent before a model ever sees it — even -though a *detected* bounding box in state would have satisfied it at call time. - -Nothing is stubbed: the declarations travel as MCP ``_meta`` over the wire, -and the foreign server genuinely carries none. The chat model *is* stubbed — -it replays a fixed script — so the demo needs no API key and no network. +connects one agent to all three and drives a conversation in which a +2000-vertex geometry moves between two servers that share no code, no imports +and no vocabulary — only a **name**. + +- ``search_datasets`` returns an ``area_of_interest`` data key, captured to + ``dataset-search/search_datasets/area_of_interest``. +- The model reads that key off a breadcrumb and hands it to ``clip_raster`` as + ``@state:``. That parameter is ``NotAuthored``, so its schema accepts a + handle and nothing else — the model could not have written a geometry into + it even if it tried, which the last section demonstrates by trying. +- The foreign server's ``describe_geometry`` takes a structured parameter + nobody declared. It gains the handle branch as well, and the model points it + at the same value. Its ``elevation_profile`` returns a large array nobody + declared, captured on size alone. + +Nothing is stubbed: the declarations travel as MCP ``_meta`` over the wire, and +the foreign server genuinely carries none. The chat model *is* stubbed — it +replays a fixed script — so the demo needs no API key and no network. uv run python examples/session-state/demo.py """ @@ -45,19 +41,18 @@ from langchain_core.utils.function_calling import convert_to_openai_tool from langchain_mcp_adapters.client import MultiServerMCPClient -from mcp_runtime.declarations import CONSUMES_META_KEY, PRODUCES_META_KEY +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, PRODUCES_META_KEY from mcp_runtime.server import build_server from mcp_state import ( StateCaptureMiddleware, - StateEntry, + StateRefusal, bind_all_injected, - detect_kind, handle_for, make_inspect_state, - partition_usable, + owners, publications, state_keys, - unsatisfiable, + with_server_name, ) # `build_server` imports a toolset by name at call time. These live beside this @@ -74,19 +69,13 @@ TOOLSETS = ["dataset-search", "raster-ops"] FOREIGN = "terrain" -AOI_KEY = "dataset-search/geometry" +AOI_KEY = "dataset-search/search_datasets/area_of_interest" +CLIP_BOUNDS = "raster-ops/clip_raster/bounds" -#: The tools `script()` calls. Anything the wiring check withholds is fine -#: unless it is one of these — scenario F withholds a tool on purpose. -SCRIPTED = frozenset( - {"search_datasets", "clip_raster", "describe_geometry", "elevation_profile"} -) - -#: The two tools that exist to be unsatisfiable. Both take a -#: `geo.BoundingBox`, which nothing here declares it publishes; they differ -#: only in whether a model may invent one. +#: Takes a bounding box a model is welcome to sketch. GENERATABLE = "preview_extent" -WITHHELD = "clip_to_bbox" +#: Takes the same JSON, and says a model may not write it. +NARROWED = "clip_to_bbox" class ScriptedModel(GenericFakeChatModel): @@ -140,93 +129,83 @@ def call(index: int, name: str, args: dict[str, Any]) -> AIMessage: return [ call(1, "search_datasets", {"query": "rainfall"}), - # No `aoi`: it is not in this tool's schema at all. - call(2, "clip_raster", {"dataset_id": "chirps-daily"}), - # The foreign tool's parameter *is* in the schema, so the model fills - # it — with a handle it read off the [state updated: …] breadcrumb. + # `aoi` is NotAuthored, so a handle is the only thing its schema + # accepts — read off the [state updated: …] breadcrumb above. + call( + 2, "clip_raster", {"dataset_id": "chirps-daily", "aoi": handle_for(AOI_KEY)} + ), + # The foreign tool's parameter is untouched, so a literal would have + # been accepted. The model points it at the same value anyway. call(3, "describe_geometry", {"geometry": handle_for(AOI_KEY)}), call(4, "elevation_profile", {"region": "Severn catchment"}), AIMessage(content="Done — clipped and described your area of interest."), ] -async def report_degradation( - tools: list[Any], - bound: list[Any], - agent_tools: list[Any], - withheld: list[Any], - result: dict[str, Any], -) -> None: - """Print scenarios E and F: a tagged parameter nothing can satisfy. +def report_provenance(state: dict[str, Any], key: str = CLIP_BOUNDS) -> None: + """Walk one value's history, which is a chain of recorded facts. - Both tools take a ``geo.BoundingBox`` and differ only in whether a model - may invent one. Each half says so itself rather than being asserted, so - publishing that kind — see the README's "things to try" — makes the - degradations report that they no longer apply instead of misreporting. + Each argument a call was given is either the model or *another key*, and + that key's entry carries the same record. Nothing propagated a flag and + nothing compared two values: what is stored is what happened. """ - served = {tool.name: tool for tool in tools} - offered = {tool.name: tool for tool in agent_tools} - - if GENERATABLE not in offered: - print(" E. Tagged, model may generate") - print(f" {GENERATABLE} is withheld, so this is scenario F, not E.") - else: - schema = convert_to_openai_tool(offered[GENERATABLE])["function"]["parameters"] - advertised = sorted(served[GENERATABLE].args_schema["properties"]) - bbox = schema["properties"].get("bbox") - outcome = ( - "the tag is dropped, nothing else is" - if bbox is not None - else "the tag is honoured, so this is scenario A" - ) - print(f" E. Tagged, model may generate — {outcome}") - print(f" server advertises: {advertised}") - print(f" offered to the model: {sorted(schema['properties'])}") - if bbox is None: - print(" bbox was filled from state instead — something publishes it.") - else: - print(f" bbox also accepts a handle: {'anyOf' in bbox}") - - taken_away = WITHHELD not in offered + seen: set[str] = set() + indent = 0 + while key in state and key not in seen: + seen.add(key) + entry = state[key] + origin = entry.get("inputs") or {} + written = ", ".join(sorted(k for k, v in origin.items() if v == "model")) + print(f" {' ' * indent}{key}") + print(f" {' ' * indent} from {entry['tool']}, given {origin or 'nothing'}") + if written: + print(f" {' ' * indent} ...of which the model wrote: {written}") + following = [v for v in origin.values() if v != "model" and v in state] + if not following: + break + key = following[0] + indent += 1 + print( - f"\n F. Tagged model_generatable=False — {WITHHELD} " - f"{'is taken away' if taken_away else 'is offered after all'}" - ) - declares = "nothing DECLARES" if taken_away else "something DECLARES" - print(f" Connect time ({declares} it publishes geo.BoundingBox):") - print(f" withheld: {[str(item) for item in withheld]}") - print(f" would the host offer it? {'no' if taken_away else 'yes'}") - if not taken_away: - print("\n Something publishes geo.BoundingBox now, so nothing degrades.") - return - - # The bounds the foreign server really returned in section 4. Capture left - # them in the transcript — four floats are far below the size gate — so - # this is the entry `StateCaptureMiddleware(capture_undeclared=…)` would - # have written had the value been large, labelled by the same detector. - described = next( - message - for message in result["messages"] - if getattr(message, "name", None) == "describe_geometry" - ) - bounds = described.artifact["structured_content"]["bounds"] - detected = StateEntry( - value=bounds, kind=detect_kind(bounds), tool="describe_geometry" - ) - _, artifact = await next(tool for tool in bound if tool.name == WITHHELD).coroutine( - injected_state={"terrain/bounds": detected}, dataset_id="chirps-daily" + "\n Read it downward. The clip rests on a dataset_id the model wrote\n" + " and an area of interest a tool produced; that area rests in turn on\n" + " a query the model wrote. One level is what anything here reads —\n" + " deeper, 'the model wrote something upstream' is true of everything.\n" + " Nothing refuses on any of it. It is recorded so it is visible." ) - print(f"\n Call time (a value DETECTED as {detected['kind']} is in state):") - print(f" terrain/bounds = {bounds}") - answer = artifact["structured_content"]["message"] - print(f" the withheld tool, run anyway: {answer!r}") + +async def report_refusals(bound: list[Any], state: dict[str, Any]) -> None: + """Two calls the binding will not let through, run for real. + + Both are what a model would have to do to get round ``NotAuthored``, and + both come back as a message addressed to the model rather than an + exception, so a run recovers instead of ending. + """ + clip = next(tool for tool in bound if tool.name == "clip_raster") + + print(" A. The model writes a geometry of its own") + try: + await clip.coroutine( + injected_state=state, + dataset_id="chirps-daily", + aoi={"type": "FeatureCollection", "features": []}, + ) + except StateRefusal as refusal: + for line in str(refusal).splitlines(): + print(f" {line}") + + print(f"\n B. {NARROWED} called with nothing in state at all") + narrowed = next(tool for tool in bound if tool.name == NARROWED) + try: + await narrowed.coroutine(injected_state={}, dataset_id="chirps-daily") + except StateRefusal as refusal: + for line in str(refusal).splitlines(): + print(f" {line}") + print( - "\n The wiring check reads declarations only, because at connect nothing\n" - " has run and a detected kind is a value that may never appear. So it\n" - " withholds a tool that would in fact have worked — fail-safe, and the\n" - " reason not to put model_generatable=False on a consumer whose producer\n" - " is a third-party server." + "\n Neither is raised at the model: both arrive as an error result, so\n" + " the model reads them, runs a producer, and retries." ) @@ -244,7 +223,15 @@ async def main() -> None: for port in ports.values(): await wait_for(port) - tools = await MultiServerMCPClient(connections).get_tools() + client = MultiServerMCPClient(connections) + # Stamped with where each came from, so an undeclared capture is keyed the + # same three-part way a declared one is. The adapter takes a `server_name` + # and records it nowhere, so a host that wants it does this itself. + tools = [ + with_server_name(tool, server) + for server in connections + for tool in await client.get_tools(server_name=server) + ] published = publications(tools) rule("1. What each server declared, over the wire") @@ -252,48 +239,23 @@ async def main() -> None: meta = (tool.metadata or {}).get("_meta") or {} label = tool.name for declaration in meta.get(PRODUCES_META_KEY, []): - kind = declaration["kind"] or "(untagged)" - print(f" {label:19} publishes {declaration['stateKey']:25} {kind}") + print(f" {label:19} publishes {declaration['stateKey']}") label = "" - for declaration in meta.get(CONSUMES_META_KEY, []): - policy = ( - "model may generate" - if declaration.get("modelGeneratable", True) - else "model must not generate" - ) - print( - f" {label:19} takes {declaration['parameter']:25} " - f"{declaration['kind']} ({policy})" - ) + for parameter in meta.get(NOT_AUTHORED_META_KEY, []): + print(f" {label:19} will not let a model write {parameter!r}") label = "" - if not meta.get(PRODUCES_META_KEY) and not meta.get(CONSUMES_META_KEY): + if not meta.get(PRODUCES_META_KEY) and not meta.get(NOT_AUTHORED_META_KEY): print(f" {label:19} declares nothing") - - rule("2. Wiring check, before the agent is built") - problems = unsatisfiable(tools) - bound = bind_all_injected(tools) - agent_tools, withheld = partition_usable(bound) - print(f" declared parameters nothing publishes: {len(problems)}") - for item in problems: - print(f" {item}") - print(f" tools withheld from the agent: {len(withheld)}") print( - "\n unsatisfiable() lists every one of them; partition_usable() acts on\n" - " the fatal ones alone, so a parameter that merely degrades to the\n" - " model leaves its tool callable. Section 7 is those two lines in full." + "\n No kinds, no shared vocabulary: the only thing crossing between\n" + " toolsets is the state key, which is a name a model reads." ) - if blocked := SCRIPTED.intersection(item.tool for item in withheld): - print( - f"\n {', '.join(sorted(blocked))} withheld, and the scripted run needs\n" - " it. Restore the kind its parameter asks for, or drop\n" - " model_generatable=False to hand the parameter back to the model." - ) - return + bound = bind_all_injected(tools) - rule("3. What the model is offered") - for name in ("clip_raster", "describe_geometry"): - bound_tool = next(tool for tool in agent_tools if tool.name == name) + rule("2. What the model is offered") + for name in ("clip_raster", GENERATABLE, NARROWED, "describe_geometry"): + bound_tool = next(tool for tool in bound if tool.name == name) served = next(tool for tool in tools if tool.name == name) offered = convert_to_openai_tool(bound_tool)["function"]["parameters"] print(f" {name}") @@ -301,18 +263,20 @@ async def main() -> None: print(f" model is offered: {sorted(offered['properties'])}") for parameter, schema in sorted(offered["properties"].items()): if "anyOf" in schema: - print(f" {parameter}: object, or an @state: handle") + print(f" {parameter}: a value, or an @state: handle") + elif schema.get("pattern") == "^@state:": + print(f" {parameter}: an @state: handle and nothing else") agent = create_agent( ScriptedModel(messages=iter(script())), - [*agent_tools, make_inspect_state(state_keys(published))], - middleware=[StateCaptureMiddleware(published)], + [*bound, make_inspect_state(state_keys(published))], + middleware=[StateCaptureMiddleware(published, owners=owners(tools))], ) result = await agent.ainvoke( {"messages": [HumanMessage("find rainfall data and clip chirps to my area")]} ) - rule("4. The conversation the model actually saw") + rule("3. The conversation the model actually saw") for message in result["messages"]: # `.text` rather than `str(.content)`: a server answering in content # blocks makes that a list, and the model is shown the text, not a @@ -325,16 +289,18 @@ async def main() -> None: print(f" {label:9} {wrapped}") label = "" - rule("5. Session state") + rule("4. Session state") declared_keys = state_keys(published) for key, entry in sorted( result["tool_state"].items(), key=lambda item: item[1].get("seq", 0) ): how = "declared" if key in declared_keys else "captured on size" - print(f" {key} — {size_of(entry['value'])}, from {entry['tool']}") - print(f" kind={entry['kind'] or 'unrecognised'} ({how})") + print(f" {key}") + print(f" {size_of(entry['value'])}, from {entry['tool']} ({how})") + if origin := entry.get("inputs"): + print(f" produced by a call given {origin}") - rule("6. Did the payload ever enter the transcript?") + rule("5. Did the payload ever enter the transcript?") # The whole serialised content, content blocks included: this asks whether a # vertex leaked anywhere into the transcript, so it searches everything. transcript = " ".join(str(message.content) for message in result["messages"]) @@ -351,13 +317,15 @@ async def main() -> None: print(f" a vertex ({interior}) in it: {'yes' if interior in transcript else 'no'}") print( f"\n Both clip_raster and describe_geometry ran against the same\n" - f" {vertices}-vertex geometry. One found it by kind and never showed the\n" - " model the parameter; the other was pointed at it by name, in about ten\n" - " tokens. Neither server received it from the model." + f" {vertices}-vertex geometry, each pointed at it by a key costing about\n" + " ten tokens. Neither server received it from the model." ) - rule("7. Degradation: a kind nothing publishes") - await report_degradation(tools, bound, agent_tools, withheld, result) + rule("6. Where a value came from") + report_provenance(result["tool_state"]) + + rule("7. What the binding refuses") + await report_refusals(bound, result["tool_state"]) if __name__ == "__main__": diff --git a/examples/session-state/toolsets/dataset_search/tools.py b/examples/session-state/toolsets/dataset_search/tools.py index 0ae0e83..65152fa 100644 --- a/examples/session-state/toolsets/dataset_search/tools.py +++ b/examples/session-state/toolsets/dataset_search/tools.py @@ -1,21 +1,22 @@ """A toolset that publishes an area of interest into session state. -The producing half of the example. ``search_datasets`` returns a `message` -for the model and a `geometry` the model never sees — tagged with a `Kind`, -which is the only thing the consuming toolset in `raster_ops` matches on. - -The tag is what makes the value *injectable*. Without it the geometry would -still be captured, because it is far too large to leave in the transcript, and -could still be handed to a tool by handle — it just could not be matched to a -parameter automatically. See `foreign_server.py` for that path. +The producing half of the example. ``search_datasets`` returns a `message` for +the model and an `area_of_interest` the model never sees: it is a data key of +the `ToolResult`, so it is captured into session state under +`dataset-search/search_datasets/area_of_interest`. + +**The field name is the interface.** It is what the model reads when it +decides which stored value to hand to `raster_ops.clip_raster`, and the two +toolsets share nothing else — separate packages, separate servers, neither +importing the other. `area_of_interest` rather than `geometry` for exactly +that reason: a footprint is also a geometry, and the model would have no way +to tell them apart. """ -from typing import Annotated, NotRequired +from typing import NotRequired from langchain_core.tools import tool -from mcp_runtime.declarations import Kind -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST from mcp_runtime.tool_result import ToolError, ToolResult @@ -40,7 +41,7 @@ class SearchDatasetsResult(ToolResult): """A summary for the model, plus the area those datasets cover.""" datasets: NotRequired[list[str]] - geometry: NotRequired[Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST)]] + area_of_interest: NotRequired[dict] @tool @@ -51,7 +52,7 @@ async def search_datasets(query: str) -> SearchDatasetsResult | ToolError: return SearchDatasetsResult( message=f"Found 3 datasets for {query!r}, covering the Severn catchment.", datasets=["era5-land", "chirps-daily", "modis-lst"], - geometry=AREA_OF_INTEREST, + area_of_interest=AREA_OF_INTEREST, ) diff --git a/examples/session-state/toolsets/raster_ops/tools.py b/examples/session-state/toolsets/raster_ops/tools.py index cba9e15..6a27192 100644 --- a/examples/session-state/toolsets/raster_ops/tools.py +++ b/examples/session-state/toolsets/raster_ops/tools.py @@ -1,57 +1,74 @@ """A toolset that consumes values from session state. -The consuming half. `clip_raster` needs a geometry it has no way to ask a -model for, so it tags the parameter with the `Kind` it takes. It names a -*kind*, never the toolset that publishes one — `dataset_search` is a separate -package served by a separate MCP server, and neither imports the other. - -`model_generatable` is the tool author saying whether a model may invent the -value. It is the one policy the client cannot work out for itself: 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. - -The other two tools are here to be *unsatisfiable*, and they differ only in -that flag. Both take a `geo.BoundingBox`, which nothing in this example -declares it publishes, so the client has to degrade — and what it degrades to -is the whole difference: - -- `preview_extent` lets a model sketch a box, so its parameter stays in the - schema and additionally accepts an `@state:` handle. The tool keeps - working exactly as it would under a client that implements none of this. -- `clip_to_bbox` clips to an exact extent, so a guessed box is worse than no - answer. Its parameter is hidden and nothing can fill it, which makes the - tool uncallable — and the wiring check withholds it before a model is - offered it at all. +The consuming half. It names no other toolset — `dataset_search` is a separate +package served by a separate MCP server, and neither imports the other. What +the two share is a *name*: the model reads +`dataset-search/search_datasets/area_of_interest` off a breadcrumb and passes +it as `@state:` to `clip_raster`'s `aoi`. + +The three tools differ only in what they say about who may write the value, +and that difference is the whole example: + +- `clip_raster` clips to a 2000-vertex catchment boundary. A model cannot + produce one, and a model that tried would produce something plausible and + wrong, so the parameter is `NotAuthored`: its schema accepts a handle and + nothing else. +- `preview_extent` renders a rough preview, where a model sketching a box is a + perfectly good answer. Its parameter is left alone — it also accepts a + handle, but a literal is fine. +- `clip_to_bbox` clips to an *exact* extent, where a guessed box is worse than + no answer, so it is `NotAuthored` too. Same JSON as `preview_extent`'s + parameter; different tool, different call about who may write it. """ -from typing import Annotated +from typing import Annotated, NotRequired from langchain_core.tools import tool -from mcp_runtime.declarations import Kind -from mcp_runtime.kinds import BBOX, GEOJSON_AREA_OF_INTEREST +from mcp_runtime.declarations import NotAuthored from mcp_runtime.tool_result import ToolError, ToolResult +class ClipResult(ToolResult): + """What the clip produced, beside the sentence the model is shown. + + Both are data keys, so both are captured — and both record that the call + which produced them was given a `dataset_id` the model wrote and an `aoi` + it named from state. That is what makes the provenance of `bounds` + readable three turns later, when this call has scrolled out of view. + """ + + dataset: NotRequired[str] + bounds: NotRequired[list[float]] + + @tool async def clip_raster( dataset_id: str, - aoi: Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST, model_generatable=False)], -) -> ToolResult | ToolError: + aoi: Annotated[dict, NotAuthored()], +) -> ClipResult | ToolError: """Clip a dataset to the area of interest currently in play.""" features = aoi.get("features", []) if not features: return ToolError( error="empty_aoi", detail="The area of interest has no features." ) - vertices = sum( - len(ring) + rings = [ + ring for feature in features for ring in feature.get("geometry", {}).get("coordinates", []) - ) - return ToolResult( - message=f"Clipped {dataset_id} to a {vertices}-vertex area of interest." + ] + points = [point for ring in rings for point in ring] + vertices = sum(len(ring) for ring in rings) + return ClipResult( + message=f"Clipped {dataset_id} to a {vertices}-vertex area of interest.", + dataset=dataset_id, + bounds=[ + min(x for x, _ in points), + min(y for _, y in points), + max(x for x, _ in points), + max(y for _, y in points), + ], ) @@ -62,7 +79,7 @@ def _extent(bbox: list[float]) -> str: @tool async def preview_extent( dataset_id: str, - bbox: Annotated[list[float], Kind(BBOX)], + bbox: list[float], ) -> ToolResult | ToolError: """Render a low-resolution preview of a dataset over a bounding box.""" if len(bbox) not in (4, 6): @@ -75,7 +92,7 @@ async def preview_extent( @tool async def clip_to_bbox( dataset_id: str, - bbox: Annotated[list[float], Kind(BBOX, model_generatable=False)], + bbox: Annotated[list[float], NotAuthored()], ) -> ToolResult | ToolError: """Clip a dataset to an exact bounding box.""" if len(bbox) not in (4, 6): diff --git a/src/mcp_agent/host.py b/src/mcp_agent/host.py index ddd7b6c..3e5528f 100644 --- a/src/mcp_agent/host.py +++ b/src/mcp_agent/host.py @@ -21,14 +21,12 @@ from mcp_agent.main import with_credential_support from mcp_state import ( - BY_HANDLE, Receipt, describe, receipts_of, restore_structured, - supplied, ) -from mcp_state.state import StateEntry +from mcp_state.state import StateEntry, authored # The _meta convention a UI-capable host reads (mcp-ui / Apps-SDK style): # tool.metadata["_meta"]["ui"]["resourceUri"] names a ui:// resource to render. @@ -135,32 +133,6 @@ def remember_views( return history -def _origin(receipt: Receipt, tool_state: dict[str, StateEntry] | None) -> list[str]: - """What a receipt says about a value, beyond which key held it. - - The value itself is deliberately not shown — it is in state precisely - because it is too big for a transcript, and a step panel is no different. - Its shape stands in for it. - - Shared by both paths, which differ only in what they lead with. - """ - parts = [receipt.get("kind") or "untyped"] - if entry := (tool_state or {}).get(receipt["key"]): - parts.append(describe(entry.get("value"))) - if tool := receipt.get("tool"): - parts.append(f"from {tool}") - return parts - - -def _from_state(receipt: Receipt, tool_state: dict[str, StateEntry] | None) -> str: - """A parameter the model never saw, and where its value came from. - - Leads with the key: a declared fill is the client's own choice, so the key - appears nowhere else — not in the tool call, not in the message. - """ - return " · ".join([f"← {receipt['key']}", *_origin(receipt, tool_state)]) - - def _from_handle( handle: Any, receipt: Receipt, tool_state: dict[str, StateEntry] | None ) -> str: @@ -169,8 +141,30 @@ def _from_handle( Leads with the handle as written, because that *is* the argument. Adding ``← `` would print the key immediately to the right of itself, so only what the handle does not already say is appended. + + The value itself is deliberately not shown — it is in state precisely + because it is too big for a transcript, and a step panel is no different. + Its shape stands in for it. + + Where the call that produced the value was given a model-authored + argument, the parameter is named. A reader looking at a result wants to + know what it rests on, and "the model chose this" is the part that decides + how much to trust it. + + One line summarising one call, so it names only that. The whole of + ``entry["inputs"]`` belongs on a surface that lists stored values rather + than calls — there the state-sourced half is what makes the chain + walkable, and nothing else is showing it. """ - return " · ".join([str(handle), *_origin(receipt, tool_state)]) + parts = [str(handle)] + entry = (tool_state or {}).get(receipt["key"]) + if entry: + parts.append(describe(entry.get("value"))) + if tool := receipt.get("tool"): + parts.append(f"from {tool}") + if written := authored(entry): + parts.append(f"{', '.join(written)} written by the model") + return " · ".join(parts) def step_input( @@ -180,37 +174,24 @@ def step_input( ) -> dict[str, Any]: """A tool call's arguments, with whatever session state supplied made plain. - The two paths need opposite treatment, because they leave opposite traces - in the arguments the model produced. - - A **declared** parameter (FILL) is removed from the schema the model sees, - so it is absent from those arguments entirely; showing them alone would - present the call as having run without the value that decided its result. + A handle is present in the arguments the model wrote, but only as the key: + ``@state:dataset-search/search_datasets/area_of_interest`` names a value + without describing it, and a reader cannot expand it into what it held or + which tool put it there. Both are on the receipt, so both are added to it. - A **handle** (NAME) is present, but only as the key the model wrote — - ``@state:dataset-search/geometry`` names a value without describing it, and - a reader cannot expand it into what it held or which tool put it there. - Both are on the receipt, so both are added to it. - - The model-facing note (:func:`mcp_state.receipts.breadcrumb`) still skips - handles: repeating one there would spend tokens on what the transcript - already holds. A panel has no such cost and a reader has no such memory. + Nothing of this is echoed to the model — it wrote the handle itself. A + panel has no such cost and a reader has no such memory. """ receipts = receipts_of(getattr(result, "artifact", None)) - declared = supplied(receipts, arguments) handles = { parameter: receipt for parameter, receipt in receipts.items() - if receipt.get("via") == BY_HANDLE and parameter in arguments + if parameter in arguments } - if not declared and not handles: + if not handles: return arguments return { **arguments, - **{ - parameter: _from_state(receipt, tool_state) - for parameter, receipt in declared.items() - }, **{ parameter: _from_handle(arguments[parameter], receipt, tool_state) for parameter, receipt in handles.items() diff --git a/src/mcp_agent/main.py b/src/mcp_agent/main.py index de55150..d73bd05 100644 --- a/src/mcp_agent/main.py +++ b/src/mcp_agent/main.py @@ -57,15 +57,14 @@ from mcp_state import ( SESSION_STATE_PROMPT, StateCaptureMiddleware, - Unsatisfiable, bind_all_injected, describe_receipt, make_inspect_state, - partition_usable, + owners, publications, receipts_of, state_keys, - supplied, + with_server_name, ) from mcp_state.state import TOOL_STATE_KEY, StateEntry @@ -508,16 +507,16 @@ def resolve_credentials( def receipt_lines(arguments: dict[str, Any], result: BaseMessage | None) -> list[str]: - """What a printed tool call cannot show: the parameters state supplied. + """What a printed tool call shows as a handle, resolved to what it named. - A declared parameter is removed from the schema the model sees, so it is - absent from the arguments printed above it — the call reads as though it - ran without the value that decided its result. One line each, or none for - a tool that took nothing from state. + The argument above reads ``@state:raster-ops/clip/aoi``; this says which + tool published that and confirms the key existed. One line each, or none + for a tool that took nothing from state. """ - received = supplied(receipts_of(getattr(result, "artifact", None)), arguments) + received = receipts_of(getattr(result, "artifact", None)) return [ - describe_receipt(parameter, receipt) for parameter, receipt in received.items() + describe_receipt(parameter, receipt) + for parameter, receipt in sorted(received.items()) ] @@ -528,8 +527,8 @@ def with_session_state( system_prompt: str = SYSTEM_PROMPT, extra_tools: Sequence[BaseTool] = (), middleware: Sequence[Any] = (), -) -> tuple[Any, list[Unsatisfiable]]: - """Build the agent with :mod:`mcp_state` wired in, and say what it dropped. +) -> Any: + """Build the agent with :mod:`mcp_state` wired in. The three pieces are interdependent and all three are needed (the pattern ``docs/CONSUMING.md`` documents for anyone assembling their own agent): the @@ -537,12 +536,6 @@ def with_session_state( ``bind_all_injected`` reads back out of it, and ``inspect_state`` lets the model read a value it was only told the key of. - ``partition_usable`` withholds a tool whose required parameter nothing - connected can fill and a model may not invent — calling it could only - raise. The returned list of :class:`~mcp_state.wiring.Unsatisfiable` is a - wiring report for the caller to surface; it is empty in a sound - deployment. - ``system_prompt``, ``extra_tools`` and ``middleware`` are the seams a host with its own prompt, its own local tools, or its own callbacks builds on. ``system_prompt`` is used verbatim — a host replacing it should append @@ -553,23 +546,28 @@ def with_session_state( ``middleware`` runs after :class:`~mcp_state.StateCaptureMiddleware`. """ published = publications(tools) - agent_tools, withheld = partition_usable(bind_all_injected(tools)) - agent = create_agent( + return create_agent( model, - [*agent_tools, make_inspect_state(state_keys(published)), *extra_tools], + [ + *bind_all_injected(tools), + make_inspect_state(state_keys(published)), + *extra_tools, + ], system_prompt=system_prompt, - middleware=[StateCaptureMiddleware(published), *middleware], + middleware=[ + StateCaptureMiddleware(published, owners=owners(tools)), + *middleware, + ], checkpointer=checkpointer, ) - return agent, withheld class BuiltAgent(NamedTuple): """An agent and what it was built from. ``tools`` are as loaded, *before* binding — a UI reads each tool's - ``_meta`` off these, and binding is an agent-side concern. ``withheld`` - are the tools dropped as uncallable. ``required`` is the per-toolset + ``_meta`` off these, and binding is an agent-side concern. ``required`` is + the per-toolset credential-header declaration discovered along with the connections, or ``None`` for a direct URL that advertised none; a caller resolving credentials wants the same declaration the agent was wired with, rather @@ -579,7 +577,6 @@ class BuiltAgent(NamedTuple): agent: Any connections: dict[str, Any] tools: list[BaseTool] - withheld: list[Unsatisfiable] required: dict[str, list[str]] | None @@ -637,9 +634,16 @@ async def build_agent( if checkpointer is None: checkpointer = InMemorySaver() connections, required = await fetch_connections(url) - tools = await MultiServerMCPClient( - with_credential_support(connections, required) - ).get_tools() + # Loaded per server rather than in one call, so each tool can be stamped + # with where it came from: `langchain_mcp_adapters` takes a `server_name` + # and records it nowhere on the tool it builds, and an undeclared capture + # needs it to key a value the same way a declared one is keyed. + client = MultiServerMCPClient(with_credential_support(connections, required)) + tools = [ + with_server_name(tool, server) + for server in connections + for tool in await client.get_tools(server_name=server) + ] chat_model = init_chat_model(model, api_key=api_key.get_secret_value()) if not session_state: return BuiltAgent( @@ -652,10 +656,9 @@ async def build_agent( ), connections, tools, - [], required, ) - agent, withheld = with_session_state( + agent = with_session_state( chat_model, tools, checkpointer, @@ -663,7 +666,7 @@ async def build_agent( extra_tools=extra_tools, middleware=middleware, ) - return BuiltAgent(agent, connections, tools, withheld, required) + return BuiltAgent(agent, connections, tools, required) @dataclass @@ -846,8 +849,6 @@ async def _chat_loop( console.print( f"[dim]{len(built.tools)} tools: {', '.join(t.name for t in built.tools)}[/dim]" ) - for item in built.withheld: - console.print(f"[yellow]withholding {item}[/yellow]") if credentials: console.print(f"[dim]credentials: {', '.join(sorted(credentials))}[/dim]") if missing := sorted( diff --git a/src/mcp_agent/web.py b/src/mcp_agent/web.py index 9cadd8e..1ef505c 100644 --- a/src/mcp_agent/web.py +++ b/src/mcp_agent/web.py @@ -246,7 +246,7 @@ async def ensure_agent(model: str, api_key: str) -> None: f"{connect_error_hint(mcp_url)}" ).send() return - agent, connections, tools, withheld, required = built + agent, connections, tools, required = built # The declaration the agent was actually wired with, which is the one the # panel and the per-turn credentials must agree with. `start` read it # before any agent existed, to draw the panel; this is the authority. @@ -279,14 +279,6 @@ async def ensure_agent(model: str, api_key: str) -> None: f"Supplied by this deployment's environment: " f"{', '.join(f'`{name}`' for name in sorted(from_env))}." ).send() - if withheld: - listed = "\n".join(f"- `{item}`" for item in withheld) - await cl.Message( - f"**{len(withheld)} tool(s) are not available** — each needs a value " - "no connected toolset publishes, and its own author said a model " - f"must not invent one:\n\n{listed}\n\nConnecting the toolset that " - "produces it makes them available again." - ).send() @cl.on_settings_update diff --git a/src/mcp_agent_api/__init__.py b/src/mcp_agent_api/__init__.py index 6715bb9..e02e8d4 100644 --- a/src/mcp_agent_api/__init__.py +++ b/src/mcp_agent_api/__init__.py @@ -30,7 +30,6 @@ STATE_CONSUMED, STATE_NAMESPACE, STATE_PUBLISHED, - TOOLS_WITHHELD, agui_events, state_metadata, ) @@ -52,7 +51,6 @@ "STATE_CONSUMED", "STATE_NAMESPACE", "STATE_PUBLISHED", - "TOOLS_WITHHELD", "Built", "RunRequest", "StateEntryInfo", diff --git a/src/mcp_agent_api/events.py b/src/mcp_agent_api/events.py index 98a1304..ee6a7ab 100644 --- a/src/mcp_agent_api/events.py +++ b/src/mcp_agent_api/events.py @@ -43,7 +43,6 @@ import json from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence -from dataclasses import asdict from typing import Any from ag_ui.core import ( @@ -76,11 +75,9 @@ ) from mcp_state import restore_structured from mcp_state.state import StateEntry -from mcp_state.wiring import Unsatisfiable #: ``activityType`` values this module emits. A client switches on these; they #: are part of the wire contract, so they are named rather than inlined. -TOOLS_WITHHELD = "tools.withheld" STATE_CONSUMED = "state.consumed" STATE_PUBLISHED = "state.published" MCP_VIEW = "mcp.view" @@ -151,15 +148,18 @@ def state_metadata(state: Mapping[str, StateEntry] | None) -> dict[str, Any]: assigned by the state reducer when the write is merged, so an entry taken from a mid-turn update does not carry one — and a client ordering by it would be sorting nulls. The turn's closing update is built from the merged - state and does carry it. ``kind`` is always present, because there ``None`` - is a fact: the value is untyped. + state and does carry it. + + ``inputs`` is omitted the same way, for a reader rather than a sorter: a + key that is present always names at least one argument, so a client can + read it without first testing whether it says anything. """ return { key: { - "kind": entry.get("kind"), "tool": entry.get("tool"), "bytes": _rough_size(entry.get("value")), **({} if entry.get("seq") is None else {"seq": entry["seq"]}), + **({"inputs": inputs} if (inputs := entry.get("inputs")) else {}), } for key, entry in (state or {}).items() } @@ -267,15 +267,12 @@ async def agui_events( thread_id: str, run_id: str, tools: Mapping[str, BaseTool] | None = None, - withheld: Sequence[Unsatisfiable] = (), history: Callable[[], Awaitable[Sequence[Message]]] | None = None, ) -> AsyncIterator[BaseEvent]: """Map one turn onto AG-UI, in the order a client can render. ``tools`` is the agent's bound tools by name, read only for the ``ui://`` - bundle a tool declares; ``withheld`` is ``BuiltAgent.withheld``, announced - once at the top of the run so a client can explain a capability it does not - have rather than appearing to ignore the request. + bundle a tool declares. ``history`` is awaited once the turn has finished, and what it returns goes out as a ``MESSAGES_SNAPSHOT`` before ``RUN_FINISHED``. History here is the @@ -337,20 +334,6 @@ def ready() -> list[BaseEvent]: ] try: - if withheld: - yield _activity( - next_id("act"), - TOOLS_WITHHELD, - { - # Each declaration in full, so a client can say which - # parameter went unsatisfied and what kind it wanted; - # `asdict` because the wire carries JSON, not dataclasses. - "tools": [asdict(item) for item in withheld], - "display": f"{len(withheld)} tool(s) unavailable: " - + ", ".join(str(item) for item in withheld), - }, - ) - async for event in turn: match event: case ToolStarted(): diff --git a/src/mcp_agent_api/routes.py b/src/mcp_agent_api/routes.py index 9209292..fa3d715 100644 --- a/src/mcp_agent_api/routes.py +++ b/src/mcp_agent_api/routes.py @@ -20,8 +20,8 @@ state channel is cumulative, so this is what says which keys a *particular* turn added. ``GET /threads/{thread_id}/state/{key}`` - One session-state value in full. The wire carries only ``{kind, tool, - bytes}`` per key, so this is where a client that decided it wants the + One session-state value in full. The wire carries only ``{tool, bytes, + inputs}`` per key, so this is where a client that decided it wants the 38 kB geometry comes to get it. ``?turn=N`` serves it as of that turn rather than as of now. ``GET /views/{toolset}/{view}`` @@ -33,11 +33,11 @@ :func:`~mcp_agent.main.build_agent` is async and connects to MCP servers, so it runs in a lifespan — after the router has been built and mounted. ``provider`` is called per request and may return anything with ``.agent``, -``.connections``, ``.tools``, ``.withheld`` and ``.required``; a caller holding -a built agent already passes ``lambda: built``. Reading it by attribute is not -fastidiousness: the runtime's :class:`~mcp_agent.main.BuiltAgent` and dss's -carry those five fields in different orders, and unpacking one as the other -yields ``required`` where ``withheld`` belongs. +``.connections``, ``.tools`` and ``.required``; a caller holding +a built agent already passes ``lambda: built``. Reading it by attribute rather +than unpacking is deliberate: the runtime's +:class:`~mcp_agent.main.BuiltAgent` and dss's carry these fields in different +orders, and positional unpacking would silently pair the wrong ones. **What wraps a run is the host's.** Tracing callbacks, a correlation id on the outbound MCP calls, per-request metadata — none of that belongs here, and all @@ -105,7 +105,6 @@ ) from mcp_agent_api.history import Turn, turns_of from mcp_state.state import TOOL_STATE_KEY, StateEntry -from mcp_state.wiring import Unsatisfiable #: URI scheme and layout of a view resource: ``ui:///``. VIEW_URI = "ui://{toolset}/{view}" @@ -140,9 +139,6 @@ def connections(self) -> dict[str, Any]: ... @property def tools(self) -> list[BaseTool]: ... - @property - def withheld(self) -> Sequence[Unsatisfiable]: ... - @property def required(self) -> dict[str, list[str]] | None: ... @@ -197,14 +193,16 @@ class StateEntryInfo(BaseModel): ``GET /threads/{id}/state/{key}``. """ - kind: str | None = Field( - description="The `Kind` the publishing tool tagged this value with. " - "`null` is a fact rather than a gap: the value is untyped." - ) tool: str | None = Field(description="The tool that published it.") bytes: int = Field( description="Rough serialised size, for deciding whether to fetch it." ) + inputs: dict[str, str] | None = Field( + default=None, + description="Where each argument of the producing call came from: " + 'another state key, or `"model"` for one the model wrote. ' + "**Omitted** when that call took no arguments.", + ) seq: int | None = Field( default=None, description="Publication order, assigned when the write is merged. " @@ -262,9 +260,21 @@ class StateValueResponse(BaseModel): """``GET /threads/{thread_id}/state/{key}`` — one published value in full.""" key: str - kind: str | None tool: str | None - seq: int | None + #: Both of these are sent as `null` where the stream omits them. A route + #: serving one value in full answers about every field of it, including + #: the ones there is nothing to say about; the stream is re-sent each turn + #: for every key at once, where the same nulls are only weight. + seq: int | None = Field( + description="Publication order, assigned when the write is merged. " + "`null` before then." + ) + inputs: dict[str, str] | None = Field( + default=None, + description="Where each argument of the producing call came from: " + 'another state key, or `"model"` for one the model wrote. ' + "`null` where that call took no arguments.", + ) turn: int | None = Field( description="Echoed back from `?turn=N`, so a client holding several " "panels cannot mistake one turn's value for another's. `null` when " @@ -632,7 +642,6 @@ async def frames() -> AsyncIterator[str]: thread_id=thread_id, run_id=run_id, tools={tool.name: tool for tool in agent.tools}, - withheld=agent.withheld, history=lambda: thread_snapshot(thread_id), ): yield encoder.encode(event) @@ -740,9 +749,9 @@ async def read_state( ) -> dict[str, Any]: """One published value in full — the payload the wire left out. - ``{key:path}`` because state keys are qualified with the publishing - toolset (``dataset-search/geometry``) and that slash is part of the - key, not a path separator. + ``{key:path}`` because a state key names the toolset, the tool and + the field (``dataset-search/search_datasets/area_of_interest``) + and those slashes are part of the key, not path separators. ``?turn=N`` serves the value **as it stood at the end of turn N** rather than now. Without it a key a later turn overwrote reads back as @@ -764,9 +773,9 @@ async def read_state( ) return { "key": key, - "kind": entry.get("kind"), "tool": entry.get("tool"), "seq": entry.get("seq"), + "inputs": entry.get("inputs"), # Echoed so a client holding several panels cannot mistake one # turn's value for another's. "turn": turn, diff --git a/src/mcp_runtime/declarations.py b/src/mcp_runtime/declarations.py index 7c72fc7..8f7806b 100644 --- a/src/mcp_runtime/declarations.py +++ b/src/mcp_runtime/declarations.py @@ -1,44 +1,42 @@ """What a tool exchanges with session state, read off its own signature. -One marker, :class:`Kind`, used on both sides of a tool. On a ``ToolResult`` -data key it says what the tool publishes; on a parameter it says what the tool -takes:: +Two things a tool says about itself, both derived from annotations it already +carries for other reasons. + +**What it publishes.** Every data key of a tool's ``ToolResult`` — every field +but ``message`` — is a value the tool produces, and is captured into session +state under a key naming where it came from. Being a data key rather than +prose is the whole declaration; nothing further is written:: class SearchDatasetsResult(ToolResult): - geometry: NotRequired[ - Annotated[FeatureCollection, Kind(GEOJSON_AREA_OF_INTEREST)] - ] + datasets: NotRequired[list[str]] + area_of_interest: NotRequired[FeatureCollection] + +Those field names travel. A model choosing a stored value to pass onward reads +the key it is stored under, so **a data key is a public name**: it is what the +next tool's model has to recognise. ``area_of_interest`` and ``footprint`` are +the same JSON, and only the name says which is which. +**What a model may not write.** :class:`NotAuthored` on a parameter says the +caller must supply a value that already exists. It names no type, so no second +toolset has to agree with anything, and it says nothing about session state — +a tool carrying it behaves normally against a client that ignores it:: @tool async def clip_raster( dataset_id: str, - aoi: Annotated[FeatureCollection, Kind(GEOJSON_AREA_OF_INTEREST)], + aoi: Annotated[FeatureCollection, NotAuthored()], ) -> ClipResult | ToolError: ... -A kind names *what a value is*. It says nothing about where a value comes -from — that is the client's decision, made against everything it connected to -(:mod:`mcp_state.injection`). A server cannot know whether some other toolset -publishes the kind its tool consumes, so it does not try to. - -The declaration is an **accelerator, not a requirement**. A client can move -values between tools that declare nothing, by recognising values by shape and -letting the model pass them by handle (:mod:`mcp_state.handles`). Declaring -buys three things that path cannot: the parameter leaves the model's schema -entirely, resolution costs no model turn, and a tag on something that is not a -parameter is caught at ``build_server`` rather than at connect. - -Both sides are read from the same annotations the MCP schemas are derived -from, so a declaration cannot drift from the signature. Nothing here runs at -call time; a server only advertises. +Nothing here runs at call time; a server only advertises. """ +from collections.abc import Sequence from dataclasses import dataclass -from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints +from typing import Annotated, Any, get_args, get_origin, get_type_hints from langchain_core.tools import BaseTool from mcp.server.fastmcp.tools import Tool as FastMCPTool -from pydantic import BaseModel from mcp_runtime.fastmcp_output import _arms, _return_annotation @@ -46,42 +44,68 @@ async def clip_raster( # ``_meta`` convention, so they cannot collide with ``ui`` (MCP Apps) or # another extension's keys. PRODUCES_META_KEY = "io.developmentseed.toolsets/produces" -CONSUMES_META_KEY = "io.developmentseed.toolsets/consumes" +NOT_AUTHORED_META_KEY = "io.developmentseed.toolsets/notAuthored" -# Separator between the owning toolset and the field name in a state key. +# Separator between the parts of a state key. NAMESPACE_SEP = "/" @dataclass(frozen=True) -class Kind: - """The semantic type of a value a tool publishes or takes. - - Args: - kind: The type, from :mod:`mcp_runtime.kinds` (e.g. - ``"geojson.AreaOfInterest"``). Two toolsets interoperate by - agreeing on this string and nothing else. - model_generatable: Whether a model may be asked for the value when no - connected tool publishes the kind. ``True`` (the default) keeps the - parameter in the model's schema, so the tool still works where a - plain MCP client's would. Set ``False`` for a value a model can - only fake — a 2000-vertex geometry, an item collection — and the - tool is withheld instead. Meaningless on an output, where nothing - is being asked of a model. +class NotAuthored: + """A parameter whose value a model must not write. + + A claim about the parameter, not about where a value comes from: *the + caller must supply one that already exists*. It says nothing about session + state, and a tool carrying it works unchanged against a client that has + never heard of any of this — which is the point, because the author of a + tool should not have to know how a client moves values around in order to + describe their own parameter. + + What a client does with it scales with how much it implements. One that + ignores ``_meta`` leaves the parameter alone and the model fills it. One + that reads only the description finds the sentence + :data:`NOT_AUTHORED_NOTE` appended, and mostly obeys it. This package's + client (:mod:`mcp_state.injection`) narrows the parameter's schema so that + a literal is not accepted at all — the only value it will take is a + ``@state:`` reference to something already published. + + Use it for a value a model can only fake: a 2000-vertex geometry, an item + collection, a bounding box that has to be *the* one under discussion rather + than a plausible-looking set of four numbers. + + **It binds a parameter, not a concept.** The constraint reaches exactly the + parameter it annotates, so a tool that accepts the same value a second way + — an opaque ``dict[str, Any]`` request body with a field of its own for it — + is unconstrained by that route, and a model asked for the same thing by two + surfaces will answer both. Reading a stored value is a supported move + (``inspect_state``), which is all it takes to obtain one to write. A tool + with such a parameter has to reconcile the two itself; leaving one to + silently win discards the other while the client's receipts still report it + as used. """ - kind: str - model_generatable: bool = True + +#: Appended to a ``NotAuthored`` parameter's description in the served schema, +#: so a client that reads nothing but the schema still passes the constraint on +#: to its model. +NOT_AUTHORED_NOTE = ( + "This value must already exist — it comes from an earlier tool result, not " + "from you. Do not write one." +) -def qualified(toolset: str, field: str) -> str: - """The namespaced state key a toolset's data key is published under. +def qualified(toolset: str, tool: str, field: str) -> str: + """The state key a tool's data key is published under. - Data keys are ``ToolResult`` field names, so two toolsets can easily both - choose ``geometry``. Session state is one namespace merged last-write-wins, - so qualifying every write by its owning toolset is what stops one - overwriting the other. Storage only — consumers resolve by kind. + Three parts, ``//``. Session state is one namespace + merged last-write-wins, so qualifying is what stops two toolsets that both + return ``geometry`` overwriting each other. The **tool** is in there because + the key is read: a model choosing between stored values is told + ``dataset-search/search_datasets/area_of_interest`` rather than + ``dataset-search/area_of_interest``, and the difference is whether it knows + which call produced the value it is about to reuse. """ - return f"{toolset}{NAMESPACE_SEP}{field}" + return NAMESPACE_SEP.join((toolset, tool, field)) def _annotation_marker(annotation: Any, marker: type) -> Any | None: @@ -99,139 +123,144 @@ def _annotation_marker(annotation: Any, marker: type) -> Any | None: return _annotation_marker(args[0], marker) if args else None -def consumed_kinds(tool: BaseTool) -> dict[str, Kind]: - """The ``Kind`` tags on a tool's parameters, by parameter name.""" - fn = getattr(tool, "coroutine", None) or getattr(tool, "func", None) - if fn is None: - return {} - hints = get_type_hints(fn, include_extras=True) - return { - name: marker - for name, annotation in hints.items() - if name != "return" and (marker := _annotation_marker(annotation, Kind)) - } - - -def output_kinds(tool: BaseTool) -> dict[str, str | None]: - """A tool's ``ToolResult`` data keys mapped to their declared kind. +def output_fields(tool: BaseTool) -> list[str]: + """A tool's ``ToolResult`` data keys, sorted. Reads the same return annotation :mod:`mcp_runtime.fastmcp_output` derives the output schema from, so the two cannot disagree. ``message`` is the - model-facing text, never state, and is excluded. A key with no - :class:`Kind` tag maps to ``None``: it is still captured, and a client may - still recognise its kind from the value's own shape. + model-facing text, never state, and is excluded along with the error arm's + own fields. """ annotation = _return_annotation(tool) if annotation is None: - return {} - keys: dict[str, str | None] = {} - for arm in _arms(annotation): - for field, field_annotation in get_type_hints(arm, include_extras=True).items(): - if field in ("message", "error", "detail"): - continue - tag = _annotation_marker(field_annotation, Kind) - keys[field] = tag.kind if tag else keys.get(field) - return keys - - -def _optional_in_schema(tool: BaseTool) -> set[str]: - """The tool's parameters that its own input schema does not require.""" - model = cast(type[BaseModel], tool.get_input_schema()) - schema = model.model_json_schema() - return set(schema.get("properties", {})) - set(schema.get("required", [])) - - -def _declaration(name: str, marker: Kind, *, required: bool) -> dict[str, Any]: - """One parameter's wire-form declaration. - - ``required`` is read from the tool's own input schema rather than declared - separately: a parameter with a Python default is optional there, which is - exactly the condition under which a client may leave it out of a call. - """ - return { - "parameter": name, - "kind": marker.kind, - "required": required, - "modelGeneratable": marker.model_generatable, + return [] + fields = { + field + for arm in _arms(annotation) + for field in get_type_hints(arm, include_extras=True) + if field not in ("message", "error", "detail") } + return sorted(fields) -def _parameter_declarations(tool: BaseTool) -> list[dict[str, Any]]: - """Every consumed-kind declaration for one tool, ordered by parameter.""" - markers = consumed_kinds(tool) - if not markers: +def not_authored(tool: BaseTool) -> list[str]: + """The tool's parameters tagged :class:`NotAuthored`, in order.""" + fn = getattr(tool, "coroutine", None) or getattr(tool, "func", None) + if fn is None: return [] - optional = _optional_in_schema(tool) + hints = get_type_hints(fn, include_extras=True) return [ - _declaration(parameter, marker, required=parameter not in optional) - for parameter, marker in sorted(markers.items()) + name + for name, annotation in hints.items() + if name != "return" and _annotation_marker(annotation, NotAuthored) ] -def state_declarations(tools: list[BaseTool]) -> dict[str, Any]: - """What this toolset publishes and consumes, for its ``/health`` route. +def _noted(parameters: dict[str, Any], names: Sequence[str]) -> dict[str, Any]: + """``parameters`` with :data:`NOT_AUTHORED_NOTE` on each named property. + + The note goes in the JSON Schema rather than only in ``_meta`` so that the + constraint survives a client that reads no extensions at all: the + description is the one field every MCP client already passes to its model. + """ + properties = parameters.get("properties") + if not isinstance(properties, dict): + return parameters + updated = dict(properties) + for name in names: + schema = updated.get(name) + if not isinstance(schema, dict): + continue + existing = str(schema.get("description") or "").strip() + updated[name] = { + **schema, + "description": f"{existing} {NOT_AUTHORED_NOTE}".strip(), + } + if updated == properties: + return parameters + return {**parameters, "properties": updated} + + +def state_declarations(toolset: str, tools: list[BaseTool]) -> dict[str, Any]: + """What this toolset publishes and will not let a model write. The same declarations :func:`with_state_meta` stamps into ``_meta``, restated where a plain HTTP client can read them without speaking MCP — the route ``credential_headers`` already travels, and what lets the index - show a deployment's data flow. + show a deployment's data flow. ``not_authored`` is the part of that flow a + deployment cannot satisfy on its own: a parameter listed there needs some + other toolset to have published a value first. """ - produces = sorted( + produces = [ { - kind - for tool in tools - for kind in output_kinds(tool).values() - if kind is not None + "tool": tool.name, + "field": field, + "state_key": qualified(toolset, tool.name, field), } - ) - consumes = [ - {"tool": tool.name, **declaration} for tool in tools - for declaration in _parameter_declarations(tool) + for field in output_fields(tool) ] - return {"produces": produces, "consumes": consumes} + withheld = [ + {"tool": tool.name, "parameter": parameter} + for tool in tools + for parameter in not_authored(tool) + ] + return {"produces": produces, "not_authored": withheld} def with_state_meta( toolset: str, tools: list[BaseTool], fastmcp_tools: list[FastMCPTool] ) -> list[FastMCPTool]: - """Return ``fastmcp_tools`` with published/consumed kinds stamped into ``_meta``. + """Return ``fastmcp_tools`` with what each tool declares stamped on. + + Published data keys go into ``_meta``; :class:`NotAuthored` parameters go + into ``_meta`` *and* into the served input schema, as a sentence on the + parameter's description. The second is what makes the constraint mean + something to a client that reads no extensions. Pure: inputs are left untouched; each tool that declares something is replaced by a copy carrying it. - Raises if a ``Kind`` tags something that is not one of its tool's + Raises if :class:`NotAuthored` tags something that is not one of its tool's parameters, naming the offender so a typo fails ``build_server`` rather than going unnoticed until a client connects — the gate :mod:`mcp_runtime.fastmcp_output` applies to returns. """ - consumes_by_tool: dict[str, list[dict[str, Any]]] = {} - publishes_by_tool: dict[str, dict[str, str | None]] = {} + publishes_by_tool: dict[str, list[str]] = {} + not_authored_by_tool: dict[str, list[str]] = {} for tool in tools: arg_names = set(tool.args) - for parameter in consumed_kinds(tool): + names = not_authored(tool) + for parameter in names: if parameter not in arg_names: raise RuntimeError( - f"tool {tool.name!r} tags {parameter!r} with Kind, but it is " - f"not one of its parameters ({', '.join(sorted(arg_names))})" + f"tool {tool.name!r} tags {parameter!r} NotAuthored, but it " + f"is not one of its parameters ({', '.join(sorted(arg_names))})" ) - if declarations := _parameter_declarations(tool): - consumes_by_tool[tool.name] = declarations - if kinds := output_kinds(tool): - publishes_by_tool[tool.name] = kinds + if fields := output_fields(tool): + publishes_by_tool[tool.name] = fields + if names: + not_authored_by_tool[tool.name] = names def stamped(fastmcp_tool: FastMCPTool) -> FastMCPTool: meta = dict(fastmcp_tool.meta or {}) - if declarations := consumes_by_tool.get(fastmcp_tool.name): - meta[CONSUMES_META_KEY] = declarations - if kinds := publishes_by_tool.get(fastmcp_tool.name): + if fields := publishes_by_tool.get(fastmcp_tool.name): meta[PRODUCES_META_KEY] = [ - {"stateKey": qualified(toolset, field), "field": field, "kind": kind} - for field, kind in sorted(kinds.items()) + { + "stateKey": qualified(toolset, fastmcp_tool.name, field), + "field": field, + } + for field in fields ] + names = not_authored_by_tool.get(fastmcp_tool.name) + if names: + meta[NOT_AUTHORED_META_KEY] = names if not meta: return fastmcp_tool - return fastmcp_tool.model_copy(update={"meta": meta}) + update: dict[str, Any] = {"meta": meta} + if names: + update["parameters"] = _noted(fastmcp_tool.parameters, names) + return fastmcp_tool.model_copy(update=update) return [stamped(fastmcp_tool) for fastmcp_tool in fastmcp_tools] diff --git a/src/mcp_runtime/index.py b/src/mcp_runtime/index.py index bb51d9e..5cb932f 100644 --- a/src/mcp_runtime/index.py +++ b/src/mcp_runtime/index.py @@ -44,14 +44,20 @@ class ToolsetService(NamedTuple): class StateDeclarations(BaseModel): - """What a toolset publishes into session state, and takes back out of it. + """What a toolset publishes into session state, and will not author. - Whether an injected parameter can be satisfied depends on which servers a - client connects to, so it is determined client-side (``mcp_state.wiring``). + Whether a ``not_authored`` parameter can be satisfied depends on which + servers a client connects to and what has run, so it is not answerable + here — only reported. """ - produces: list[str] = [] - consumes: list[dict[str, Any]] = [] + #: ``{tool, field, state_key}`` for each ``ToolResult`` data key this + #: toolset publishes into session state. + produces: list[dict[str, Any]] = [] + #: ``{tool, parameter}`` for each parameter a model may not write. What a + #: deployment cannot satisfy on its own: something else has to publish a + #: value for it first. + not_authored: list[dict[str, Any]] = [] class ToolsetEntry(BaseModel): diff --git a/src/mcp_runtime/kinds.py b/src/mcp_runtime/kinds.py deleted file mode 100644 index f2c90c1..0000000 --- a/src/mcp_runtime/kinds.py +++ /dev/null @@ -1,69 +0,0 @@ -"""The shared vocabulary tools name values by. - -A kind string is the only thing a producing toolset and a consuming toolset -agree on. They may live in different repos, be served by different MCP -servers, and never reference each other — the agent matches -``Kind(GEOJSON_AREA_OF_INTEREST)`` on one tool's output against the same tag -on another tool's parameter, and that string is the entire contract. - -So it cannot be ad hoc. Two toolsets writing ``geojson.AreaOfInterest`` and -``geojson-area-of-interest`` do not fail — they silently never interoperate, -which is worse. Kinds live here, in the package both sides already depend on, -and are added by PR like any other public API. - -Adding one is safe: the wire carries the string, so a producer and a consumer -interoperate as soon as both use the same text, whatever runtime version they -pin. Redefining one is not — matching is nominal, so narrowing or reusing an -existing kind changes what every current producer and consumer means by it, -silently. - -**Naming.** ``.``, dotted, domain first. Be specific enough -that two values of the same kind really are interchangeable: an area of -interest and a result footprint are both GeoJSON, but a tool asking for one -must never be handed the other, so they are separate kinds. - -**Size.** A kind names what a value *is*, not how big it is. When a payload -is large enough that shipping it to the consuming server is itself the cost, -publish a pre-signed URL and use a ``*.Ref`` kind — the injected value is -then a short string the consumer fetches, and the saving is bandwidth as -well as tokens. -""" - -# --- geospatial ----------------------------------------------------------- - -#: A GeoJSON FeatureCollection delimiting the area a user is working in. -GEOJSON_AREA_OF_INTEREST = "geojson.AreaOfInterest" - -#: A GeoJSON FeatureCollection describing where data *is* (coverage, -#: footprints, tiles). Deliberately distinct from an area of interest: a clip -#: tool wants the former and would silently produce nonsense given the latter. -GEOJSON_FOOTPRINT = "geojson.Footprint" - -#: A bounding box as ``[west, south, east, north]`` in EPSG:4326. -BBOX = "geo.BoundingBox" - -# --- catalogue ------------------------------------------------------------ - -#: A STAC ItemCollection, inline. -STAC_ITEM_COLLECTION = "stac.ItemCollection" - -#: A URL resolving to a STAC ItemCollection, for payloads too large to inline. -STAC_ITEM_COLLECTION_REF = "stac.ItemCollection.Ref" - -#: The identifiers a search settled on, in result order. -DATASET_IDS = "catalogue.DatasetIds" - - -#: Every kind this package defines. Also what :mod:`mcp_state.detect` labels -#: recognisable values with, so a server that declares nothing still lands -#: values in state that a declared parameter can resolve against. -KINDS = frozenset( - { - GEOJSON_AREA_OF_INTEREST, - GEOJSON_FOOTPRINT, - BBOX, - STAC_ITEM_COLLECTION, - STAC_ITEM_COLLECTION_REF, - DATASET_IDS, - } -) diff --git a/src/mcp_runtime/local.py b/src/mcp_runtime/local.py index 2e97122..68257f9 100644 --- a/src/mcp_runtime/local.py +++ b/src/mcp_runtime/local.py @@ -132,7 +132,7 @@ def build_local_app(toolsets: list[str], base_url: str) -> FastAPI: # Read from the tools directly rather than over HTTP from each # toolset's /health, which is where `index.describe` gets it. # Same declarations either way — this process already holds them. - state=StateDeclarations(**state_declarations(tools)), + state=StateDeclarations(**state_declarations(name, tools)), ) ) diff --git a/src/mcp_runtime/server.py b/src/mcp_runtime/server.py index 47c8111..12daef0 100644 --- a/src/mcp_runtime/server.py +++ b/src/mcp_runtime/server.py @@ -124,7 +124,7 @@ def build_server( register_views(server, toolset, module_name, views) tool_names = [tool.name for tool in tools] - state = state_declarations(tools) + state = state_declarations(toolset, tools) @server.custom_route("/health", methods=["GET"]) async def health(request: Request) -> Response: diff --git a/src/mcp_state/__init__.py b/src/mcp_state/__init__.py index a113ed6..29d8acb 100644 --- a/src/mcp_state/__init__.py +++ b/src/mcp_state/__init__.py @@ -5,31 +5,35 @@ through agent state, without them passing through the model. It works against **any** MCP server. A server that says nothing about itself -still has its large returns captured (by size, labelled by recognising the -value's own shape), and its structured parameters still gain a ``@state:`` -handle the model can point at a stored value with. Nothing needs to be -declared, installed or configured for that path. +still has its large returns captured by size, and its structured parameters +still gain a ``@state:`` handle the model can point at a stored value +with. Nothing needs to be declared, installed or configured for that path. -Declaring is an accelerator. A server built on :mod:`mcp_runtime` tags a -parameter with :class:`mcp_runtime.declarations.Kind`, and the parameter leaves -the model's schema entirely: the client matches the kind and fills it, so the -model neither sees the value nor spends a token choosing it. +**The model chooses which stored value to use, always.** It has the +conversation, and "the area the user just drew" is not something a heuristic +can be relied on to know. What this package does is make the choice cheap to +express, make what is stored legible, and refuse a call that got it wrong in a +way the model can act on. A server that wants a parameter it can trust tags it +:class:`mcp_runtime.declarations.NotAuthored`, and the parameter is narrowed +until a handle is the only thing it accepts. -Seven moving parts, one namespace: +Six moving parts, one namespace: - :mod:`mcp_state.state` — the ``tool_state`` dict on graph state, keyed by - ``/``, values wrapped in a :class:`~mcp_state.state.StateEntry`. + ``//``, values wrapped in a + :class:`~mcp_state.state.StateEntry`. - :mod:`mcp_state.middleware` — captures values out of tool returns into - ``tool_state``, keeping large payloads out of the transcript. -- :mod:`mcp_state.detect` — recognises what a captured value is from its own - shape, so an undeclared value is still labelled. + ``tool_state``, keeping large payloads out of the transcript, and records + what the call that produced each value was given. +- :mod:`mcp_state.detect` — describes a stored value's shape for the listing + the model chooses from. Shape only: what a value *means* is carried by the + name its tool stored it under. - :mod:`mcp_state.handles` — the ``@state:`` reference a model passes in place of a value. -- :mod:`mcp_state.injection` — binds tools so declared parameters are filled - by the client and handles are resolved before the call. +- :mod:`mcp_state.injection` — binds tools so handles are resolved before the + call and a narrowed parameter cannot be written by the model. - :mod:`mcp_state.receipts` — what a tool was handed from ``tool_state`` and - which tool published it, so a value filled behind the model's back is still - traceable. + which tool published it, so a host can show a call as it ran. - :mod:`mcp_state.prompt` — the system-prompt fragment that explains all of the above to the model; a host appends it to its own instructions. @@ -44,16 +48,14 @@ Declarations are honoured unconditionally, and participating is unilateral and free, so "does not follow the spec" is not a boundary. A hostile server can -tag a parameter with a kind and be handed the matching value from -``tool_state`` on its next call, with no model or user in the loop; or declare -that it publishes a kind, and have its return written into ``tool_state`` -where another server's tool consumes it — poisoning an input that, by design, -nothing in the transcript shows. +declare data keys it does not have, and have its return written into +``tool_state`` 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 little: a large value from any connected -server can be reached by a handle, so a server need not declare anything to -get its output in front of another tool. The model has to name it, which the -transcript records, but that is visibility rather than control. +Undeclared capture widens this: a large value from any connected server can be +reached by a handle, so a server need not declare anything to get its output in +front of another tool. The model has to name it, which the transcript records, +but that 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 @@ -65,7 +67,7 @@ See ``docs/SESSION-STATE.md`` for the flows this implies. """ -from mcp_state.detect import describe, detect_kind +from mcp_state.detect import describe from mcp_state.handles import ( HANDLE_PREFIX, available, @@ -82,44 +84,38 @@ from mcp_state.middleware import ( CAPTURED_ARTIFACT_KEY, DEFAULT_CAPTURE_BYTES, + SERVER_METADATA_KEY, StateCaptureMiddleware, + call_inputs, + owners, publications, - published_kinds, - publishers, restore_structured, state_keys, + with_server_name, ) from mcp_state.prompt import SESSION_STATE_PROMPT from mcp_state.receipts import ( - BY_DECLARATION, - BY_HANDLE, INJECTED_ARTIFACT_KEY, Receipt, describe_receipt, receipts_of, - supplied, ) from mcp_state.state import ( + MODEL_AUTHORED, TOOL_STATE_KEY, AgentState, StateEntry, - entries_of_kind, + authored, merge_tool_state, ) -from mcp_state.wiring import ( - Unsatisfiable, - partition_usable, - raise_unsatisfiable, - unsatisfiable, -) __all__ = [ - "BY_DECLARATION", - "BY_HANDLE", "CAPTURED_ARTIFACT_KEY", "DEFAULT_CAPTURE_BYTES", "HANDLE_PREFIX", "INJECTED_ARTIFACT_KEY", + "MODEL_AUTHORED", + "SERVER_METADATA_KEY", "SESSION_STATE_PROMPT", "TOOL_STATE_KEY", "AgentState", @@ -127,32 +123,27 @@ "StateCaptureMiddleware", "StateRefusal", "StateEntry", - "Unsatisfiable", + "authored", "available", "bind_all_injected", "bind_injected", + "call_inputs", "dereference", "dereference_with_receipts", "describe", "describe_receipt", - "detect_kind", - "entries_of_kind", "handle_for", "is_handle", "make_inspect_state", "merge_tool_state", "offer_handles", - "partition_usable", + "owners", "publications", - "published_kinds", - "publishers", - "raise_unsatisfiable", "read_state_key", "receipts_of", "restore_structured", "state_keys", - "supplied", "unresolved", "unresolved_message", - "unsatisfiable", + "with_server_name", ] diff --git a/src/mcp_state/detect.py b/src/mcp_state/detect.py index b6835d1..b081118 100644 --- a/src/mcp_state/detect.py +++ b/src/mcp_state/detect.py @@ -1,47 +1,20 @@ -"""Recognise a value's kind from its own shape. - -A server that declares nothing still returns values that say what they are: -GeoJSON carries ``"type": "FeatureCollection"``, a STAC ItemCollection adds -``stac_version``, a bounding box is four or six numbers. :func:`detect_kind` -reads that, so a value captured from an unmodified MCP server lands in -``tool_state`` labelled well enough for a declared parameter to resolve -against it. - -This is the *value* side of matching, and it is the easy side — a value is a -concrete thing that can be inspected. The parameter side has no equivalent: a -parameter is a hole, and the JSON Schema for a large object is almost always -``{"type": "object"}``, which matches everything. That asymmetry is why the -general path (:mod:`mcp_state.handles`) asks the model to name the value it -wants rather than trying to infer the match. - -Detection is deliberately conservative. A wrong label is worse than no label: -an unlabelled value is still captured and still readable with ``inspect_state``, -whereas a mislabelled one can be injected somewhere it does not belong. Every -detector here keys on a discriminator the format itself defines, never on a -field name or a guess. +"""Describe a stored value's shape, without claiming to know what it means. + +A model choosing which stored value to pass to a tool never sees the value +itself — it sees one line per key, and this writes the part of that line that +comes from the value. + +Shape only, deliberately. Recognising a *format* is easy and tempting: GeoJSON +carries ``"type": "FeatureCollection"``, a bounding box is four or six numbers. +Recognising what a value is *for* is not possible from the bytes at all. An +area of interest and a coverage footprint are the same JSON, and a detector +that labelled one would be confidently wrong about the other roughly half the +time — on the very listing a model reads to choose between them. What a value +means is carried by the name it is stored under, which its tool chose. """ from typing import Any -from mcp_runtime.kinds import BBOX, GEOJSON_FOOTPRINT, STAC_ITEM_COLLECTION - -# A bounding box is [west, south, east, north], or the 6-element form with -# elevation. Anything else of numbers is some other array. -_BBOX_LENGTHS = (4, 6) - - -def _is_number(value: Any) -> bool: - """Whether ``value`` is a JSON number (``bool`` is not, despite ``int``).""" - return isinstance(value, (int, float)) and not isinstance(value, bool) - - -def _is_bbox(value: Any) -> bool: - return ( - isinstance(value, list) - and len(value) in _BBOX_LENGTHS - and all(_is_number(item) for item in value) - ) - def _is_feature_collection(value: Any) -> bool: return ( @@ -51,37 +24,6 @@ def _is_feature_collection(value: Any) -> bool: ) -def _is_item_collection(value: Any) -> bool: - """A STAC ItemCollection is a FeatureCollection that admits to a version.""" - if not _is_feature_collection(value): - return False - if "stac_version" in value: - return True - features = value.get("features") or [] - return bool(features) and all( - isinstance(feature, dict) and "stac_version" in feature for feature in features - ) - - -def detect_kind(value: Any) -> str | None: - """The kind ``value`` announces itself as, or ``None``. - - GeoJSON is reported as :data:`~mcp_runtime.kinds.GEOJSON_FOOTPRINT` rather - than an area of interest: both are FeatureCollections and the bytes cannot - tell them apart, so the detector names the one whose meaning is "here is - where some data is", which is what a tool return almost always is. A tool - that means an area of interest says so with a ``Kind`` tag, and a declared - tag always wins over detection. - """ - if _is_item_collection(value): - return STAC_ITEM_COLLECTION - if _is_feature_collection(value): - return GEOJSON_FOOTPRINT - if _is_bbox(value): - return BBOX - return None - - def _positions(coordinates: Any) -> int: """How many ``[lon, lat]`` positions are under a GeoJSON coordinate array. diff --git a/src/mcp_state/handles.py b/src/mcp_state/handles.py index fdb617b..b8bf3f9 100644 --- a/src/mcp_state/handles.py +++ b/src/mcp_state/handles.py @@ -32,8 +32,8 @@ from typing import Any from mcp_state.detect import describe -from mcp_state.receipts import BY_HANDLE, Receipt, receipt_for -from mcp_state.state import StateEntry +from mcp_state.receipts import Receipt, receipt_for +from mcp_state.state import StateEntry, authored, rests_on_state #: Prefix marking an argument as a reference into ``tool_state``. HANDLE_PREFIX = "@state:" @@ -82,47 +82,78 @@ def _could_be_structured(schema: Any) -> bool: return declared in STRUCTURED_TYPES -def _with_handle_branch(schema: dict[str, Any]) -> dict[str, Any]: - """One parameter's schema, also accepting a handle string.""" - handle_branch = { +def _handle_branch() -> dict[str, Any]: + """The schema arm accepting a ``@state:`` reference.""" + return { "type": "string", "pattern": f"^{HANDLE_PREFIX}", "description": ( "A session-state reference, e.g. " - f"{handle_for('dataset-search/geometry')} — the key from a " - "[state updated: …] note. The value is substituted before the " - "tool runs, so prefer this over repeating a large value." + f"{handle_for('dataset-search/search_datasets/area_of_interest')} " + "— the key from a [state updated: …] note. The value is " + "substituted before the tool runs, so prefer this over repeating " + "a large value." ), } + + +def _with_handle_branch(schema: dict[str, Any]) -> dict[str, Any]: + """One parameter's schema, also accepting a handle string.""" original = {key: value for key, value in schema.items() if key != "description"} - branched: dict[str, Any] = {"anyOf": [original, handle_branch]} + branched: dict[str, Any] = {"anyOf": [original, _handle_branch()]} if description := schema.get("description"): branched["description"] = description return branched -def offer_handles(args_schema: Any, skip: frozenset[str] = frozenset()) -> Any: +def handle_only(schema: dict[str, Any]) -> dict[str, Any]: + """One parameter's schema, accepting *nothing but* a handle string. + + For a parameter its server tagged :class:`~mcp_runtime.declarations. + NotAuthored`. Dropping the original arm is the enforcement: a model cannot + write a literal into a parameter whose only accepted form is a string + matching ``^@state:``, so the value it passes is necessarily one some tool + already produced. + + The parameter's own description is kept — it is the sentence that says why + the constraint is there, and the only part of this a model reads as prose. + """ + branch = _handle_branch() + if description := schema.get("description"): + branch["description"] = f"{description} {branch['description']}" + return branch + + +def offer_handles( + args_schema: Any, + only: frozenset[str] = frozenset(), +) -> Any: """The schema with every structured parameter also accepting ``@state:``. Pure and shallow: only the entries under ``properties`` change, so ``$defs``, ``required`` and everything else survive byte-for-byte. - ``skip`` names parameters an injected declaration already handles — those - are about to be removed from the schema entirely, so offering a handle for - them would only confuse the model. + + ``only`` names parameters that must take a handle and nothing else + (:func:`handle_only`). It wins over the structured-type test, because there + the constraint is the server's stated intent rather than an inference from + the schema — a scalar parameter is narrowed just the same. """ if not isinstance(args_schema, dict): return args_schema properties = args_schema.get("properties") if not isinstance(properties, dict): return args_schema - updated = { - name: ( - _with_handle_branch(schema) - if name not in skip and _could_be_structured(schema) - else schema - ) - for name, schema in properties.items() - } + + def rewritten(name: str, schema: Any) -> Any: + if not isinstance(schema, dict): + return schema + if name in only: + return handle_only(schema) + if _could_be_structured(schema): + return _with_handle_branch(schema) + return schema + + updated = {name: rewritten(name, schema) for name, schema in properties.items()} if updated == properties: return args_schema return {**args_schema, "properties": updated} @@ -146,7 +177,7 @@ def dereference_with_receipts( entry = state.get(key) if entry is not None: resolved[name] = entry.get("value") - receipts[name] = receipt_for(key, entry, BY_HANDLE) + receipts[name] = receipt_for(key, entry) continue resolved[name] = value return resolved, receipts @@ -206,6 +237,7 @@ def unresolved_message( tool_name: str, found: list[tuple[str, str]], tool_state: dict[str, StateEntry] | None, + not_authored: frozenset[str] = frozenset(), ) -> str: """What to tell the model about handles that could not be substituted. @@ -214,8 +246,16 @@ def unresolved_message( thing that says which field is wrong. Each line says which of the two failures it is: a key nobody published is the model's to correct by running another tool, while a nested handle is one the mechanism cannot serve at - all, so the model is pointed at ``inspect_state`` to read the value and - write the field itself. + all. + + ``not_authored`` changes the advice, not the diagnosis. Reading the value + with ``inspect_state`` and writing the field by hand is the right answer + for an ordinary opaque field, and the wrong one for a tool holding a + parameter its server said a model must not write: there the same value has + a parameter of its own, and "write it yourself" is an instruction to carry + it around the constraint. Observed, not anticipated — a model refused for a + handle nested in an opaque request read this, fetched the value, and wrote + it in. """ state = tool_state or {} lines = [ @@ -229,12 +269,20 @@ def unresolved_message( for path, key in found ] listing = available(state) - closing = ( - "Read a value with inspect_state and write the field yourself, or call " - "the tool that produces it first." - if listing - else "Nothing has been published to session state yet." - ) + if not listing: + closing = "Nothing has been published to session state yet." + elif not_authored: + named = ", ".join(f"{name!r}" for name in sorted(not_authored)) + closing = ( + f"Do not read the value and write it in — {tool_name} takes it as " + f"{named}, which is the parameter to pass the handle to. If nothing " + "holds it yet, call the tool that produces it first." + ) + else: + closing = ( + "Read a value with inspect_state and write the field yourself, or " + "call the tool that produces it first." + ) return "\n".join( [f"{tool_name} was not called. Unresolved session-state references:"] + lines @@ -243,26 +291,56 @@ def unresolved_message( ) +#: Model-authored parameters named on a listing line before the rest are +#: summarised. Three fits the line; past that the names stop being a signal and +#: start being the reason the rest of the line goes unread. +MAX_AUTHORED_NAMED = 3 + + +def _provenance(entry: StateEntry) -> str: + """What one listing line says about where a value came from. + + A note only where there is something to warn about, which is a value with + **no** tool-found input anywhere behind it. A call that drew on state at + all is the unremarkable case and says nothing, so a listing stays readable + at the length a refusal prints it. + + Naming the model-authored arguments of a call that *did* draw on state + inverts the note. A submit taking an area from state and seven scalars + beside it would carry seven names, where one invented outright from a + single argument carries one — marking the trustworthy value more heavily + than the invented one, on the listing a model reads to choose between them. + Hence the gate on the whole call rather than a filter over its arguments. + + That is a judgement about *this* surface rather than about the record. A + host panel, read long after the call has scrolled away, should show the + whole of ``entry["inputs"]``: there the state-sourced half is what makes + the chain walkable, and nothing else is showing it. + """ + if rests_on_state(entry): + return "" + written = authored(entry) + if not written: + return "" + if len(written) > MAX_AUTHORED_NAMED: + return f" (you wrote every argument: {len(written)} of them)" + return f" (you wrote: {', '.join(written)})" + + def available(tool_state: dict[str, StateEntry] | None) -> list[str]: - """One line per stored value, naming its handle, kind and shape. + """One line per stored value: its handle, shape, publisher and provenance. For a host that wants to put what is in state in front of the model - directly rather than relying on the capture breadcrumbs. + directly rather than relying on the capture breadcrumbs, and what a refusal + lists so the model can correct itself. :func:`_provenance` writes the last + part of each line and says why it is only ever one half of the record. """ return [ - f"{handle_for(key)} — {entry.get('kind') or 'untyped'}, " - f"{describe(entry.get('value'))}, from {entry.get('tool') or 'unknown'}" + f"{handle_for(key)} — {describe(entry.get('value'))}, " + f"from {entry.get('tool') or 'unknown'}" + _provenance(entry) for key, entry in sorted( (tool_state or {}).items(), key=lambda item: item[1].get("seq", 0), reverse=True, ) ] - - -def offers_handles(args_schema: Any, skip: frozenset[str] = frozenset()) -> bool: - """Whether any parameter would gain a handle branch. - - Lets a caller skip wrapping a tool with nothing to point at state. - """ - return offer_handles(args_schema, skip) is not args_schema diff --git a/src/mcp_state/injection.py b/src/mcp_state/injection.py index d9cda9b..df9e9f0 100644 --- a/src/mcp_state/injection.py +++ b/src/mcp_state/injection.py @@ -1,62 +1,52 @@ -"""Fill tool parameters from session state instead of from the model. - -Two paths, applied by one function, in order of how little the model has to do. - -**Declared.** A server that tags a parameter with -:class:`mcp_runtime.declarations.Kind` gets the strong form: the parameter is -removed from the schema the model sees, and filled at call time from -``tool_state`` by matching the kind. The model neither generates the value nor -knows the parameter exists — zero tokens, and nothing to hallucinate. - -**Undeclared.** Every other structured parameter gains a ``@state:`` -handle branch (:mod:`mcp_state.handles`), so the model can point at a stored -value by name rather than reproducing it. About ten tokens, and it needs -nothing from the server — the path an unmodified third-party tool takes. - -Both fall out of one mechanism. LangGraph reads ``InjectedState`` -annotations off the tool's *coroutine* as well as its schema +"""Give a tool a value the model never wrote, and refuse the calls that cheat. + +A tool parameter that could hold a structured value gains a second accepted +form: the string ``@state:``, naming something already in session state +(:mod:`mcp_state.handles`). The model spends about ten tokens on the key +instead of thousands reproducing the value, the value itself never enters the +transcript, and none of it needs anything from the server — the path an +unmodified third-party tool takes. + +A server that wants more says so with +:class:`~mcp_runtime.declarations.NotAuthored`, and the parameter is +**narrowed**: it keeps its place in the schema and loses the arm that accepted +a literal, so a handle is the only thing that fits. The difference is that the +model cannot decline the offer and write the value out instead. + +Both are one mechanism. LangGraph reads ``InjectedState`` annotations off the +tool's *coroutine* as well as its schema (``langgraph.prebuilt.tool_node._get_all_injected_args``), so a wrapper coroutine carrying one gets the whole ``tool_state`` dict handed to it at call -time, while ``args_schema`` — the server's raw JSON Schema, minus the declared -parameters and plus the handle branches — is what reaches the model. Keeping -it a plain dict rather than round-tripping through pydantic preserves the -server's schema exactly, which matters now that MCP input schemas may use the -whole of JSON Schema 2020-12. - -**A resolved value is validated against the parameter's own schema before -use.** A kind is a nominal type: two servers agreeing on the string -``geojson.AreaOfInterest`` does not make one's payload fit the other's schema. -A value that does not validate is treated as absent, so a mismatch degrades to -the model being asked (or a clear error) rather than the consuming server -receiving something it will reject with no one watching. - -Resolution is by **kind**, so the tool that published a value may live in a -different toolset on a different MCP server, and neither end names the other. -The agent is the bus. +time, while ``args_schema`` — the server's raw JSON Schema, with the handle +branches rewritten into it — is what reaches the model. Keeping it a plain dict +rather than round-tripping through pydantic preserves the server's schema +exactly, which matters now that MCP input schemas may use the whole of JSON +Schema 2020-12. + +**Which stored value to use is the model's decision, and no heuristic's.** It +has the conversation; "the area the user just drew" is not something recency +can be relied on to know. What the client does is make the choice cheap to +express, make what is available legible, and refuse a call that got it wrong +in a way the model can act on. """ -from collections.abc import Callable, Container, Mapping, Sequence +from collections.abc import Callable, Sequence from typing import Annotated, Any -import jsonschema from langchain_core.tools import BaseTool, StructuredTool, ToolException from langgraph.prebuilt import InjectedState -from mcp_runtime.declarations import CONSUMES_META_KEY +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY from mcp_state.handles import ( + available, dereference_with_receipts, + is_handle, offer_handles, unresolved, unresolved_message, ) -from mcp_state.middleware import publishers -from mcp_state.receipts import ( - BY_DECLARATION, - INJECTED_ARTIFACT_KEY, - Receipt, - receipt_for, -) -from mcp_state.state import TOOL_STATE_KEY, StateEntry, entries_of_kind +from mcp_state.receipts import INJECTED_ARTIFACT_KEY, Receipt +from mcp_state.state import TOOL_STATE_KEY, StateEntry class StateRefusal(ToolException): @@ -64,10 +54,10 @@ class StateRefusal(ToolException): Its own type so it can be told apart from whatever the wrapped tool raises, which matters because the two want opposite handling. A refusal is - *addressed to the model*: it names the parameter, what would fill it, and - which tool publishes that, so the model can fix the call and try again. It - is therefore delivered as the tool's **result** — a ``ToolMessage`` with - ``status="error"`` — rather than raised. + *addressed to the model*: it names the parameter, says what was wrong with + it, and lists what session state actually holds, so the model can fix the + call and try again. It is therefore delivered as the tool's **result** — a + ``ToolMessage`` with ``status="error"`` — rather than raised. Raising it instead would end the run, and worse: the assistant message keeps its ``tool_calls`` while no ``ToolMessage`` answers them, which is a @@ -112,143 +102,131 @@ def handle(error: ToolException) -> Handled: STATE_PARAM = "injected_state" -def wants(declaration: dict[str, Any]) -> str: - """The kind a declaration resolves against.""" - return str(declaration.get("kind") or "") +def not_authored_for(tool: BaseTool) -> frozenset[str]: + """The tool's parameters its server says a model must not write. + ``langchain_mcp_adapters`` preserves the MCP tool's ``_meta`` onto the + converted LangChain tool's ``metadata``, which is what makes a server-side + declaration reachable here at all. + """ + meta = (getattr(tool, "metadata", None) or {}).get("_meta") or {} + found = meta.get(NOT_AUTHORED_META_KEY) + if not isinstance(found, list): + return frozenset() + return frozenset(str(name) for name in found if isinstance(name, str)) -def satisfiable(declaration: dict[str, Any], published: Container[str]) -> bool: - """Whether anything connected publishes the kind this declaration asks for. - ``published`` is anything that answers ``kind in …`` — the set of kinds - from :func:`mcp_state.middleware.published_kinds`, or the richer mapping - from :func:`mcp_state.middleware.publishers`. +def _authored( + tool_name: str, + parameter: str, + tool_state: dict[str, StateEntry] | None, + *, + written: bool, +) -> str: + """What to tell the model about a ``NotAuthored`` parameter it got wrong. + + ``written`` distinguishes the two ways to get it wrong, because the fix + differs: a literal means the model tried to author the value and should + point at a stored one instead, while an omission on a required parameter + usually means nothing has produced one yet. + + Neither message names a tool that would publish the value. + :class:`~mcp_runtime.declarations.NotAuthored` says only that the model may + not write it, so there is nothing here to look a producer up by. What the + model gets instead is the listing of what *is* in state, on top of the tool + descriptions it already holds. """ - kind = declaration.get("kind") - return bool(kind) and kind in published + lead = ( + f"{tool_name} was not called. {parameter!r} takes a value that already " + "exists in this session; you cannot write one." + ) + if written: + lead = ( + f"{tool_name} was not called. {parameter!r} was given a value you " + "wrote. It takes a reference to a value some tool already produced." + ) + listing = available(tool_state) + if not listing: + return ( + f"{lead} Nothing has been published to session state yet, so run " + "the tool that produces this first." + ) + return "\n".join( + [f"{lead} Pass @state: naming one of:"] + [f" {line}" for line in listing] + ) -def model_generatable(declaration: dict[str, Any]) -> bool: - """Whether the model may be asked for this value when nothing publishes it.""" - return bool(declaration.get("modelGeneratable", True)) +#: How a ``$ref`` into the schema's own definitions is written. +DEFS_REF_PREFIX = "#/$defs/" -def declarations_for(tool: BaseTool) -> list[dict[str, Any]]: - """A tool's consumed-kind declarations, from its server ``_meta``. +def _refs(node: Any) -> set[str]: + """Every ``$ref`` target under ``node``, not descending into ``$defs``.""" + if isinstance(node, dict): + found = {node["$ref"]} if isinstance(node.get("$ref"), str) else set() + for key, value in node.items(): + if key != "$defs": + found |= _refs(value) + return found + if isinstance(node, list): + return {ref for item in node for ref in _refs(item)} + return set() - ``langchain_mcp_adapters`` preserves the MCP tool's ``_meta`` onto the - converted LangChain tool's ``metadata``, which is what makes a - server-side declaration reachable here at all. - """ - meta = (getattr(tool, "metadata", None) or {}).get("_meta") or {} - found = meta.get(CONSUMES_META_KEY) - return [item for item in found if isinstance(item, dict)] if found else [] +def _prune_defs(args_schema: Any) -> Any: + """The schema with definitions nothing references removed. -def _property_schema(args_schema: Any, parameter: str) -> dict[str, Any] | None: - """The sub-schema for one parameter, with the parent's ``$defs`` carried. + Narrowing a parameter deletes the only ``$ref`` to a definition without + touching ``$defs``, which then travels to the model describing a type no + parameter mentions — the whole cost of a richly typed parameter, with none + of its benefit. Reachability is followed through ``$defs`` themselves, so a + definition kept alive only by another kept one survives. - ``$defs`` ride along because a property is very often a ``$ref`` into - them, and validating the extracted fragment alone would fail to resolve. + Returns the original object when nothing is unreachable, so a caller can + still tell by identity that the schema is untouched. """ if not isinstance(args_schema, dict): - return None - schema = (args_schema.get("properties") or {}).get(parameter) - if not isinstance(schema, dict): - return None - if defs := args_schema.get("$defs"): - return {**schema, "$defs": defs} - return schema - - -def _validates(value: Any, schema: dict[str, Any] | None) -> bool: - """Whether ``value`` satisfies ``schema`` (vacuously true with no schema).""" - if schema is None: - return True - try: - jsonschema.validate(value, schema) - except jsonschema.ValidationError: - return False - except jsonschema.SchemaError: - # A schema we cannot evaluate is not evidence the value is wrong. - return True - return True - - -def _prune(args_schema: Any, parameters: set[str]) -> Any: - """The server's schema with ``parameters`` removed, otherwise untouched. - - Pure, and deliberately shallow: only ``properties`` and ``required`` name - the parameters, so everything else — ``$defs``, ``allOf``, annotations — - survives byte-for-byte. Removing nothing returns the original object, so a - caller can tell by identity that the schema is untouched. - """ - if not parameters or not isinstance(args_schema, dict): return args_schema - pruned = dict(args_schema) - if isinstance(properties := pruned.get("properties"), dict): - pruned["properties"] = { - name: schema - for name, schema in properties.items() - if name not in parameters - } - if isinstance(required := pruned.get("required"), list): - remaining = [name for name in required if name not in parameters] - if remaining: - pruned["required"] = remaining - else: - pruned.pop("required", None) - return pruned - + defs = args_schema.get("$defs") + if not isinstance(defs, dict) or not defs: + return args_schema -def resolve( - declaration: dict[str, Any], - tool_state: dict[str, StateEntry] | None, - schema: dict[str, Any] | None, -) -> tuple[str, StateEntry] | None: - """Find the state entry satisfying one declaration. - - Returns the key it is stored under and the entry itself, or ``None``. The - most recently published entry of the declared kind that also validates - against ``schema`` is used, so a stale or foreign-dialect value is passed - over rather than injected. - - The key and the entry both come back because the caller needs more than - the value: the entry carries the kind and the publishing tool, which is - what a receipt (:mod:`mcp_state.receipts`) records. - """ - kind = declaration.get("kind") - if not kind: - return None - for key, entry in entries_of_kind(tool_state, kind): - if _validates(entry.get("value"), schema): - return key, entry - return None + def names(refs: set[str]) -> set[str]: + return { + ref[len(DEFS_REF_PREFIX) :] + for ref in refs + if ref.startswith(DEFS_REF_PREFIX) + } + outside = {key: value for key, value in args_schema.items() if key != "$defs"} + frontier = names(_refs(outside)) + reachable: set[str] = set() + while frontier: + name = frontier.pop() + if name in reachable or name not in defs: + continue + reachable.add(name) + frontier |= names(_refs(defs[name])) + + if len(reachable) == len(defs): + return args_schema + pruned = dict(args_schema) + kept = {name: schema for name, schema in defs.items() if name in reachable} + if kept: + pruned["$defs"] = kept + else: + pruned.pop("$defs", None) + return pruned -def _missing( - tool_name: str, declaration: dict[str, Any], producers: list[str] | None -) -> str: - """What to tell the model when a required parameter cannot be filled. - ``producers`` names the connected tools that publish the kind, so the model - is told which one to run rather than left to work it out from the kind - string. Empty means nothing connected publishes it at all — a wiring fault - (:mod:`mcp_state.wiring`) rather than a recoverable turn. ``None`` means the - caller supplied no map to look in, which is not the same claim. - """ - lead = ( - f"{tool_name} needs {declaration['parameter']!r}, which is supplied from " - f"session state ({declaration.get('kind') or 'a value'}) rather than by " - "you, and nothing in this session has published it." - ) - if producers is None: - return f"{lead} If a tool produces it, run that one first." - if not producers: - return f"{lead} No connected tool publishes it, so it cannot be supplied here." - if len(producers) == 1: - return f"{lead} Run {producers[0]} first — it publishes this." - return f"{lead} Run one of {', '.join(producers)} first — they publish this." +def _required(args_schema: Any) -> frozenset[str]: + """The parameters a server's own schema marks required.""" + if not isinstance(args_schema, dict): + return frozenset() + required = args_schema.get("required") + if not isinstance(required, list): + return frozenset() + return frozenset(name for name in required if isinstance(name, str)) def _with_receipts( @@ -272,55 +250,23 @@ def _with_receipts( return content, {**(artifact or {}), INJECTED_ARTIFACT_KEY: receipts} -def _bindable( - tool: BaseTool, published: Mapping[str, list[str]] | None -) -> list[dict[str, Any]]: - """The declarations this client will act on for one tool. +def bind_injected(tool: BaseTool) -> BaseTool: + """Return ``tool`` with handles offered, and narrowed where its server said. - A declaration whose kind nothing connected publishes is dropped when the - model may generate the value: the parameter then stays in the schema and - the model fills it, which is what a client implementing none of this would - do anyway. One that may *not* be model-generated is kept, so the parameter - is hidden and the tool reports the gap — and :mod:`mcp_state.wiring` can - withhold it entirely. - """ - declarations = declarations_for(tool) - if published is None: - return declarations - return [ - declaration - for declaration in declarations - if satisfiable(declaration, published) or not model_generatable(declaration) - ] - - -def bind_injected( - tool: BaseTool, published: Mapping[str, list[str]] | None = None -) -> BaseTool: - """Return ``tool`` with declared parameters hidden, and handles offered. - - ``published`` maps each kind the connected tools publish to the tools that - publish it (see :func:`mcp_state.middleware.publishers`). Without it every - declaration is assumed satisfiable and an unfillable parameter cannot name - what would fill it; :func:`bind_all_injected` supplies it. - - A tool with no declarations and no structured parameters is returned + A parameter its server tagged + :class:`~mcp_runtime.declarations.NotAuthored` keeps its place in the + schema but accepts only a handle, and a call that writes a literal into one + — or omits a required one — is refused before it reaches the server. + + A tool with no narrowing and no structured parameters is returned unchanged, so this is safe to map over every tool from every server. """ - declarations = _bindable(tool, published) - declared = {item["parameter"] for item in declarations} - args_schema = offer_handles(_prune(tool.args_schema, declared), frozenset(declared)) - if not declarations and args_schema is tool.args_schema: + not_authored = not_authored_for(tool) + args_schema = _prune_defs(offer_handles(tool.args_schema, only=not_authored)) + if not not_authored and args_schema is tool.args_schema: return tool + required_not_authored = not_authored & _required(tool.args_schema) - schemas = { - item["parameter"]: _property_schema(tool.args_schema, item["parameter"]) - for item in declarations - } - producers: dict[str, list[str] | None] = { - item["parameter"]: None if published is None else published.get(wants(item), []) - for item in declarations - } inner: Callable[..., Any] = getattr(tool, "coroutine", None) or getattr( tool, "func" ) @@ -333,24 +279,25 @@ async def call( runtime: Any = None, **arguments: Any, ) -> Any: - arguments, receipts = dereference_with_receipts(arguments, injected_state) - for declaration in declarations: - parameter = declaration["parameter"] - if parameter in arguments: - continue # an explicit value wins; never override a caller - found = resolve(declaration, injected_state, schemas[parameter]) - if found is not None: - key, entry = found - arguments[parameter] = entry.get("value") - receipts[parameter] = receipt_for(key, entry, BY_DECLARATION) - elif declaration.get("required", True): + # Checked before anything is substituted, because that is the only + # point a literal is still distinguishable from a resolved handle. The + # narrowed schema should have prevented one, but a schema is a request + # to a model rather than a guarantee from it. + for parameter in sorted(not_authored): + if parameter not in arguments: + if parameter in required_not_authored: + raise StateRefusal( + _authored(tool.name, parameter, injected_state, written=False) + ) + elif not is_handle(arguments[parameter]): raise StateRefusal( - _missing(tool.name, declaration, producers[parameter]) + _authored(tool.name, parameter, injected_state, written=True) ) - # Checked after both paths have filled what they can, so what is left - # is genuinely unresolvable rather than merely not yet resolved. + arguments, receipts = dereference_with_receipts(arguments, injected_state) if leftover := unresolved(arguments): - raise StateRefusal(unresolved_message(tool.name, leftover, injected_state)) + raise StateRefusal( + unresolved_message(tool.name, leftover, injected_state, not_authored) + ) result = await inner(runtime=runtime, **arguments) return _with_receipts(result, receipts, response_format) @@ -373,11 +320,5 @@ async def call( def bind_all_injected(tools: list[BaseTool]) -> list[BaseTool]: - """Apply :func:`bind_injected` across a whole toolset load. - - Resolves what is published once over the full set, so a model-generatable - parameter with no publisher connected degrades to model-supplied rather - than to a tool that always raises. - """ - published = publishers(tools) - return [bind_injected(tool, published) for tool in tools] + """Apply :func:`bind_injected` across a whole toolset load.""" + return [bind_injected(tool) for tool in tools] diff --git a/src/mcp_state/middleware.py b/src/mcp_state/middleware.py index 145e722..04f3741 100644 --- a/src/mcp_state/middleware.py +++ b/src/mcp_state/middleware.py @@ -11,28 +11,27 @@ Two ways a field gets captured: **Declared.** The server said so, via :func:`publications` reading its -``_meta``. The declaration names the qualified key the field lands under and -the kind it publishes, which is what keeps two toolsets' identically-named -fields from overwriting each other. +``_meta``. Every ``ToolResult`` data key is one; the declaration names the +qualified key the field lands under, which is what keeps two toolsets' +identically-named fields from overwriting each other. **By size.** With ``capture_undeclared`` set, any field whose serialised form exceeds it is captured whatever the server said, keyed by the tool that -returned it and labelled with whatever :func:`mcp_state.detect.detect_kind` -recognises. This is what lets an unmodified third-party MCP server take part: +returned it. This is what lets an unmodified third-party MCP server take part: it declares nothing, and its large values still land somewhere a later tool can be pointed at. -Declared capture wins where both apply — a server that named a kind knows -better than a detector. +Declared capture wins where both apply — the server named the key. Secret-shaped field names are refused either way. That is a backstop against a toolset publishing something it should not, not a defence against a server that means harm. **The other direction.** A tool that was *given* a value from ``tool_state`` -records a receipt on its artifact (:mod:`mcp_state.receipts`); the declared -ones become a ``[state used: …]`` note alongside ``[state updated: …]``. That -runs before any of the capture checks, so a consumer returning nothing +records a receipt on its artifact (:mod:`mcp_state.receipts`), which a host +reads to show a call as it ran. Nothing is echoed to the model: it wrote the +``@state:`` handle itself. That runs before any of the capture checks, so +a consumer returning nothing structured still reports what it received. **What is left on the message.** The captured payload moves to ``tool_state``, @@ -47,7 +46,7 @@ import json import re -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from typing import Any from langchain.agents.middleware import AgentMiddleware @@ -57,14 +56,13 @@ from langgraph.types import Command from mcp_runtime.declarations import PRODUCES_META_KEY, qualified -from mcp_state.detect import detect_kind +from mcp_state.handles import handle_key, is_handle from mcp_state.receipts import ( INJECTED_ARTIFACT_KEY, Receipt, receipts_of, ) -from mcp_state.receipts import breadcrumb as receipt_breadcrumb -from mcp_state.state import TOOL_STATE_KEY, AgentState, StateEntry +from mcp_state.state import MODEL_AUTHORED, TOOL_STATE_KEY, AgentState, StateEntry MESSAGE_KEY = "message" @@ -83,9 +81,53 @@ re.IGNORECASE, ) -#: ``{tool name: {field: {"stateKey": ..., "kind": ...}}}`` +#: ``{tool name: {field: {"stateKey": ...}}}`` Published = dict[str, dict[str, dict[str, Any]]] +#: Metadata key naming the MCP server a tool was loaded from. Stamped by the +#: host at load (``langchain_mcp_adapters`` takes a ``server_name`` and puts it +#: nowhere on the tool), and read here so an undeclared capture is keyed the +#: same three-part way a declared one is. +SERVER_METADATA_KEY = "mcp_toolsets_server" + + +def call_inputs(arguments: Mapping[str, Any]) -> dict[str, str]: + """Where each argument of one call came from. + + A handle is a reference the model wrote to a value some tool produced, so + it resolves to that key; everything else the model authored this turn. + Exact, and cheap: no value is compared, inspected or stored — only the + argument's name and, where there is one, the key it pointed at. + + This is the *input* side deliberately. A tool owns what it returns, and + the client is in no position to decide whether a returned value was really + derived or merely echoed back — an equality test would catch the echo and + miss every transformation, producing a label that is sometimes right with + no way to tell which time. What a call was handed needs no such judgement. + """ + return { + name: handle_key(value) if is_handle(value) else MODEL_AUTHORED + for name, value in arguments.items() + } + + +def with_server_name(tool: BaseTool, server: str) -> BaseTool: + """``tool`` with the server it came from recorded on its metadata.""" + return tool.model_copy( + update={"metadata": {**(tool.metadata or {}), SERVER_METADATA_KEY: server}} + ) + + +def owners(tools: list[BaseTool]) -> dict[str, str]: + """``{tool name: server}`` for the tools a host stamped at load.""" + found = {} + for tool in tools: + server = (getattr(tool, "metadata", None) or {}).get(SERVER_METADATA_KEY) + if isinstance(server, str) and server: + found[tool.name] = server + return found + + #: Artifact key under which a captured message records ``{field: stateKey}`` #: for every field that moved to ``tool_state``. Read it with #: :func:`restore_structured` rather than by hand. @@ -130,28 +172,6 @@ def state_keys(published: Published) -> frozenset[str]: ) -def publishers(tools: list[BaseTool]) -> dict[str, list[str]]: - """Which of the connected tools publishes each declared kind. - - What :func:`mcp_state.injection.bind_all_injected` decides satisfiability - against, and what lets a consumer that cannot be filled name the tool to - run first. Kinds that only ever arrive by detection are absent, so a - declared parameter is not assumed satisfiable on the strength of a value - that may never appear. - """ - found: dict[str, set[str]] = {} - for name, fields in publications(tools).items(): - for declaration in fields.values(): - if kind := declaration.get("kind"): - found.setdefault(kind, set()).add(name) - return {kind: sorted(found[kind]) for kind in sorted(found)} - - -def published_kinds(tools: list[BaseTool]) -> frozenset[str]: - """Every kind the connected tools declare they publish.""" - return frozenset(publishers(tools)) - - def _from_artifact(artifact: Any) -> dict[str, Any] | None: """The MCP ``structured_content`` dict from a tool message artifact, if any.""" if not isinstance(artifact, dict): @@ -205,6 +225,9 @@ class StateCaptureMiddleware(AgentMiddleware[AgentState]): capture_undeclared: Size in serialised bytes above which an undeclared field is captured anyway. ``None`` disables it, leaving capture exactly as declared. + owners: ``{tool name: server}``, from :func:`owners`. Only undeclared + captures need it, and only to key them the same way a declared one + is keyed. """ state_schema = AgentState @@ -213,13 +236,15 @@ def __init__( self, published: Published | None = None, capture_undeclared: int | None = DEFAULT_CAPTURE_BYTES, + owners: dict[str, str] | None = None, ) -> None: super().__init__() self._published = published or {} self._capture_undeclared = capture_undeclared + self._owners = owners or {} def _updates( - self, tool_name: str, payload: dict[str, Any] + self, tool_name: str, payload: dict[str, Any], arguments: Mapping[str, Any] ) -> tuple[dict[str, StateEntry], dict[str, str]]: """The ``tool_state`` writes one return earns, and where each came from. @@ -227,7 +252,21 @@ def _updates( The caller needs it twice over: to describe what is *left* when no ``message`` told it what to say, and to record on the message where a UI host can find the value again. + + ``arguments`` are the model's, from the call this answers. Every entry + one return writes carries the same :func:`call_inputs` record: it + describes the *call*, not the field, because which output derives from + which input is the tool's business and it does not say. """ + origin = call_inputs(arguments) + + def entry(value: Any) -> StateEntry: + """One write, carrying where the producing call's arguments came from.""" + written = StateEntry(value=value, tool=tool_name) + if origin: + written["inputs"] = origin + return written + declarations = self._published.get(tool_name, {}) threshold = self._capture_undeclared updates: dict[str, StateEntry] = {} @@ -236,15 +275,20 @@ def _updates( if field == MESSAGE_KEY or BLOCKED_KEY_PATTERN.search(field): continue if declaration := declarations.get(field): - updates[declaration["stateKey"]] = StateEntry( - value=value, kind=declaration.get("kind"), tool=tool_name - ) + updates[declaration["stateKey"]] = entry(value) sources[field] = declaration["stateKey"] elif threshold is not None and _size(value) >= threshold: - updates[qualified(tool_name, field)] = StateEntry( - value=value, kind=detect_kind(value), tool=tool_name + # A server that declared nothing still gets a three-part key, + # so the model reads the same shape whatever produced it. The + # owning server is only known if the host stamped it at load; + # without that the key is the tool and the field alone. + key = ( + qualified(owner, tool_name, field) + if (owner := self._owners.get(tool_name)) + else f"{tool_name}/{field}" ) - sources[field] = qualified(tool_name, field) + updates[key] = entry(value) + sources[field] = key return updates, sources def _content(self, payload: dict[str, Any], captured: Iterable[str]) -> str: @@ -275,18 +319,19 @@ async def awrap_tool_call( # nothing structured still has a receipt to report, and every path # below this can return early. received = receipts_of(result.artifact) - used = receipt_breadcrumb(received) payload = _from_artifact(result.artifact) if payload is None: - return _noting(result, used) + return result - updates, sources = self._updates(request.tool_call["name"], payload) + updates, sources = self._updates( + request.tool_call["name"], payload, request.tool_call.get("args") or {} + ) if not updates and not isinstance(payload.get(MESSAGE_KEY), str): - return _noting(result, used) + return result written = _breadcrumb(sorted(updates)) if updates else None - notes = [note for note in (used, written) if note] + notes = [note for note in (written,) if note] captured = result.model_copy( update={ "content": _annotated(self._content(payload, sources), notes), @@ -303,22 +348,6 @@ def _annotated(content: str, notes: list[str]) -> str: return "\n\n".join(part for part in (content, *notes) if part) -def _noting(message: ToolMessage, note: str | None) -> ToolMessage: - """``message`` with ``note`` appended, or unchanged when there is none. - - Used where capture leaves the message otherwise alone. Adapter content is a - list of blocks rather than a string, so the note is appended in kind. - """ - if not note: - return message - content: Any = message.content - if isinstance(content, list): - content = [*content, {"type": "text", "text": note}] - else: - content = _annotated(str(content), [note]) - return message.model_copy(update={"content": content}) - - def _residue( payload: dict[str, Any], sources: dict[str, str], diff --git a/src/mcp_state/prompt.py b/src/mcp_state/prompt.py index 4318fcf..c4b8ea8 100644 --- a/src/mcp_state/prompt.py +++ b/src/mcp_state/prompt.py @@ -2,11 +2,11 @@ Everything else in this package is host-side machinery the model never sees. The model's half of the contract is three surfaces that do reach it: the -``[state updated: …]`` breadcrumbs capture writes, the ``[state used: …]`` -notes a declared fill leaves, and the ``inspect_state`` tool. This fragment -explains those surfaces once, in prompt form, so the model drives them -deliberately instead of inferring them from tool descriptions alone — and asks -it to carry the provenance they record into its answers. +``[state updated: …]`` breadcrumbs capture writes, the listing a refusal puts +in front of it, and the ``inspect_state`` tool. This fragment explains them +once, in prompt form, so the model drives them deliberately instead of +inferring them from tool descriptions alone — and asks it to carry the +provenance they record into its answers. It is host-agnostic and self-contained. A host that replaces the bundled prompt appends it to its own instructions:: @@ -23,8 +23,8 @@ How this system moves tool data: Large tool values — geometries, item collections, data arrays — do not pass \ -through this conversation. The host keeps them in session state and moves \ -them between tools for you. +through this conversation. The host keeps them in session state, and you move \ +them between tools by naming them. - A "[state updated: — …]" note in a tool result means the value was \ stored under that key. The value itself is not in the transcript. @@ -33,18 +33,25 @@ a handle over copying a large value into a call. A handle only works as a \ whole argument, never as a fragment inside one, and only on a parameter whose \ schema accepts it — elsewhere it is just a string, and will be taken as one. -- Some tool parameters are hidden from you on purpose: the host fills them \ -from session state at call time. A "[state used: , \ -published by ]" note after the call records which stored value was \ -used. If a tool reports that a required value is not in state yet, first call \ -the tool that produces it, then retry. +- A key reads "//", so it says which call produced the \ +value. Choose between stored values on that and on what the tool you are \ +calling asks for, not on which was written most recently. +- A listing may add "(you wrote: )". That means the call which \ +produced the value was given an argument you wrote rather than one a tool \ +supplied, so the value rests on it. Prefer a value that does not say this \ +where you have the choice, and say so when you present a result that depends \ +on one. +- Some parameters accept nothing but a handle. Their description says the \ +value must already exist; write "@state:", never a value of your own. If \ +nothing suitable has been stored yet, call the tool that produces it first. - Call inspect_state with a bare key from a "[state updated: …]" note — not \ an "@state:" handle — to read or search a stored value when you need its \ content. -Provenance: the state notes are the record of where data came from and how it \ -was reused. When you present a result, name the tool that produced the data \ +Provenance: when you present a result, name the tool that produced the data \ behind it and say where that data was reused — for example "clipped with the \ -area of interest that search_datasets returned". If a "[state used: …]" note \ -shows the host filled in a different value than the user meant, say so and \ -repeat the call with the intended "@state:" written explicitly.""" +area of interest that search_datasets returned". Where a result rests on a \ +value you wrote rather than one a tool produced, say so plainly; do not \ +recite provenance that has no bearing on whether an answer can be relied on. \ +If you passed a handle that turns out not to be the value the user meant, say \ +so and repeat the call with the intended "@state:".""" diff --git a/src/mcp_state/receipts.py b/src/mcp_state/receipts.py index 9594c5c..fbc1322 100644 --- a/src/mcp_state/receipts.py +++ b/src/mcp_state/receipts.py @@ -5,41 +5,19 @@ it, so a value can be traced from the tool that published it to the tool that consumed it without leaving the transcript. -A declared parameter is removed from the schema the model sees and filled at -call time (:mod:`mcp_state.injection`), which means nothing else records that -it was supplied at all: the tool call carries no such argument, and the return -says nothing about it. Two things follow from that, and both are what a receipt -is for. - -Where several stored values share a kind, resolution takes the most recent. -Without a receipt the model cannot tell which one it was given, so it can -neither correct a wrong pick nor describe the result accurately. And a host -rendering the call — ``mcp_agent.host.step_input``, say — shows arguments that -are missing the one value that decided the output. +A value reaches a tool because the model wrote ``@state:`` as an argument +and :mod:`mcp_state.handles` substituted it on the way out. The key is +therefore already in the transcript — but the transcript says nothing about +what was behind it, and by the time a host renders the call the argument is +just a string. A receipt adds the two facts a reader needs: the entry that key +held, and the tool that published it. Receipts ride on the tool message's artifact under :data:`INJECTED_ARTIFACT_KEY`, beside the ``captured_state`` map capture leaves -there, and :class:`~mcp_state.middleware.StateCaptureMiddleware` turns the -declared ones into a ``[state used: …]`` line next to ``[state updated: …]``. - -Handle-supplied parameters (:mod:`mcp_state.handles`) are recorded but not -turned into a line: the model wrote ``@state:`` itself, so the key is -already in the tool call arguments. - -``via`` names which of the two paths supplied the value, and its two values are -the same two rungs ``docs/SESSION-STATE.md`` calls FILL and NAME: - -- :data:`BY_DECLARATION` (``"declaration"``) is a **FILL**. The consuming tool - tagged the parameter with a ``Kind``, so the client removed it from the - model's schema and matched a stored value to it. The model never saw the - parameter, and the tool call carries no such argument. -- :data:`BY_HANDLE` (``"handle"``) is a **NAME**. The parameter stayed in the - schema, widened to also accept ``@state:``, and the model wrote that - string as the argument. Needs no cooperation from the server, so it is - available on any MCP server at all. +there. Nothing echoes them back to the model: it wrote the handle itself, so +repeating it would spend tokens on something it already knows. """ -from collections.abc import Mapping from typing import Any, NotRequired, TypedDict, cast from mcp_state.state import StateEntry @@ -49,29 +27,19 @@ #: rather than by hand. INJECTED_ARTIFACT_KEY = "injected_state" -#: The client filled this parameter by matching the kind its server declared. -#: The model never saw the parameter. -BY_DECLARATION = "declaration" - -#: The model pointed this parameter at a stored value with ``@state:``. -BY_HANDLE = "handle" - class Receipt(TypedDict): - """Where one filled parameter's value came from.""" + """Where one substituted parameter's value came from.""" #: The ``tool_state`` key the value was read from. key: str - #: :data:`BY_DECLARATION` or :data:`BY_HANDLE`. - via: str - kind: NotRequired[str | None] #: The tool that published the value, as recorded on its state entry. tool: NotRequired[str | None] -def receipt_for(key: str, entry: StateEntry, via: str) -> Receipt: +def receipt_for(key: str, entry: StateEntry) -> Receipt: """A receipt for the entry stored under ``key``.""" - return Receipt(key=key, via=via, kind=entry.get("kind"), tool=entry.get("tool")) + return Receipt(key=key, tool=entry.get("tool")) def receipts_of(artifact: Any) -> dict[str, Receipt]: @@ -93,43 +61,9 @@ def receipts_of(artifact: Any) -> dict[str, Receipt]: } -def supplied( - receipts: Mapping[str, Receipt], arguments: Mapping[str, Any] -) -> dict[str, Receipt]: - """The receipts for parameters ``arguments`` does not already account for. - - What a host showing a tool call needs: a declared parameter is absent from - the arguments the model produced, so the call reads as though it ran - without it. A handle is already there as the ``@state:`` string the - model wrote, and needs no second telling. - """ - return { - parameter: receipt - for parameter, receipt in sorted(receipts.items()) - if parameter not in arguments - } - - def describe_receipt(parameter: str, receipt: Receipt) -> str: - """One receipt as ``aoi ← dataset-search/geometry, published by search``.""" + """One receipt as ``aoi ← , published by ``.""" origin = f"{parameter} ← {receipt['key']}" if tool := receipt.get("tool"): return f"{origin}, published by {tool}" return origin - - -def breadcrumb(receipts: Mapping[str, Receipt]) -> str | None: - """The ``[state used: …]`` note, or ``None`` when there is nothing to say. - - Only declared fills are named. A handle is already in the tool call - arguments the model wrote, so repeating it here would spend tokens on - something the transcript records anyway. - """ - declared = [ - describe_receipt(parameter, receipt) - for parameter, receipt in sorted(receipts.items()) - if receipt.get("via") == BY_DECLARATION - ] - if not declared: - return None - return f"[state used: {'; '.join(declared)}]" diff --git a/src/mcp_state/state.py b/src/mcp_state/state.py index 8c94a42..6a34fd9 100644 --- a/src/mcp_state/state.py +++ b/src/mcp_state/state.py @@ -1,8 +1,8 @@ """Agent graph state that MCP tools publish into and read back from. Where a client keeps what tools exchange. :mod:`mcp_runtime.declarations` is -how a *server* may describe what it publishes and takes; this is the namespace -those values live in, and it fills up whether or not anything was declared. +how a *server* describes what it publishes; this is the namespace those values +live in, and it fills up whether or not anything was declared. A value lands here either because its tool declared it, or because it was too large to leave in the transcript. Everything lands under one namespace, so @@ -12,18 +12,27 @@ The stored values never enter the model's context: the tool message becomes the ``message`` plus a ``[state updated: …]`` breadcrumb. From there a value travels one of two ways — the model reads it on demand with ``inspect_state``, -or the client feeds it straight back into a later tool call without the model -ever seeing it (:mod:`mcp_state.injection`). - -Two properties of the namespace make that second path work: - -Keys are *qualified* — ``dataset-search/geometry`` rather than ``geometry`` -(see :func:`mcp_runtime.declarations.qualified`), so one toolset's write cannot -overwrite another's. - -Values are wrapped in a :class:`StateEntry` rather than stored bare, because -resolving by kind has to know each value's kind and which write was most -recent. Readers take ``entry["value"]``. +or it points a tool parameter at the key with ``@state:`` and the client +substitutes the value on the way out (:mod:`mcp_state.handles`), so the value +itself never passes through the transcript either way. + +Keys are *qualified* — ``dataset-search/search_datasets/area_of_interest`` +rather than ``area_of_interest`` (see +:func:`mcp_runtime.declarations.qualified`), so one toolset's write cannot +overwrite another's, and so the key a model reads says which call produced the +value. + +Values are wrapped in a :class:`StateEntry` rather than stored bare, because a +listing has to say where each value came from and which write was most recent. +Readers take ``entry["value"]``. + +An entry also records what the call that produced it was *given* +(``inputs``). A tool owns its outputs and this never second-guesses them; what +it records is the one thing the client knows for certain, which is where each +argument came from. Read one level — the call that produced the entry you are +looking at — and it says whether a value rests on something a tool produced or +on something the model wrote. Read further, since every argument it names is +either the model or another key, and it is a chain. """ import json @@ -40,22 +49,58 @@ #: by anyone running the agent under a checkpointer. #: #: Set high enough that an ordinary session never reaches it (hundreds of large -#: geometries), because eviction is not free: a consumer resolving by kind can -#: only find what is still here. Newest-first is the right order to keep for -#: exactly that reason — kind resolution already prefers the highest ``seq``. +#: geometries), because eviction is not free: a handle names a key, and a key +#: that has been evicted resolves to nothing. Newest-first is the right order to +#: keep for exactly that reason. MAX_TOOL_STATE_BYTES = 8 * 1024 * 1024 class StateEntry(TypedDict): - """One published value, with what a consumer needs to find it again.""" + """One published value, with what a reader needs to make sense of it.""" value: Any - kind: NotRequired[str | None] tool: NotRequired[str] - #: Monotonic write order, assigned by :func:`merge_tool_state`. Kind - #: resolution picks the highest — "the AOI we are working with" is - #: reliably the most recently published one. + #: Monotonic write order, assigned by :func:`merge_tool_state`. What orders + #: the listing a model chooses from, newest first. seq: NotRequired[int] + #: Where each argument of the producing call came from: a ``tool_state`` + #: key, or :data:`MODEL_AUTHORED` for one the model wrote. Absent where + #: that call took no arguments. Parameter names and keys only — never + #: values, so this stays cheap however large the call was. + inputs: NotRequired[dict[str, str]] + + +#: Recorded in a :class:`StateEntry`'s ``inputs`` for an argument the model +#: wrote itself, as against one that named a stored value. +MODEL_AUTHORED = "model" + + +def authored(entry: StateEntry | None) -> list[str]: + """The parameters of an entry's producing call that the model wrote. + + Empty for a value whose call took only stored values, and for one captured + before ``inputs`` was recorded. One level: this reads the call that + produced *this* entry and follows nothing further, because at depth "the + model wrote something upstream" is true of every value in a session — it + wrote the query that found the dataset. + """ + inputs = entry.get("inputs") if entry else None + return sorted( + name for name, origin in (inputs or {}).items() if origin == MODEL_AUTHORED + ) + + +def rests_on_state(entry: StateEntry | None) -> list[str]: + """The ``tool_state`` keys an entry's producing call was given. + + The other half of ``inputs`` from :func:`authored`, and the half that says + a value was built on something a tool found rather than on something + invented. Same one level, for the same reason. + """ + inputs = entry.get("inputs") if entry else None + return sorted( + origin for origin in (inputs or {}).values() if origin != MODEL_AUTHORED + ) def merge_tool_state( @@ -122,18 +167,3 @@ class AgentState(_BaseAgentState): """The agent's built-in state plus the namespace tools publish into.""" tool_state: NotRequired[Annotated[dict[str, StateEntry], merge_tool_state]] - - -def entries_of_kind( - tool_state: dict[str, StateEntry] | None, kind: str -) -> list[tuple[str, StateEntry]]: - """Every published entry of ``kind``, most recently written first.""" - return sorted( - ( - (key, entry) - for key, entry in (tool_state or {}).items() - if entry.get("kind") == kind - ), - key=lambda item: item[1].get("seq", 0), - reverse=True, - ) diff --git a/src/mcp_state/wiring.py b/src/mcp_state/wiring.py deleted file mode 100644 index 0d6c6f5..0000000 --- a/src/mcp_state/wiring.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Check that every declared parameter has something that can satisfy it. - -A ``Kind``-tagged parameter names the kind it takes. If nothing connected -*publishes* that kind, the declaration can never be satisfied. Whether that -matters depends on what the tag said: - -- ``model_generatable`` (the default) is fine — the parameter stays in the - model's schema and is filled the way any plain MCP client would fill it. -- ``model_generatable=False`` on a *required* parameter is fatal: every call - raises before it reaches the server, so the tool should not be offered. -- ``model_generatable=False`` on an *optional* one leaves it empty forever, - and the tool falls back to whatever default it chose. - -All three are worth reporting — a mistyped kind string or a toolset deployed -without the one that feeds it is invisible until a user happens to trigger it. - -Runs at connect, the first point holding every connected server's declarations -— and holding them for the servers actually running rather than the ones a -manifest expects. A server checking its own tools cannot do it: the producer of -a kind usually lives in another toolset. - -The check is on the wiring, not on membership of :mod:`mcp_runtime.kinds`, so -a kind this package has never heard of is fine and a mistyped one still shows -up as unsatisfiable. -""" - -from dataclasses import dataclass - -from langchain_core.tools import BaseTool - -from mcp_state.injection import ( - declarations_for, - model_generatable, - satisfiable, - wants, -) -from mcp_state.middleware import published_kinds - - -@dataclass(frozen=True) -class Unsatisfiable: - """One declared parameter nothing connected publishes the kind for.""" - - tool: str - parameter: str - #: The kind the parameter asked for. - wants: str - #: Whether the tool's own schema requires the parameter. - required: bool - #: Whether the tool said a model may produce the value instead. - model_generatable: bool - - @property - def fatal(self) -> bool: - """Whether this makes the tool impossible to call.""" - return self.required and not self.model_generatable - - def __str__(self) -> str: - if self.model_generatable: - outcome = "the model is asked for it" - elif self.required: - outcome = "the tool cannot be called" - else: - outcome = "it stays empty" - return f"{self.tool}.{self.parameter} wants {self.wants} — {outcome}" - - -def unsatisfiable(tools: list[BaseTool]) -> list[Unsatisfiable]: - """Every declared parameter nothing in ``tools`` publishes the kind for. - - Empty means the wiring is sound: each declared parameter has at least one - publisher of the right kind among the connected servers. Ordered by tool - then parameter, so the output is stable enough to diff between deployments. - """ - published = published_kinds(tools) - found = [ - Unsatisfiable( - tool=tool.name, - parameter=declaration["parameter"], - wants=wants(declaration) or "nothing (malformed declaration)", - required=bool(declaration.get("required", True)), - model_generatable=model_generatable(declaration), - ) - for tool in tools - for declaration in declarations_for(tool) - if not satisfiable(declaration, published) - ] - return sorted(found, key=lambda item: (item.tool, item.parameter)) - - -def partition_usable( - tools: list[BaseTool], -) -> tuple[list[BaseTool], list[Unsatisfiable]]: - """Split tools into the ones a model can actually call, and why the rest can't. - - A tool with a required parameter that nothing publishes and a model may not - invent is dead: every call raises before it reaches the server. Advertising - it to the model only buys failed turns and a confusing error, so the usual - handling is to leave it out of the agent and report what was left out:: - - agent_tools, withheld = partition_usable(bind_all_injected(tools)) - - Only a *fatal* declaration withholds a tool — the other outcomes leave it - callable. An optional one is simply omitted from the call, so the tool - picks its own default; a model-generatable one stays in the schema. - - This is about whether a *publisher is connected*, never about whether a - value has been published yet. A tool whose producer is connected but has - not run stays available — the model is meant to call it, be told to run the - producer first, and try again. - """ - withheld = [item for item in unsatisfiable(tools) if item.fatal] - blocked = {item.tool for item in withheld} - return [tool for tool in tools if tool.name not in blocked], withheld - - -def raise_unsatisfiable(tools: list[BaseTool], *, fatal_only: bool = True) -> None: - """Refuse to start when the state wiring cannot work. - - ``fatal_only`` (the default) reports only declarations that leave a tool - uncallable. Pass ``False`` to treat any unsatisfiable declaration as an - error, including ones that degrade to a tool default or to the model. - """ - found = [item for item in unsatisfiable(tools) if item.fatal or not fatal_only] - if not found: - return - listed = "\n ".join(str(item) for item in found) - raise RuntimeError( - "no connected tool publishes what these declared parameters need — " - "check the kind strings, or connect the toolset that produces them:" - f"\n {listed}" - ) diff --git a/tests/mcp_agent/test_agent_state.py b/tests/mcp_agent/test_agent_state.py index a636b06..91b1486 100644 --- a/tests/mcp_agent/test_agent_state.py +++ b/tests/mcp_agent/test_agent_state.py @@ -23,8 +23,7 @@ run_turn, with_session_state, ) -from mcp_runtime.declarations import CONSUMES_META_KEY, PRODUCES_META_KEY -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, PRODUCES_META_KEY from mcp_state import SESSION_STATE_PROMPT from mcp_state.state import TOOL_STATE_KEY @@ -33,23 +32,13 @@ PUBLISHES_AOI = { PRODUCES_META_KEY: [ { - "stateKey": "dataset-search/geometry", + "stateKey": "dataset-search/search_datasets/geometry", "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, } ] } -NEEDS_AOI = { - CONSUMES_META_KEY: [ - { - "parameter": "aoi", - "kind": GEOJSON_AREA_OF_INTEREST, - "required": True, - "modelGeneratable": False, - } - ] -} +NEEDS_AOI = {NOT_AUTHORED_META_KEY: ["aoi"]} def mcp_tool(name: str, meta: dict[str, Any] | None = None) -> StructuredTool: @@ -112,10 +101,9 @@ async def call() -> tuple[str, dict[str, Any]]: def _agent_over(script: list[Any]) -> Any: """A real agent over a real in-process checkpointer, driven by ``script``.""" - agent, _ = with_session_state( + return with_session_state( _ScriptedModel(messages=iter(script)), [_publishing_tool()], InMemorySaver() ) - return agent def _tool_call(index: int) -> AIMessage: @@ -168,21 +156,23 @@ def test_all_three_pieces_are_installed(monkeypatch): assert TOOL_STATE_KEY in middleware.state_schema.__annotations__ -def test_an_uncallable_tool_is_withheld_and_reported(monkeypatch): - # clip needs an AOI nothing publishes and may not be invented: every call - # would raise before reaching the server, so it is not offered at all. +def test_a_tool_nothing_can_satisfy_yet_is_still_offered(monkeypatch): + """Nothing is taken away at connect: a producer may run later in the turn. + + What the tool cannot do is be called with a value the model wrote — its + schema forbids that, and a call with nothing in state is refused with a + message the model can act on. + """ recorded = _record_create_agent(monkeypatch) - _, withheld = with_session_state("model", [mcp_tool("clip", NEEDS_AOI)]) - assert "clip" not in recorded["tools"] - assert [(item.tool, item.parameter) for item in withheld] == [("clip", "aoi")] + with_session_state("model", [mcp_tool("clip", NEEDS_AOI)]) + assert "clip" in recorded["tools"] def test_a_tool_stays_when_its_producer_is_connected(monkeypatch): recorded = _record_create_agent(monkeypatch) - _, withheld = with_session_state( + with_session_state( "model", [mcp_tool("clip", NEEDS_AOI), mcp_tool("search", PUBLISHES_AOI)] ) - assert withheld == [] assert {"clip", "search"} <= set(recorded["tools"]) @@ -215,12 +205,9 @@ def test_a_host_can_layer_its_own_prompt_tools_and_middleware(monkeypatch): def test_an_extra_tool_is_not_treated_as_an_mcp_tool(monkeypatch): - """A host's own tool is added as given: not bound to state, not withheld.""" + """A host's own tool is added as given: not bound to session state.""" recorded = _record_create_agent(monkeypatch) - _, withheld = with_session_state( - "model", [], extra_tools=[mcp_tool("clip", NEEDS_AOI)] - ) - assert withheld == [] + with_session_state("model", [], extra_tools=[mcp_tool("clip", NEEDS_AOI)]) assert "clip" in recorded["tools"] @@ -278,10 +265,12 @@ async def test_state_and_transcript_both_survive_between_turns(): ) first = await run_turn(agent, "find it", "thread-1") - assert list(first.sidecar or {}) == ["dataset-search/geometry"] + assert list(first.sidecar or {}) == ["dataset-search/search_datasets/geometry"] turn = await run_turn(agent, "and again", "thread-1") - assert list(turn.sidecar or {}) == ["dataset-search/geometry"], "state must persist" + assert list(turn.sidecar or {}) == ["dataset-search/search_datasets/geometry"], ( + "state must persist" + ) assert len(turn.history) > len(turn.new_messages), "the transcript must persist too" assert "find it" in [str(message.content) for message in turn.history] @@ -293,7 +282,7 @@ async def test_threads_do_not_share_state(): ) first = await run_turn(agent, "find it", "thread-1") - assert list(first.sidecar or {}) == ["dataset-search/geometry"] + assert list(first.sidecar or {}) == ["dataset-search/search_datasets/geometry"] second = await run_turn(agent, "what do you have?", "thread-2") assert not (second.sidecar or {}), "a fresh thread starts with empty state" @@ -369,9 +358,7 @@ def _received(receipts: dict) -> ToolMessage: FILLED = { "aoi": { - "key": "dataset-search/geometry", - "via": "declaration", - "kind": "geojson.AreaOfInterest", + "key": "dataset-search/search_datasets/geometry", "tool": "search_datasets", } } @@ -380,14 +367,17 @@ def _received(receipts: dict) -> ToolMessage: def test_the_cli_names_the_parameter_the_printed_call_omits(): """`→ clip_raster {'dataset_id': 'chirps'}` shows no aoi; this is why.""" assert receipt_lines({"dataset_id": "chirps"}, _received(FILLED)) == [ - "aoi ← dataset-search/geometry, published by search_datasets" + "aoi ← dataset-search/search_datasets/geometry, published by search_datasets" ] -def test_the_cli_does_not_repeat_a_handle_already_in_the_call(): - args = {"geometry": "@state:dataset-search/geometry"} - handle = {"geometry": {**FILLED["aoi"], "via": "handle"}} - assert receipt_lines(args, _received(handle)) == [] +def test_the_cli_resolves_a_handle_to_what_it_named(): + """The argument shows the key; this says who published it.""" + args = {"geometry": "@state:dataset-search/search_datasets/geometry"} + assert receipt_lines(args, _received({"geometry": FILLED["aoi"]})) == [ + "geometry ← dataset-search/search_datasets/geometry, " + "published by search_datasets" + ] def test_the_cli_prints_nothing_for_a_tool_that_took_nothing_from_state(): diff --git a/tests/mcp_agent/test_host.py b/tests/mcp_agent/test_host.py index 254879d..6877d01 100644 --- a/tests/mcp_agent/test_host.py +++ b/tests/mcp_agent/test_host.py @@ -159,97 +159,62 @@ def _received(call_id: str, name: str, receipts: dict) -> ToolMessage: AOI = {"type": "FeatureCollection", "features": [{"id": "poly"}]} STATE = { - "dataset-search/geometry": { + "dataset-search/search_datasets/geometry": { "value": AOI, - "kind": "geojson.AreaOfInterest", "tool": "search_datasets", "seq": 1, } } -FILLED = { - "aoi": { - "key": "dataset-search/geometry", - "via": "declaration", - "kind": "geojson.AreaOfInterest", - "tool": "search_datasets", - } +RECEIPT = { + "aoi": {"key": "dataset-search/search_datasets/geometry", "tool": "search_datasets"} } -def test_step_input_shows_the_parameter_the_model_never_saw(): - """A declared parameter is pruned from the schema, so the call it produced - has no `aoi` in it — the step would show a clip that ran against nothing.""" - shown = step_input( - {"dataset_id": "chirps"}, _received("2", "clip_raster", FILLED), STATE - ) - - assert shown["dataset_id"] == "chirps" - assert shown["aoi"] == ( - "← dataset-search/geometry · geojson.AreaOfInterest · " - "1 feature(s), 0 vertices · from search_datasets" - ) - - def test_step_input_says_what_a_handle_resolved_to(): """`@state:` names a value without describing it. The key stays put — the model wrote it — and what it does not say is appended.""" - args = {"geometry": "@state:dataset-search/geometry"} - handle = {"geometry": {**FILLED["aoi"], "via": "handle"}} + args = {"geometry": "@state:dataset-search/search_datasets/geometry"} + handle = {"geometry": RECEIPT["aoi"]} shown = step_input(args, _received("4", "describe", handle), STATE) assert shown["geometry"] == ( - "@state:dataset-search/geometry · geojson.AreaOfInterest · " - "1 feature(s), 0 vertices · from search_datasets" + "@state:dataset-search/search_datasets/geometry · 1 feature(s), 0 vertices · from search_datasets" ) def test_step_input_never_prints_a_handles_key_twice(): - """The declared form leads with `← `, which here would land beside the - identical key the model already wrote.""" - args = {"geometry": "@state:dataset-search/geometry"} - handle = {"geometry": {**FILLED["aoi"], "via": "handle"}} + """The key the model wrote is the argument; repeating it beside itself + would be noise.""" + args = {"geometry": "@state:dataset-search/search_datasets/geometry"} + handle = {"geometry": RECEIPT["aoi"]} shown = step_input(args, _received("4", "describe", handle), STATE) - assert shown["geometry"].count("dataset-search/geometry") == 1 + assert shown["geometry"].count("dataset-search/search_datasets/geometry") == 1 assert "←" not in shown["geometry"] -def test_step_input_annotates_an_untagged_handle(): - """Nothing has to be tagged for a value to be reachable by handle, so the - kind is routinely absent — the shape and the publisher still are not.""" - args = {"request": "@state:gazet/aoi"} - handle = {"request": {"key": "gazet/aoi", "via": "handle", "tool": "get_aoi"}} - state = {"gazet/aoi": {**STATE["dataset-search/geometry"], "kind": None}} +def test_step_input_annotates_a_handle_with_no_publisher(): + """``tool`` is optional on a state entry, so the line degrades to the + shape alone rather than dropping out.""" + args = {"request": "@state:gazet/get_aoi/aoi"} + handle = {"request": {"key": "gazet/get_aoi/aoi"}} + state = {"gazet/get_aoi/aoi": {"value": AOI, "seq": 1}} shown = step_input(args, _received("5", "submit", handle), state) - assert shown["request"] == ( - "@state:gazet/aoi · untyped · 1 feature(s), 0 vertices · from get_aoi" - ) - - -def test_step_input_carries_both_paths_in_one_call(): - """A tool can take one of each; neither treatment leaks into the other.""" - args = {"geometry": "@state:dataset-search/geometry"} - both = { - "geometry": {**FILLED["aoi"], "via": "handle"}, - "aoi": FILLED["aoi"], - } - - shown = step_input(args, _received("6", "clip_raster", both), STATE) - - assert shown["geometry"].startswith("@state:dataset-search/geometry · ") - assert shown["aoi"].startswith("← dataset-search/geometry · ") + assert shown["request"] == ("@state:gazet/get_aoi/aoi · 1 feature(s), 0 vertices") def test_step_input_falls_back_when_the_value_is_no_longer_in_state(): """State is bounded and keys are overwritten; the origin is still true.""" - shown = step_input({}, _received("2", "clip_raster", FILLED), {}) + args = {"aoi": "@state:dataset-search/search_datasets/geometry"} + shown = step_input(args, _received("2", "clip_raster", RECEIPT), {}) - assert shown["aoi"] == ( - "← dataset-search/geometry · geojson.AreaOfInterest · from search_datasets" + assert ( + shown["aoi"] + == "@state:dataset-search/search_datasets/geometry · from search_datasets" ) @@ -285,3 +250,37 @@ def test_the_web_host_still_re_exports_them(): for name in host.__all__ if hasattr(host, "__all__") else web.__all__: assert getattr(web, name) is getattr(host, name) + + +def test_step_input_names_what_the_model_wrote_upstream() -> None: + """A reader looking at a result wants to know what it rests on.""" + key = "gazet/get_aoi/bbox" + args = {"area": f"@state:{key}"} + receipt = {"area": {"key": key, "tool": "get_aoi"}} + state = { + key: { + "value": [12.4, 55.6, 12.7, 55.8], + "tool": "get_aoi", + "seq": 1, + "inputs": {"bbox": "model"}, + } + } + + shown = step_input(args, _received("7", "submit", receipt), state) + + assert shown["area"] == ( + f"@state:{key} · 4 item(s) · from get_aoi · bbox written by the model" + ) + + +def test_step_input_says_nothing_where_a_tool_supplied_everything() -> None: + key = "raster-ops/clip/geometry" + args = {"g": f"@state:{key}"} + receipt = {"g": {"key": key, "tool": "clip"}} + state = { + key: {"value": AOI, "tool": "clip", "seq": 1, "inputs": {"aoi": "ds/s/aoi"}} + } + + shown = step_input(args, _received("8", "describe", receipt), state) + + assert "written by the model" not in shown["g"] diff --git a/tests/mcp_agent/test_streaming.py b/tests/mcp_agent/test_streaming.py index a0f35c5..dfd7b28 100644 --- a/tests/mcp_agent/test_streaming.py +++ b/tests/mcp_agent/test_streaming.py @@ -31,8 +31,7 @@ TurnFinished, stream_turn, ) -from mcp_runtime.declarations import CONSUMES_META_KEY, PRODUCES_META_KEY -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, PRODUCES_META_KEY AOI = {"type": "FeatureCollection", "features": [{"id": "polygon"}]} STATE_KEY = "dataset-search/geometry" @@ -116,23 +115,19 @@ async def call() -> tuple[str, dict[str, Any]]: coroutine=call, response_format="content_and_artifact", metadata={ - "_meta": { - PRODUCES_META_KEY: [ - { - "stateKey": key, - "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, - } - ] - } + "_meta": {PRODUCES_META_KEY: [{"stateKey": key, "field": "geometry"}]} }, ) def _publisher() -> StructuredTool: - """Publishes a geometry into state, tagged with its kind.""" + """Publishes a geometry into state, under the key its server declared. - async def call() -> tuple[str, dict[str, Any]]: + Takes a query, so the entry it writes has a producing call with an argument + to record — which is the ordinary case and the one worth exercising. + """ + + async def call(q: str = "") -> tuple[str, dict[str, Any]]: return "found 3", { "structured_content": {"message": "found 3", "geometry": AOI} } @@ -140,25 +135,17 @@ async def call() -> tuple[str, dict[str, Any]]: return StructuredTool( name="search", description="search", - args_schema={"type": "object", "properties": {}}, + args_schema={"type": "object", "properties": {"q": {"type": "string"}}}, coroutine=call, response_format="content_and_artifact", metadata={ - "_meta": { - PRODUCES_META_KEY: [ - { - "stateKey": STATE_KEY, - "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, - } - ] - } + "_meta": {PRODUCES_META_KEY: [{"stateKey": STATE_KEY, "field": "geometry"}]} }, ) def _consumer() -> StructuredTool: - """Takes that geometry on a parameter the model never sees.""" + """Takes that geometry on a parameter only a handle fits.""" async def call(**arguments: Any) -> tuple[str, dict[str, Any]]: return "clipped", {"structured_content": {"ok": True}} @@ -169,25 +156,18 @@ async def call(**arguments: Any) -> tuple[str, dict[str, Any]]: args_schema={"type": "object", "properties": {"aoi": {"type": "object"}}}, coroutine=call, response_format="content_and_artifact", - metadata={ - "_meta": { - CONSUMES_META_KEY: [ - { - "parameter": "aoi", - "kind": GEOJSON_AREA_OF_INTEREST, - "required": True, - "modelGeneratable": False, - } - ] - } - }, + metadata={"_meta": {NOT_AUTHORED_META_KEY: ["aoi"]}}, ) -def _tool_call(name: str, call_id: str) -> AIMessage: +def _tool_call( + name: str, call_id: str, args: dict[str, Any] | None = None +) -> AIMessage: return AIMessage( content="", - tool_calls=[{"name": name, "args": {}, "id": call_id, "type": "tool_call"}], + tool_calls=[ + {"name": name, "args": args or {}, "id": call_id, "type": "tool_call"} + ], ) @@ -197,16 +177,15 @@ def _tool_call(name: str, call_id: str) -> AIMessage: def _agent(script: list[BaseMessage] | None = None) -> Any: if script is None: script = [ - _tool_call("search", "c1"), - _tool_call("clip", "c2"), + _tool_call("search", "c1", {"q": "rainfall"}), + _tool_call("clip", "c2", {"aoi": f"@state:{STATE_KEY}"}), AIMessage(content=ANSWER), ] - agent, _ = with_session_state( + return with_session_state( StreamingScriptedModel(script=script), [_publisher(), _consumer()], InMemorySaver(), ) - return agent async def _collect( @@ -255,7 +234,7 @@ async def test_tokens_arrive_before_the_turn_finishes(): assert kinds.index("ToolFinished") < kinds.index("AnswerChunk") -async def test_a_declared_fill_is_reported_on_the_tool_that_received_it(): +async def test_a_handle_is_reported_on_the_tool_that_received_it(): events = await _collect(_agent()) clip = next( @@ -263,22 +242,15 @@ async def test_a_declared_fill_is_reported_on_the_tool_that_received_it(): for event in events if isinstance(event, ToolFinished) and event.name == "clip" ) - assert clip.received == { - "aoi": { - "key": STATE_KEY, - "via": "declaration", - "kind": GEOJSON_AREA_OF_INTEREST, - "tool": "search", - } - } + assert clip.received == {"aoi": {"key": STATE_KEY, "tool": "search"}} -def test_the_model_never_saw_the_filled_parameter(): - """Not a streaming claim as such, but the reason `received` has to exist: - the argument is absent from the call, so the tool step would otherwise show - a clip that ran against nothing.""" - call = _tool_call("clip", "c2") - assert "aoi" not in (call.tool_calls[0]["args"] or {}) +def test_the_call_carries_the_key_but_never_the_value(): + """Why `received` has to exist: the argument names a value without saying + anything about it, so the tool step would otherwise show only a string.""" + call = _tool_call("clip", "c2", {"aoi": f"@state:{STATE_KEY}"}) + assert call.tool_calls[0]["args"]["aoi"] == f"@state:{STATE_KEY}" + assert "FeatureCollection" not in str(call.tool_calls[0]["args"]) async def test_what_a_tool_published_is_reported_too(): @@ -318,7 +290,7 @@ async def call() -> list[dict[str, Any]]: args_schema={"type": "object", "properties": {}}, coroutine=call, ) - agent, _ = with_session_state( + agent = with_session_state( StreamingScriptedModel( script=[_tool_call("describe", "c1"), AIMessage(content=ANSWER)] ), @@ -338,10 +310,10 @@ async def test_state_changes_are_a_running_total_not_the_latest_write(): ``tool_state`` straight off an update sees the newest and believes it is the only one.""" second = _publisher_named("second", "b/geometry") - agent, _ = with_session_state( + agent = with_session_state( StreamingScriptedModel( script=[ - _tool_call("search", "c1"), + _tool_call("search", "c1", {"q": "rainfall"}), _tool_call("second", "c2"), AIMessage(content="done"), ] @@ -366,7 +338,7 @@ async def test_a_later_turns_running_total_starts_from_the_thread(): seeded empty would announce a second turn's one key as though the thread held nothing else — and every consumer would have to merge to undo it. """ - agent, _ = with_session_state( + agent = with_session_state( StreamingScriptedModel( script=[ _tool_call("first", "c1"), @@ -396,7 +368,7 @@ async def test_a_state_change_carries_the_value_a_display_needs(): change = next(event for event in events if isinstance(event, StateChanged)) assert change.state[STATE_KEY]["value"] == AOI - assert change.state[STATE_KEY]["kind"] == GEOJSON_AREA_OF_INTEREST + assert change.state[STATE_KEY]["tool"] == "search" async def test_a_turn_that_writes_no_state_reports_no_change(): diff --git a/tests/mcp_agent_api/test_events.py b/tests/mcp_agent_api/test_events.py index aa94847..6cee3ad 100644 --- a/tests/mcp_agent_api/test_events.py +++ b/tests/mcp_agent_api/test_events.py @@ -17,14 +17,12 @@ from mcp_agent.main import with_session_state from mcp_agent.streaming import stream_turn -from mcp_state.wiring import Unsatisfiable from mcp_agent_api.events import ( ANSWER_CITATIONS, MCP_VIEW, STATE_CONSUMED, STATE_NAMESPACE, STATE_PUBLISHED, - TOOLS_WITHHELD, agui_events, state_metadata, state_patch, @@ -55,21 +53,6 @@ async def call(**arguments: Any) -> str: ) -def _withheld(name: str) -> Unsatisfiable: - """One dropped tool, as ``partition_usable`` reports it. - - ``BuiltAgent.withheld`` is a list of these, not of names — a test passing - strings would exercise a caller that does not exist. - """ - return Unsatisfiable( - tool=name, - parameter="aoi", - wants="geojson.AreaOfInterest", - required=True, - model_generatable=False, - ) - - async def _events(agent: Any = None, **kwargs: Any) -> list: turn = stream_turn(agent or _agent(), "clip chirps", "t1") return [ @@ -119,37 +102,12 @@ def _activities(events: list) -> dict[str, Any]: async def test_the_run_is_framed_and_nothing_precedes_it(): - """The client's verifier rejects a stream whose first event is anything but - RUN_STARTED — including an activity announcing withheld tools.""" - events = await _events(withheld=[_withheld("submit_request")]) + """The client's verifier rejects a stream whose first event is anything + but RUN_STARTED.""" + events = await _events() assert _types(events)[0] == "RUN_STARTED" assert _types(events)[-1] == "RUN_FINISHED" - assert _types(events)[1] == "ACTIVITY_SNAPSHOT" - - -async def test_withheld_tools_are_announced_once(): - events = await _events( - withheld=[_withheld("submit_request"), _withheld("download")] - ) - - content = _activities(events)[TOOLS_WITHHELD] - # Each declaration in full: a client can name the parameter and the kind, - # not just the tool it lost. - assert [item["tool"] for item in content["tools"]] == [ - "submit_request", - "download", - ] - assert content["tools"][0]["wants"] == "geojson.AreaOfInterest" - assert content["tools"][0]["parameter"] == "aoi" - assert "submit_request" in content["display"] - assert _types(events).count("ACTIVITY_SNAPSHOT") == len(_activities(events)) - - -async def test_nothing_is_announced_when_no_tool_was_withheld(): - events = await _events() - - assert TOOLS_WITHHELD not in _activities(events) async def test_a_tool_call_is_a_full_lifecycle(): @@ -168,7 +126,7 @@ async def test_arguments_go_out_as_json(): events = await _events() args = [e for e in events if e.type.value == "TOOL_CALL_ARGS"] - assert json.loads(args[0].delta) == {} + assert json.loads(args[0].delta) == {"q": "rainfall"} async def test_a_receipt_rides_an_activity_beside_its_tool_call(): @@ -176,7 +134,6 @@ async def test_a_receipt_rides_an_activity_beside_its_tool_call(): received = _activities(events)[STATE_CONSUMED]["received"] assert received["aoi"]["key"] == STATE_KEY - assert received["aoi"]["via"] == "declaration" assert received["aoi"]["tool"] == "search" @@ -187,7 +144,8 @@ async def test_the_receipt_carries_the_line_the_chainlit_host_shows(): display = _activities(events)[STATE_CONSUMED]["received"]["aoi"]["display"] assert display == ( - f"← {STATE_KEY} · geojson.AreaOfInterest · 1 feature(s), 0 vertices · from search" + f"@state:{STATE_KEY} · 1 feature(s), 0 vertices · from search" + " · q written by the model" ) @@ -366,7 +324,6 @@ async def test_state_deltas_carry_metadata_and_never_the_value(): deltas = _deltas(events) assert deltas, "a tool published, so state changed" entry = _applied(events)[STATE_NAMESPACE][STATE_KEY] - assert entry["kind"] == "geojson.AreaOfInterest" assert entry["tool"] == "search" assert entry["bytes"] > 0 assert "value" not in entry @@ -418,7 +375,7 @@ async def test_a_call_with_nothing_to_render_announces_no_view(): way, and announcing a view anyway gives a client a panel it can only render empty, which next to a retry of the same tool reads as a duplicate.""" viewed = _viewed("show") - agent, _ = with_session_state( + agent = with_session_state( StreamingScriptedModel( script=[_tool_call("show", "c1"), AIMessage(content="done")] ), @@ -551,7 +508,7 @@ async def test_citations_come_after_the_answer_because_that_is_where_they_belong async def test_no_activity_deltas_are_emitted(): """Deltas fail silently client-side — an orphan is dropped without error — so every activity here is a snapshot complete in itself.""" - events = await _events(withheld=[_withheld("x")]) + events = await _events() assert "ACTIVITY_DELTA" not in _types(events) @@ -573,20 +530,20 @@ async def test_each_new_message_gets_its_own_id(): """Only the events that *create* a message: an answer's START/CONTENT/END share one id by design. Reusing an id across two creations would overwrite the first message, since a snapshot replaces by default.""" - events = await _events(withheld=[_withheld("x")]) + events = await _events() creates = {"ACTIVITY_SNAPSHOT", "TOOL_CALL_RESULT", "TEXT_MESSAGE_START"} ids = [event.message_id for event in events if event.type.value in creates] assert len(ids) == len(set(ids)) - assert len(ids) >= 4 # withheld, two tool results, the answer + assert len(ids) >= 3 # two tool results, the answer def test_state_metadata_survives_a_value_that_will_not_serialise(): class Opaque: __slots__ = () - metadata = state_metadata({"k": {"value": Opaque(), "kind": None, "tool": "t"}}) + metadata = state_metadata({"k": {"value": Opaque(), "tool": "t"}}) assert metadata["k"]["tool"] == "t" assert metadata["k"]["bytes"] is not None # default=str still measures it @@ -594,9 +551,7 @@ class Opaque: async def test_the_whole_run_encodes_as_sse(): """Every event has to survive the encoder — a payload that will not serialise fails at the socket, mid-run, where a client cannot recover.""" - events = await _events( - withheld=[_withheld("x")], tools={"search": _viewed("search")} - ) + events = await _events(tools={"search": _viewed("search")}) encoder = EventEncoder() wire = "".join(encoder.encode(event) for event in events) diff --git a/tests/mcp_agent_api/test_history.py b/tests/mcp_agent_api/test_history.py index 087ca19..afc4991 100644 --- a/tests/mcp_agent_api/test_history.py +++ b/tests/mcp_agent_api/test_history.py @@ -25,7 +25,6 @@ from mcp_agent_api.history import turns_of from mcp_agent_api.routes import create_router from mcp_runtime.declarations import PRODUCES_META_KEY -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST from tests.mcp_agent.test_streaming import STATE_KEY, StreamingScriptedModel, _tool_call THREAD = "many-turns" @@ -56,7 +55,6 @@ async def call() -> tuple[str, dict[str, Any]]: { "stateKey": STATE_KEY, "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, } ] } @@ -69,12 +67,12 @@ def _built(turns: int = 2) -> BuiltAgent: script: list[Any] = [] for n in range(turns): script += [_tool_call("search", f"c{n}"), AIMessage(content=f"answer {n + 1}")] - agent, withheld = with_session_state( + agent = with_session_state( StreamingScriptedModel(script=script), [_versioning_publisher()], InMemorySaver(), ) - return BuiltAgent(agent, {}, [_versioning_publisher()], withheld, None) + return BuiltAgent(agent, {}, [_versioning_publisher()], None) def _client(built: BuiltAgent) -> httpx.AsyncClient: @@ -124,7 +122,6 @@ async def test_each_turn_carries_the_state_it_ended_with() -> None: for turn in history: entry = turn["state"][STATE_KEY] - assert entry["kind"] == GEOJSON_AREA_OF_INTEREST assert entry["tool"] == "search" diff --git a/tests/mcp_agent_api/test_routes.py b/tests/mcp_agent_api/test_routes.py index d001907..4a57762 100644 --- a/tests/mcp_agent_api/test_routes.py +++ b/tests/mcp_agent_api/test_routes.py @@ -22,7 +22,6 @@ from langgraph.checkpoint.memory import InMemorySaver from mcp_agent.main import BuiltAgent, _credentials, with_session_state -from mcp_agent_api.events import TOOLS_WITHHELD from mcp_agent_api.routes import ( Built, ViewCache, @@ -31,7 +30,6 @@ latest_user_text, thread_messages, ) -from mcp_state.wiring import Unsatisfiable from tests.mcp_agent.test_streaming import ( AOI, STATE_KEY, @@ -56,7 +54,6 @@ def _built(**overrides: Any) -> BuiltAgent: "agent": _agent(), "connections": {}, "tools": [_publisher(), _consumer()], - "withheld": [], "required": None, } return BuiltAgent(**{**fields, **overrides}) @@ -176,32 +173,6 @@ async def test_a_client_without_a_thread_id_is_told_the_one_it_got(): assert (await client.get(f"/threads/{thread_id}")).status_code == 200 -async def test_withheld_tools_reach_the_client(): - """The first caller to pass ``BuiltAgent.withheld`` through for real. It is - a list of ``Unsatisfiable``, and rendering it as though it were a list of - names raises inside the generator and becomes a RUN_ERROR.""" - withheld = [ - Unsatisfiable( - tool="submit_request", - parameter="aoi", - wants="geojson.AreaOfInterest", - required=True, - model_generatable=False, - ) - ] - - async with _client(_built(withheld=withheld)) as client: - events = await _run(client) - - announced = [ - event for event in events if event.get("activityType") == TOOLS_WITHHELD - ] - assert [item["tool"] for item in announced[0]["content"]["tools"]] == [ - "submit_request" - ] - assert "RUN_ERROR" not in [event["type"] for event in events] - - async def test_a_turn_that_fails_is_reported_on_the_stream(): """Once the response has begun there is no status code left to send.""" async with _client(_built(agent=_agent(script=[]))) as client: @@ -251,7 +222,7 @@ async def call() -> str: args_schema={"type": "object", "properties": {}}, coroutine=call, ) - agent, _ = with_session_state( + agent = with_session_state( StreamingScriptedModel( script=[ AIMessage( @@ -336,7 +307,7 @@ async def call() -> str: args_schema={"type": "object", "properties": {}}, coroutine=call, ) - agent, _ = with_session_state( + agent = with_session_state( StreamingScriptedModel( script=[ AIMessage( @@ -399,7 +370,7 @@ async def test_a_thread_reads_back_as_messages(): roles = [message["role"] for message in thread["messages"]] assert roles[0] == "user" assert roles.count("tool") == 2 - assert thread["state"][STATE_KEY]["kind"] == "geojson.AreaOfInterest" + assert thread["state"][STATE_KEY]["tool"] == "search" async def test_a_reload_gets_the_activities_back_too(): @@ -475,7 +446,7 @@ async def test_a_tool_call_survives_the_round_trip(): for call in message.get("toolCalls") or [] ] assert [call["function"]["name"] for call in calls] == ["search", "clip"] - assert json.loads(calls[0]["function"]["arguments"]) == {} + assert json.loads(calls[0]["function"]["arguments"]) == {"q": "rainfall"} async def test_an_unknown_thread_is_not_an_empty_conversation(): @@ -497,7 +468,7 @@ async def test_a_state_value_is_served_in_full(): body = response.json() assert body["value"] == AOI - assert body["kind"] == "geojson.AreaOfInterest" + assert body["tool"] == "search" assert body["seq"] == 1 @@ -680,13 +651,13 @@ def test_the_read_routes_document_their_shapes(): "StateEntryInfo", } assert set(schemas["StateEntryInfo"]["properties"]) == { - "kind", "tool", "bytes", "seq", + "inputs", } # seq is the one that may legitimately be absent; the rest always travel. - assert set(schemas["StateEntryInfo"]["required"]) == {"kind", "tool", "bytes"} + assert set(schemas["StateEntryInfo"]["required"]) == {"tool", "bytes"} def test_the_run_route_is_documented_as_an_event_stream(): @@ -714,7 +685,7 @@ async def test_documenting_the_shapes_left_the_wire_alone(): entries = list(body["state"].values()) assert entries, "the turn published nothing, so this proves nothing" for entry in entries: - assert "kind" in entry + assert "tool" in entry if entry.get("seq") is None: assert "seq" not in entry @@ -768,3 +739,12 @@ def test_the_run_route_documents_every_event_it_can_emit(): "TextMessageContentEvent", "ActivitySnapshotEvent", } <= referenced + + +async def test_a_state_value_carries_where_its_call_got_its_arguments() -> None: + """So a client rendering a value can say what it rests on, turns later.""" + async with _client() as client: + await _run(client, threadId="prov-1") + body = (await client.get(f"/threads/prov-1/state/{STATE_KEY}")).json() + + assert body["inputs"] == {"q": "model"} diff --git a/tests/mcp_runtime/test_index.py b/tests/mcp_runtime/test_index.py index c32ce07..f3da371 100644 --- a/tests/mcp_runtime/test_index.py +++ b/tests/mcp_runtime/test_index.py @@ -93,7 +93,7 @@ async def fake_describe(client, service, public_url): "status": "ok", "tools": ["search_datasets"], "credential_headers": ["x-demo-token"], - "state": {"produces": [], "consumes": []}, + "state": {"produces": [], "not_authored": []}, } ], } diff --git a/tests/mcp_runtime/test_local.py b/tests/mcp_runtime/test_local.py index 2428570..d9f1b34 100644 --- a/tests/mcp_runtime/test_local.py +++ b/tests/mcp_runtime/test_local.py @@ -6,8 +6,7 @@ from fastapi.testclient import TestClient from langchain_core.tools import tool -from mcp_runtime.declarations import Kind -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST +from mcp_runtime.declarations import NotAuthored from mcp_runtime.local import ( LocalSettings, build_local_app, @@ -31,7 +30,7 @@ def whoami() -> ToolResult: class SearchResult(ToolResult): """A summary for the model, plus the area those datasets cover.""" - geometry: NotRequired[Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST)]] + geometry: NotRequired[dict] @tool @@ -41,7 +40,7 @@ def search_datasets(query: str) -> SearchResult: @tool -def clip_raster(aoi: Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST)]) -> ToolResult: +def clip_raster(aoi: Annotated[dict, NotAuthored()]) -> ToolResult: """Clip a raster to an area of interest.""" return ToolResult(message=f"clipped to {len(aoi)} key(s)") @@ -144,7 +143,7 @@ def test_index_lists_every_mounted_toolset(monkeypatch): "status": "ok", "tools": ["echo"], "credential_headers": [], - "state": {"produces": [], "consumes": []}, + "state": {"produces": [], "not_authored": []}, }, { "name": "beta", @@ -152,7 +151,7 @@ def test_index_lists_every_mounted_toolset(monkeypatch): "status": "ok", "tools": ["whoami"], "credential_headers": ["x-demo-token"], - "state": {"produces": [], "consumes": []}, + "state": {"produces": [], "not_authored": []}, }, ], } @@ -172,16 +171,14 @@ def test_index_carries_state_declarations(monkeypatch): entry = client.get("/").json()["toolsets"][0] assert entry["state"] == { - "produces": [GEOJSON_AREA_OF_INTEREST], - "consumes": [ + "produces": [ { - "tool": "clip_raster", - "parameter": "aoi", - "kind": GEOJSON_AREA_OF_INTEREST, - "required": True, - "modelGeneratable": True, + "tool": "search_datasets", + "field": "geometry", + "state_key": "gamma/search_datasets/geometry", } ], + "not_authored": [{"tool": "clip_raster", "parameter": "aoi"}], } diff --git a/tests/mcp_state/test_adoption.py b/tests/mcp_state/test_adoption.py index 2c09bca..44a3632 100644 --- a/tests/mcp_state/test_adoption.py +++ b/tests/mcp_state/test_adoption.py @@ -14,8 +14,7 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.tools import StructuredTool -from mcp_runtime.declarations import CONSUMES_META_KEY, PRODUCES_META_KEY -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, PRODUCES_META_KEY from mcp_state import ( AgentState, StateCaptureMiddleware, @@ -79,29 +78,21 @@ async def test_a_payload_crosses_servers_without_entering_the_transcript() -> No meta={ PRODUCES_META_KEY: [ { - "stateKey": "dataset-search/geometry", + "stateKey": "dataset-search/search/geometry", "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, } ] }, returns={"message": "found 3 datasets", "geometry": AOI}, seen=seen, ) - # "raster-ops", a different server: consumes one, naming only its kind. + # "raster-ops", a different server: takes one, and says a model may not + # write it. It names no other toolset — the model bridges the two by name. clip = mcp_tool( "clip", {"id": {"type": "string"}, "aoi": {"type": "object"}}, ["id", "aoi"], - meta={ - CONSUMES_META_KEY: [ - { - "parameter": "aoi", - "kind": GEOJSON_AREA_OF_INTEREST, - "required": True, - } - ] - }, + meta={NOT_AUTHORED_META_KEY: ["aoi"]}, seen=seen, ) # A third-party MCP server, declaring nothing. @@ -125,11 +116,18 @@ async def test_a_payload_crosses_servers_without_entering_the_transcript() -> No AIMessage( content="", tool_calls=[ - call_of("clip", {"id": "era5"}, "2"), + call_of( + "clip", + { + "id": "era5", + "aoi": "@state:dataset-search/search/geometry", + }, + "2", + ), call_of("weather", {"city": "Reading"}, "3"), call_of( "describe", - {"geometry": "@state:dataset-search/geometry"}, + {"geometry": "@state:dataset-search/search/geometry"}, "4", ), ], @@ -146,10 +144,13 @@ async def test_a_payload_crosses_servers_without_entering_the_transcript() -> No result = await agent.ainvoke({"messages": [HumanMessage("clip era5 to my aoi")]}) # Published under its qualified key, and only there. - assert list(result["tool_state"]) == ["dataset-search/geometry"] + assert list(result["tool_state"]) == ["dataset-search/search/geometry"] # The model got the message and a breadcrumb, never the payload. assert not any("coordinates" in str(m.content) for m in result["messages"]) - assert "[state updated: dataset-search/geometry" in result["messages"][2].content + assert ( + "[state updated: dataset-search/search/geometry" + in result["messages"][2].content + ) # The consuming tool on the other server got it anyway. assert seen["clip"]["aoi"] == AOI # And so did the third-party one, which declared nothing: the model named @@ -158,17 +159,15 @@ async def test_a_payload_crosses_servers_without_entering_the_transcript() -> No # The third-party tool is entirely unaffected. assert seen["weather"] == {"city": "Reading"} - # The transcript records the join: the tool that never saw the parameter - # still says which stored value it ran against, and who published it. The - # one the model pointed at a key itself is not told twice. + # The join is recorded where a host reads it, and nowhere the model pays + # for: it wrote both keys itself. results = {m.name: m for m in result["messages"] if isinstance(m, ToolMessage)} - assert ( - "[state used: aoi ← dataset-search/geometry, published by search]" - in results["clip"].content + assert "state used" not in str(results["clip"].content) + assert receipts_of(results["clip"].artifact)["aoi"]["key"] == ( + "dataset-search/search/geometry" ) - assert "state used" not in str(results["describe"].content) assert receipts_of(results["describe"].artifact)["geometry"]["key"] == ( - "dataset-search/geometry" + "dataset-search/search/geometry" ) assert receipts_of(results["weather"].artifact) == {} @@ -189,9 +188,8 @@ async def test_injection_works_without_the_middleware_but_capture_does_not() -> meta={ PRODUCES_META_KEY: [ { - "stateKey": "dataset-search/geometry", + "stateKey": "dataset-search/search/geometry", "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, } ] }, @@ -232,9 +230,8 @@ async def test_capture_needs_no_state_schema_from_the_host() -> None: meta={ PRODUCES_META_KEY: [ { - "stateKey": "dataset-search/geometry", + "stateKey": "dataset-search/search/geometry", "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, } ] }, @@ -259,9 +256,12 @@ async def test_capture_needs_no_state_schema_from_the_host() -> None: ) result = await agent.ainvoke({"messages": [HumanMessage("search")]}) - assert result["tool_state"]["dataset-search/geometry"]["value"] == AOI + assert result["tool_state"]["dataset-search/search/geometry"]["value"] == AOI # The key the breadcrumb advertises is one `inspect_state` can now read. - assert "[state updated: dataset-search/geometry" in result["messages"][2].content + assert ( + "[state updated: dataset-search/search/geometry" + in result["messages"][2].content + ) async def test_a_host_may_still_bring_its_own_state_schema() -> None: @@ -277,9 +277,8 @@ class HostState(AgentState): meta={ PRODUCES_META_KEY: [ { - "stateKey": "dataset-search/geometry", + "stateKey": "dataset-search/search/geometry", "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, } ] }, @@ -304,4 +303,4 @@ class HostState(AgentState): result = await agent.ainvoke({"messages": [HumanMessage("search")], "run_id": "r1"}) assert result["run_id"] == "r1" - assert result["tool_state"]["dataset-search/geometry"]["value"] == AOI + assert result["tool_state"]["dataset-search/search/geometry"]["value"] == AOI diff --git a/tests/mcp_state/test_capture.py b/tests/mcp_state/test_capture.py index 07fea50..624e57e 100644 --- a/tests/mcp_state/test_capture.py +++ b/tests/mcp_state/test_capture.py @@ -13,7 +13,6 @@ from langgraph.types import Command from mcp_runtime.declarations import PRODUCES_META_KEY -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST, GEOJSON_FOOTPRINT from mcp_state.inspect import read_state_key from mcp_state.middleware import ( CAPTURED_ARTIFACT_KEY, @@ -33,11 +32,7 @@ AOI = {"type": "FeatureCollection", "features": [{"id": "polygon"}]} PUBLISHES_GEOMETRY = [ - { - "stateKey": "dataset-search/geometry", - "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, - } + {"stateKey": "dataset-search/search/geometry", "field": "geometry"} ] @@ -79,16 +74,15 @@ async def handler(_request: Any) -> ToolMessage: return await middleware.awrap_tool_call(request, handler) -async def test_a_declared_key_lands_under_its_qualified_name_with_its_kind() -> None: - """The write carries everything injection later resolves on.""" +async def test_a_declared_key_lands_under_its_qualified_name() -> None: + """The write carries everything a later reader needs to make sense of it.""" middleware = StateCaptureMiddleware( publications([remote_tool("search", PUBLISHES_GEOMETRY)]) ) result = await capture(middleware, "search", {"message": "found", "geometry": AOI}) assert isinstance(result, Command) - entry = result.update[TOOL_STATE_KEY]["dataset-search/geometry"] + entry = result.update[TOOL_STATE_KEY]["dataset-search/search/geometry"] assert entry["value"] == AOI - assert entry["kind"] == GEOJSON_AREA_OF_INTEREST assert entry["tool"] == "search" @@ -101,13 +95,13 @@ async def test_the_payload_leaves_the_transcript_for_a_breadcrumb() -> None: assert isinstance(result, Command) (captured,) = result.update["messages"] assert "polygon" not in captured.content - assert "dataset-search/geometry" in captured.content + assert "dataset-search/search/geometry" in captured.content # The payload leaves the artifact as well as the content, replaced by a note # of where it went. The artifact never reached the model either way; this is # so a UI host can put the value back (test_restore_* below). assert captured.artifact["structured_content"] == {"message": "found"} assert captured.artifact[CAPTURED_ARTIFACT_KEY] == { - "geometry": "dataset-search/geometry" + "geometry": "dataset-search/search/geometry" } @@ -135,7 +129,7 @@ async def test_restore_omits_a_key_that_is_no_longer_in_state() -> None: """A bounded/pruned state must degrade to a partial view, not a KeyError.""" artifact = { "structured_content": {"message": "found"}, - CAPTURED_ARTIFACT_KEY: {"geometry": "dataset-search/geometry"}, + CAPTURED_ARTIFACT_KEY: {"geometry": "dataset-search/search/geometry"}, } assert restore_structured(artifact, {}) == {"message": "found"} @@ -188,19 +182,18 @@ async def test_a_large_undeclared_value_is_captured_on_size_alone() -> None: assert isinstance(result, Command) entry = result.update[TOOL_STATE_KEY]["terrain/coverage"] assert entry["tool"] == "terrain" - # Recognised from the value's own shape, since nothing declared a kind. - assert entry["kind"] == GEOJSON_FOOTPRINT -async def test_an_unrecognisable_value_is_captured_but_left_unlabelled() -> None: - """No label is safe; a wrong one gets injected somewhere it does not belong.""" - middleware = StateCaptureMiddleware(publications([remote_tool("terrain")])) - samples = [{"distance_m": index, "elevation_m": 40.0} for index in range(400)] +async def test_an_undeclared_capture_keyed_by_its_server_when_known() -> None: + """So the model reads one key shape whatever produced the value.""" + middleware = StateCaptureMiddleware( + publications([remote_tool("terrain")]), owners={"terrain": "terrain-ops"} + ) result = await capture( - middleware, "terrain", {"message": "sampled", "samples": samples} + middleware, "terrain", {"message": "sampled", "coverage": big_geometry()} ) assert isinstance(result, Command) - assert result.update[TOOL_STATE_KEY]["terrain/samples"]["kind"] is None + assert "terrain-ops/terrain/coverage" in result.update[TOOL_STATE_KEY] async def test_undeclared_capture_can_be_switched_off() -> None: @@ -234,7 +227,7 @@ async def test_a_secret_shaped_field_is_never_stored_however_it_is_declared() -> [ remote_tool( "auth", - [{"stateKey": "auth/api_key", "field": "api_key", "kind": None}], + [{"stateKey": "auth/login/api_key", "field": "api_key"}], ) ] ) @@ -248,15 +241,15 @@ def test_inspect_reads_through_the_envelope() -> None: """A stored value is read as its value, not as its StateEntry wrapper.""" state = { TOOL_STATE_KEY: { - "dataset-search/geometry": StateEntry( - value=AOI, kind=GEOJSON_AREA_OF_INTEREST, tool="search", seq=1 + "dataset-search/search/geometry": StateEntry( + value=AOI, tool="search", seq=1 ) } } - read = read_state_key("dataset-search/geometry", state) + read = read_state_key("dataset-search/search/geometry", state) assert "FeatureCollection" in read assert "seq" not in read - assert "kind" not in read + assert "tool" not in read def test_inspect_and_capture_agree_on_the_key() -> None: @@ -269,16 +262,16 @@ def test_inspect_and_capture_agree_on_the_key() -> None: """ published = publications([remote_tool("search", PUBLISHES_GEOMETRY)]) allowed = state_keys(published) - assert allowed == {"dataset-search/geometry"} + assert allowed == {"dataset-search/search/geometry"} state = { TOOL_STATE_KEY: { - "dataset-search/geometry": StateEntry(value=AOI, seq=1), + "dataset-search/search/geometry": StateEntry(value=AOI, seq=1), "foreign/samples": StateEntry(value=[1, 2, 3], seq=2), } } listing = read_state_key("*", state, allowed_keys=allowed) - assert "dataset-search/geometry" in listing + assert "dataset-search/search/geometry" in listing assert "foreign/samples" in listing assert "[1, 2, 3]" in read_state_key("foreign/samples", state, allowed_keys=allowed) @@ -290,10 +283,12 @@ def test_a_handle_is_read_as_the_key_inside_it() -> None: `foo`. Refusing it produces a "no such key" answer that names the key the caller asked for, which reads as the value being gone. """ - state = {TOOL_STATE_KEY: {"dataset-search/geometry": StateEntry(value=AOI, seq=1)}} + state = { + TOOL_STATE_KEY: {"dataset-search/search/geometry": StateEntry(value=AOI, seq=1)} + } - handled = read_state_key("@state:dataset-search/geometry", state) - assert handled == read_state_key("dataset-search/geometry", state) + handled = read_state_key("@state:dataset-search/search/geometry", state) + assert handled == read_state_key("dataset-search/search/geometry", state) assert "FeatureCollection" in handled @@ -310,7 +305,7 @@ def test_the_breadcrumb_scopes_the_handle_to_a_parameter() -> None: into how session state is named at all — which is how it ends up as inspect_state's argument and on plain string parameters. """ - note = _breadcrumb(["dataset-search/geometry"]) + note = _breadcrumb(["dataset-search/search/geometry"]) assert "bare key to inspect_state" in note assert "@state: only to a tool parameter" in note @@ -321,7 +316,7 @@ def test_the_breadcrumb_scopes_the_handle_to_a_parameter() -> None: def test_a_declared_key_not_yet_published_says_so() -> None: """Distinct from an unknown key: the answer is "run the producer", not "give up".""" allowed = state_keys(publications([remote_tool("search", PUBLISHES_GEOMETRY)])) - missing = read_state_key("dataset-search/geometry", {}, allowed_keys=allowed) + missing = read_state_key("dataset-search/search/geometry", {}, allowed_keys=allowed) assert "has not published it yet" in missing unknown = read_state_key("nobody/knows", {}, allowed_keys=allowed) diff --git a/tests/mcp_state/test_handles.py b/tests/mcp_state/test_handles.py index 6f981af..bc5265d 100644 --- a/tests/mcp_state/test_handles.py +++ b/tests/mcp_state/test_handles.py @@ -10,8 +10,7 @@ from langchain_core.tools import StructuredTool, ToolException from langchain_core.utils.function_calling import convert_to_openai_tool -from mcp_runtime.kinds import BBOX, GEOJSON_FOOTPRINT, STAC_ITEM_COLLECTION -from mcp_state.detect import describe, detect_kind +from mcp_state.detect import describe from mcp_state.handles import ( HANDLE_PREFIX, available, @@ -20,6 +19,7 @@ is_handle, offer_handles, unresolved, + unresolved_message, ) from mcp_state.injection import bind_injected from mcp_state.state import StateEntry @@ -90,13 +90,25 @@ def test_a_cheap_parameter_is_left_alone() -> None: assert schema["properties"]["region"] == {"type": "string"} -def test_a_declared_parameter_is_not_also_offered_as_a_handle() -> None: - """It is about to be removed from the schema; offering it would confuse.""" +def test_a_narrowed_parameter_is_not_also_offered_a_literal_arm() -> None: + """``only`` replaces the parameter's schema rather than adding to it, so + there is no arm left for the model to write a value into.""" schema = offer_handles( {"type": "object", "properties": {"aoi": {"type": "object"}}}, - skip=frozenset({"aoi"}), + only=frozenset({"aoi"}), ) - assert schema["properties"]["aoi"] == {"type": "object"} + assert "anyOf" not in schema["properties"]["aoi"] + assert schema["properties"]["aoi"]["pattern"] == f"^{HANDLE_PREFIX}" + + +def test_narrowing_wins_over_the_structured_type_test() -> None: + """A scalar gains no handle branch on its own, and is narrowed anyway when + its server said so: the declaration is intent, not an inference.""" + schema = offer_handles( + {"type": "object", "properties": {"region": {"type": "string"}}}, + only=frozenset({"region"}), + ) + assert schema["properties"]["region"]["pattern"] == f"^{HANDLE_PREFIX}" def test_an_unknown_handle_is_left_for_the_caller_to_catch() -> None: @@ -108,7 +120,7 @@ def test_an_unknown_handle_is_left_for_the_caller_to_catch() -> None: # --- handles that substitution cannot reach -------------------------------- -STORED = {"gazet/aoi": StateEntry(value=AOI, kind=GEOJSON_FOOTPRINT, tool="get_aoi")} +STORED = {"gazet/get_aoi/aoi": StateEntry(value=AOI, tool="get_aoi")} async def submit(args: dict[str, Any]) -> tuple[str, dict[str, Any]]: @@ -141,7 +153,9 @@ async def test_a_nested_handle_stops_the_call_instead_of_reaching_the_server() - back as ``undefined value : "@state:gazet" for parameter AREA`` minutes later. """ - answer, seen = await submit({"request": {"area": handle_for("gazet/aoi"), "n": 1}}) + answer, seen = await submit( + {"request": {"area": handle_for("gazet/get_aoi/aoi"), "n": 1}} + ) assert "request.area" in answer assert seen == {}, "the call must not go out" @@ -151,14 +165,14 @@ async def test_the_message_says_which_of_the_two_failures_it_is() -> None: """A key nobody published is the model's to fix by running another tool; a nested handle is one the mechanism cannot serve, so it is pointed at ``inspect_state`` to read the value and write the field itself.""" - nested, _ = await submit({"request": {"area": handle_for("gazet/aoi")}}) + nested, _ = await submit({"request": {"area": handle_for("gazet/get_aoi/aoi")}}) unknown, _ = await submit({"request": {"area": handle_for("gazet/nope")}}) assert "never inside one" in nested assert "inspect_state" in nested assert "no such key" in unknown # What is in state either way, so the model can point at something real. - assert "gazet/aoi" in nested and "gazet/aoi" in unknown + assert "gazet/get_aoi/aoi" in nested and "gazet/get_aoi/aoi" in unknown async def test_a_whole_argument_handle_still_resolves() -> None: @@ -170,7 +184,9 @@ async def test_a_whole_argument_handle_still_resolves() -> None: [bound], { "messages": [ - tool_call("describe_geometry", {"geometry": handle_for("gazet/aoi")}) + tool_call( + "describe_geometry", {"geometry": handle_for("gazet/get_aoi/aoi")} + ) ], "tool_state": dict(STORED), }, @@ -192,54 +208,54 @@ def test_unresolved_names_the_path_not_just_the_parameter() -> None: assert unresolved({"request": {"area": [-3.0, 51.0, -2.0, 52.0]}}) == [] -def test_a_literal_value_passes_through_untouched() -> None: - assert dereference({"geometry": AOI}, {}) == {"geometry": AOI} - assert is_handle(AOI) is False +def test_a_nested_handle_is_not_answered_with_write_it_yourself() -> None: + """The advice for an unresolved handle depends on whether the tool holds a + parameter a model must not write. + Observed live: a model put a handle inside an opaque ``request`` dict, was + refused, read the closing line, fetched the value with ``inspect_state`` + and wrote it in — carrying it around the very constraint the tool declared. + """ + state: dict[str, StateEntry] = { + "gazet/get_aoi/bbox": StateEntry( + value=[-3.0, 51.0, -2.0, 52.0], tool="get_aoi", seq=1 + ) + } + found = [("request.area", "gazet/get_aoi/bbox")] -def test_available_lists_what_a_model_could_point_at() -> None: - listed = available( - { - "a/geometry": StateEntry( - value=AOI, kind=GEOJSON_FOOTPRINT, tool="search", seq=1 - ) - } - ) - assert listed == [ - f"{handle_for('a/geometry')} — {GEOJSON_FOOTPRINT}, " - "1 feature(s), 0 vertices, from search" - ] + open_tool = unresolved_message("submit_request", found, state) + assert "write the field yourself" in open_tool + narrowed = unresolved_message("submit_request", found, state, frozenset({"area"})) + assert "write the field yourself" not in narrowed + assert "'area'" in narrowed + assert "Do not read the value and write it in" in narrowed -# --- recognising a value by its own shape --------------------------------- + # Both still list what is actually stored, which is the part the model + # needs either way. + assert "@state:gazet/get_aoi/bbox" in narrowed -def test_geojson_announces_itself() -> None: - assert detect_kind({"type": "FeatureCollection", "features": []}) == ( - GEOJSON_FOOTPRINT - ) +def test_an_empty_state_says_so_however_the_tool_is_declared() -> None: + """Nothing to point at, so neither closing applies.""" + found = [("request.area", "gazet/get_aoi/bbox")] + for declared in (frozenset(), frozenset({"area"})): + message = unresolved_message("submit_request", found, {}, declared) + assert "Nothing has been published to session state yet." in message -def test_stac_is_distinguished_from_plain_geojson() -> None: - assert ( - detect_kind( - {"type": "FeatureCollection", "stac_version": "1.0.0", "features": []} - ) - == STAC_ITEM_COLLECTION - ) +def test_a_literal_value_passes_through_untouched() -> None: + assert dereference({"geometry": AOI}, {}) == {"geometry": AOI} + assert is_handle(AOI) is False -def test_a_bounding_box_is_four_or_six_numbers() -> None: - assert detect_kind([-3.0, 51.0, -2.0, 52.0]) == BBOX - assert detect_kind([-3.0, 51.0, -2.0]) is None - # `bool` is an `int` in Python; a list of flags is not a bounding box. - assert detect_kind([True, False, True, False]) is None +def test_available_lists_what_a_model_could_point_at() -> None: + key = "dataset-search/search/area_of_interest" + listed = available({key: StateEntry(value=AOI, tool="search", seq=1)}) + assert listed == [f"{handle_for(key)} — 1 feature(s), 0 vertices, from search"] -def test_an_unrecognised_shape_stays_unlabelled() -> None: - """No label is safe; a wrong one gets injected somewhere it does not belong.""" - assert detect_kind([{"distance_m": 0, "elevation_m": 40}]) is None - assert detect_kind({"datasets": ["a", "b"]}) is None +# --- recognising a value by its own shape --------------------------------- def test_describe_summarises_without_revealing() -> None: diff --git a/tests/mcp_state/test_injection.py b/tests/mcp_state/test_injection.py index 8903f0b..6412d00 100644 --- a/tests/mcp_state/test_injection.py +++ b/tests/mcp_state/test_injection.py @@ -1,56 +1,35 @@ -"""Declared parameters: hidden from the model, filled from session state. +"""Binding a tool: handles resolved, refusals delivered, everything else left. -The end-to-end case is the interesting one, so it is the first test: a tool -in one toolset publishes a value, a tool in a *different* toolset on a -*different* server takes it, and the two are matched only by kind. +The kind-matching path is gone. What binding does now is rewrite a schema so a +model can name a stored value, substitute what it names on the way out, and +refuse — as a *result*, never an exception — the calls it cannot serve. + +Narrowing (``NotAuthored``) has its own module, ``test_not_authored.py``. """ -from typing import Annotated, Any, NotRequired +from typing import Any import pytest from langchain_core.messages import AIMessage -from langchain_core.tools import StructuredTool, ToolException, tool -from langchain_core.utils.function_calling import convert_to_openai_tool +from langchain_core.tools import StructuredTool, ToolException from langgraph.graph import END, START, StateGraph from langgraph.prebuilt.tool_node import ToolNode -from mcp_runtime.declarations import ( - CONSUMES_META_KEY, - PRODUCES_META_KEY, - Kind, - consumed_kinds, - output_kinds, - qualified, - state_declarations, - with_state_meta, -) -from mcp_runtime.fastmcp_output import to_fastmcp -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST, GEOJSON_FOOTPRINT -from mcp_runtime.tool_result import ToolError, ToolResult -from mcp_state.injection import StateRefusal, bind_injected, resolve +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY +from mcp_state.handles import handle_for +from mcp_state.injection import StateRefusal, bind_all_injected, bind_injected from mcp_state.state import AgentState, StateEntry, merge_tool_state AOI = {"type": "FeatureCollection", "features": [{"id": "big-polygon"}]} GEOJSON_SCHEMA = {"type": "object", "properties": {"type": {"type": "string"}}} -def declaration(**overrides: Any) -> dict[str, Any]: - """One consumed-kind declaration as a server would put it on the wire.""" - return { - "parameter": "aoi", - "kind": GEOJSON_AREA_OF_INTEREST, - "required": True, - "modelGeneratable": False, - **overrides, - } - - def remote_tool( name: str, *, properties: dict[str, Any], required: list[str], - consumes: list[dict[str, Any]] | None = None, + meta: dict[str, Any] | None = None, seen: dict[str, Any] | None = None, ) -> StructuredTool: """A stand-in for a tool loaded from an MCP server by the adapter. @@ -75,7 +54,7 @@ async def call(runtime: Any = None, **arguments: Any) -> Any: }, coroutine=call, response_format="content_and_artifact", - metadata={"_meta": {CONSUMES_META_KEY: consumes}} if consumes else None, + metadata={"_meta": meta} if meta else None, ) @@ -95,133 +74,52 @@ def tool_call(name: str, args: dict[str, Any]) -> AIMessage: ) -async def test_value_crosses_toolsets_and_servers_matched_only_by_kind() -> None: - """A publisher in one toolset fills a consumer in another, via kind alone.""" +async def test_a_handle_reaches_the_tool_as_the_value_it_named() -> None: + """The whole mechanism, on a server that declares nothing at all.""" seen: dict[str, Any] = {} - # Served by "raster-ops"; knows nothing about who produces an AOI. clip = remote_tool( "clip_raster", properties={"dataset_id": {"type": "string"}, "aoi": GEOJSON_SCHEMA}, required=["dataset_id", "aoi"], - consumes=[declaration()], seen=seen, ) - bound = bind_injected(clip) - - # The model is never offered `aoi` at all. - parameters = convert_to_openai_tool(bound)["function"]["parameters"] - assert "aoi" not in parameters["properties"] - assert parameters["required"] == ["dataset_id"] + key = "dataset-search/search_datasets/area_of_interest" - # Published earlier by "dataset-search", on a different server. await run_tools( - [bound], + [bind_injected(clip)], { - "messages": [tool_call("clip_raster", {"dataset_id": "era5"})], - "tool_state": { - qualified("dataset-search", "geometry"): StateEntry( - value=AOI, kind=GEOJSON_AREA_OF_INTEREST, tool="search", seq=1 - ) - }, + "messages": [ + tool_call("clip_raster", {"dataset_id": "era5", "aoi": handle_for(key)}) + ], + "tool_state": {key: StateEntry(value=AOI, tool="search_datasets", seq=1)}, }, ) - assert seen == {"dataset_id": "era5", "aoi": AOI} - - -async def test_a_wrong_kind_is_not_injected() -> None: - """A footprint never satisfies a parameter asking for an area of interest.""" - assert ( - resolve( - declaration(), - {"x/f": StateEntry(value=AOI, kind=GEOJSON_FOOTPRINT, seq=1)}, - None, - ) - is None - ) - - -async def test_the_most_recent_entry_of_a_kind_wins() -> None: - """Two AOIs in state: the one published last is the one in play. - - The key comes back with it, because which of the two was chosen is exactly - what a receipt has to record. - """ - older = {"type": "FeatureCollection", "features": ["older"]} - found = resolve( - declaration(), - { - "a/geometry": StateEntry(value=older, kind=GEOJSON_AREA_OF_INTEREST, seq=1), - "b/geometry": StateEntry(value=AOI, kind=GEOJSON_AREA_OF_INTEREST, seq=7), - }, - None, - ) - assert found is not None - key, entry = found - assert (key, entry["value"]) == ("b/geometry", AOI) - - -async def test_a_value_failing_the_parameter_schema_is_skipped() -> None: - """Same kind, wrong dialect: passed over rather than sent to the server.""" - assert ( - resolve( - declaration(), - { - "a/geometry": StateEntry( - value="not-an-object", kind=GEOJSON_AREA_OF_INTEREST - ) - }, - {"type": "object"}, - ) - is None - ) - - -async def refused(bound: Any, arguments: dict[str, Any] | None = None) -> Any: - """One call that the binding turns away, as the model receives it. - - A refusal reaches the model as the tool's *result* rather than as a raised - exception — see :class:`~mcp_state.injection.StateRefusal`. Every assertion - below is on the text, which is unchanged; only the delivery is. - """ - message = await bound.ainvoke( - {"args": arguments or {}, "id": "1", "type": "tool_call"} - ) - assert message.status == "error", "a refusal must not read as a success" - return message + assert seen == {"dataset_id": "era5", "aoi": AOI} -async def test_a_missing_required_value_names_the_tool_that_would_supply_it() -> None: - """The model cannot supply it, so the error has to name the way forward. - The kind string alone is not a way forward: it names what is wanted, not - who produces it, and the producer is usually on another server entirely. - """ +async def test_a_literal_is_passed_through_untouched() -> None: + """An ordinary parameter still takes an ordinary value.""" + seen: dict[str, Any] = {} clip = remote_tool( "clip_raster", properties={"aoi": GEOJSON_SCHEMA}, required=["aoi"], - consumes=[declaration()], + seen=seen, ) - bound = bind_injected(clip, published={GEOJSON_AREA_OF_INTEREST: ["search"]}) - assert "Run search first" in (await refused(bound)).content + await run_tools( + [bind_injected(clip)], + {"messages": [tool_call("clip_raster", {"aoi": AOI})], "tool_state": {}}, + ) -async def test_a_missing_value_nothing_publishes_says_so_rather_than_guessing() -> None: - """A wiring fault, not a recoverable turn: there is no tool to send it to. + assert seen == {"aoi": AOI} - Distinct from being given no map at all, which is not the same claim — that - one keeps the generic advice rather than asserting a negative it cannot see. - """ - clip = remote_tool( - "clip_raster", - properties={"aoi": GEOJSON_SCHEMA}, - required=["aoi"], - consumes=[declaration()], - ) - nothing = await refused(bind_injected(clip, published={})) - assert "No connected tool publishes it" in nothing.content - unmapped = await refused(bind_injected(clip)) - assert "If a tool produces it" in unmapped.content + +async def test_a_tool_with_nothing_structured_is_untouched() -> None: + """Nothing worth a handle: return it as it came, by identity.""" + plain = remote_tool("search", properties={"q": {"type": "string"}}, required=["q"]) + assert bind_injected(plain) is plain async def test_binding_keeps_the_wrapped_tools_own_response_format() -> None: @@ -247,167 +145,38 @@ async def call(runtime: Any = None, **arguments: Any) -> Any: response_format="content", ) bound = bind_injected(local) - assert bound is not local # wrapped: `payload` gains a handle branch - message = await bound.ainvoke( + assert bound.response_format == "content" + result = await run_tools( + [bound], { - "name": "summarise", - "args": {"payload": {"a": 1}}, - "id": "1", - "type": "tool_call", - } + "messages": [tool_call("summarise", {"payload": {"a": 1, "b": 2}})], + "tool_state": {}, + }, ) - assert message.content == "got 1 key(s)" + assert result["messages"][-1].content == "got 2 key(s)" -async def test_an_explicitly_passed_value_is_never_overridden() -> None: - """Injection fills a gap; it does not take the call away from the caller.""" +async def test_a_handle_naming_nothing_is_refused_with_the_options() -> None: seen: dict[str, Any] = {} clip = remote_tool( - "clip_raster", - properties={"aoi": GEOJSON_SCHEMA}, - required=["aoi"], - consumes=[declaration()], - seen=seen, - ) - explicit = {"type": "FeatureCollection", "features": ["explicit"]} - await bind_injected(clip).ainvoke( - {"args": {"aoi": explicit}, "id": "1", "type": "tool_call"} - ) - assert seen["aoi"] == explicit - - -async def test_a_tool_with_nothing_structured_and_nothing_declared_is_untouched() -> ( - None -): - """No declaration and no parameter worth a handle: return it as it came.""" - plain = remote_tool("search", properties={"q": {"type": "string"}}, required=["q"]) - assert bind_injected(plain) is plain - - -async def test_a_model_generatable_parameter_stays_visible_when_unpublished() -> None: - """Nothing publishes the kind, so the model fills it as any MCP client would.""" - clip = remote_tool( - "clip_raster", - properties={"bbox": {"type": "array"}}, - required=["bbox"], - consumes=[declaration(parameter="bbox", modelGeneratable=True)], - ) - bound = bind_injected(clip, published={}) - parameters = convert_to_openai_tool(bound)["function"]["parameters"] - assert "bbox" in parameters["properties"] - - -async def test_a_non_generatable_parameter_stays_hidden_when_unpublished() -> None: - """Hiding it is the point: the tool is dead, and wiring reports it as such.""" - clip = remote_tool( - "clip_raster", - properties={"aoi": GEOJSON_SCHEMA}, - required=["aoi"], - consumes=[declaration()], + "clip_raster", properties={"aoi": GEOJSON_SCHEMA}, required=["aoi"], seen=seen ) - bound = bind_injected(clip, published={}) - parameters = convert_to_openai_tool(bound)["function"]["parameters"] - assert "aoi" not in parameters["properties"] - - -def test_merge_stamps_write_order_so_recency_is_knowable() -> None: - first = merge_tool_state({}, {"a/x": StateEntry(value=1)}) - second = merge_tool_state(first, {"b/y": StateEntry(value=2)}) - assert second["b/y"]["seq"] > second["a/x"]["seq"] - - -# --- the server-side declaration ----------------------------------------- - - -class SearchResult(ToolResult): - geometry: NotRequired[Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST)]] - - -@tool -async def search(query: str) -> SearchResult | ToolError: - """Search.""" - return SearchResult(message="found", geometry=AOI) - - -@tool -async def clip( - dataset_id: str, - aoi: Annotated[dict, Kind(GEOJSON_AREA_OF_INTEREST, model_generatable=False)], -) -> ToolResult | ToolError: - """Clip.""" - return ToolResult(message="clipped") - - -def test_one_marker_reads_off_both_sides_of_a_signature() -> None: - """The same tag means "takes" on a parameter and "publishes" on a field.""" - assert consumed_kinds(clip)["aoi"].kind == GEOJSON_AREA_OF_INTEREST - assert output_kinds(search) == {"geometry": GEOJSON_AREA_OF_INTEREST} - + key = "dataset-search/search_datasets/area_of_interest" -def test_meta_carries_both_halves_to_the_client() -> None: - stamped = with_state_meta( - "raster-ops", [search, clip], [to_fastmcp(t) for t in (search, clip)] - ) - by_name = {t.name: t for t in stamped} - assert by_name["clip"].meta[CONSUMES_META_KEY] == [ - { - "parameter": "aoi", - "kind": GEOJSON_AREA_OF_INTEREST, - "required": True, - "modelGeneratable": False, - } - ] - assert by_name["search"].meta[PRODUCES_META_KEY] == [ - { - "stateKey": "raster-ops/geometry", - "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, - } - ] - - -def test_required_is_read_from_the_tools_own_schema() -> None: - """A Python default is what makes a parameter optional; nothing else says so.""" - - @tool - async def defaulted( - dataset_id: str, - aoi: Annotated[dict | None, Kind(GEOJSON_AREA_OF_INTEREST)] = None, - ) -> ToolResult: - """Clip.""" - return ToolResult(message="x") - - stamped = with_state_meta("t", [defaulted], [to_fastmcp(defaulted)]) - assert stamped[0].meta[CONSUMES_META_KEY][0]["required"] is False - - -def test_health_advertises_both_halves_to_a_plain_http_client() -> None: - """What the index aggregates, without speaking MCP.""" - declared = state_declarations([search, clip]) - assert declared["produces"] == [GEOJSON_AREA_OF_INTEREST] - assert declared["consumes"] == [ + result = await run_tools( + [bind_injected(clip)], { - "tool": "clip", - "parameter": "aoi", - "kind": GEOJSON_AREA_OF_INTEREST, - "required": True, - "modelGeneratable": False, - } - ] - - -def test_a_tag_on_a_nonexistent_parameter_fails_the_build() -> None: - @tool - async def broken( - x: str, ghost: Annotated[dict, Kind(GEOJSON_FOOTPRINT)] - ) -> ToolResult: - """Broken.""" - return ToolResult(message="x") + "messages": [tool_call("clip_raster", {"aoi": handle_for("nope")})], + "tool_state": {key: StateEntry(value=AOI, tool="search_datasets", seq=1)}, + }, + ) - broken.args.pop("ghost") - with pytest.raises(RuntimeError, match="not one of its parameters"): - with_state_meta("t", [broken], [to_fastmcp(broken)]) + message = result["messages"][-1] + assert message.status == "error" + assert "no such key" in message.text + assert key in message.text + assert seen == {} async def test_a_refusal_leaves_the_transcript_answerable() -> None: @@ -428,18 +197,18 @@ async def test_a_refusal_leaves_the_transcript_answerable() -> None: "clip_raster", properties={"aoi": GEOJSON_SCHEMA}, required=["aoi"], - consumes=[declaration()], + meta={NOT_AUTHORED_META_KEY: ["aoi"]}, ) - bound = bind_injected(clip, published={GEOJSON_AREA_OF_INTEREST: ["search"]}) result = await run_tools( - [bound], {"messages": [tool_call("clip_raster", {})], "tool_state": {}} + [bind_injected(clip)], + {"messages": [tool_call("clip_raster", {})], "tool_state": {}}, ) + # Every call the assistant message made has an answer, which is the # property a provider checks and the raised version broke. answered = [message for message in result["messages"] if message.type == "tool"] assert [message.tool_call_id for message in answered] == ["1"] assert answered[0].status == "error" - assert "Run search first" in answered[0].content async def test_the_wrapped_tools_own_errors_are_left_alone() -> None: @@ -462,11 +231,9 @@ async def explode(**_: Any) -> Any: "required": ["aoi"], }, coroutine=explode, - metadata={"_meta": {CONSUMES_META_KEY: [declaration()]}}, ) - bound = bind_injected(exploding, published={GEOJSON_AREA_OF_INTEREST: ["search"]}) with pytest.raises(ToolException, match="the server said no"): - await bound.ainvoke( + await bind_injected(exploding).ainvoke( { "args": {"aoi": AOI}, "id": "1", @@ -488,15 +255,31 @@ async def explode(**_: Any) -> Any: args_schema={"type": "object", "properties": {"aoi": GEOJSON_SCHEMA}}, coroutine=explode, handle_tool_error="ask again later", - metadata={"_meta": {CONSUMES_META_KEY: [declaration()]}}, ) - bound = bind_injected(exploding, published={GEOJSON_AREA_OF_INTEREST: ["search"]}) - message = await bound.ainvoke( + message = await bind_injected(exploding).ainvoke( {"args": {"aoi": AOI}, "id": "1", "type": "tool_call"} ) + assert message.content == "ask again later" def test_a_refusal_is_its_own_exception_type() -> None: """So a host can tell "the binding said no" from "the tool failed".""" assert issubclass(StateRefusal, ToolException) + + +def test_bind_all_injected_maps_over_every_tool() -> None: + tools = [ + remote_tool("a", properties={"q": {"type": "string"}}, required=["q"]), + remote_tool("b", properties={"payload": {"type": "object"}}, required=[]), + ] + bound = bind_all_injected(tools) + + assert bound[0] is tools[0] # nothing structured, nothing to do + assert "anyOf" in bound[1].args_schema["properties"]["payload"] + + +def test_merge_stamps_write_order_so_recency_is_knowable() -> None: + first = merge_tool_state({}, {"t/a/x": StateEntry(value=1)}) + second = merge_tool_state(first, {"t/b/y": StateEntry(value=2)}) + assert second["t/b/y"]["seq"] > second["t/a/x"]["seq"] diff --git a/tests/mcp_state/test_not_authored.py b/tests/mcp_state/test_not_authored.py new file mode 100644 index 0000000..a513bf0 --- /dev/null +++ b/tests/mcp_state/test_not_authored.py @@ -0,0 +1,362 @@ +"""Parameters a model may not write: narrowed to a handle, and nothing else. + +``NotAuthored`` says one thing about one parameter — *the caller must supply a +value that already exists*. It names no type, so unlike ``Kind`` there is no +second toolset that has to agree with anything. What the client does with it is +narrow the parameter's schema until a literal will not fit. +""" + +from typing import Annotated, Any + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.tools import StructuredTool, tool +from langgraph.graph import END, START, StateGraph +from langgraph.prebuilt.tool_node import ToolNode + +from mcp_runtime.declarations import ( + NOT_AUTHORED_META_KEY, + NOT_AUTHORED_NOTE, + NotAuthored, + not_authored, + with_state_meta, +) +from mcp_runtime.fastmcp_output import to_fastmcp +from mcp_runtime.tool_result import ToolResult +from mcp_state.handles import HANDLE_PREFIX, handle_for +from mcp_state.injection import StateRefusal, bind_injected, not_authored_for +from mcp_state.state import AgentState, StateEntry + +AOI = {"type": "FeatureCollection", "features": [{"id": "big-polygon"}]} +GEOJSON_SCHEMA = {"type": "object", "properties": {"type": {"type": "string"}}} + + +def remote_tool( + name: str = "clip_raster", + *, + properties: dict[str, Any] | None = None, + required: list[str] | None = None, + meta: dict[str, Any] | None = None, + seen: dict[str, Any] | None = None, + defs: dict[str, Any] | None = None, +) -> StructuredTool: + """A stand-in for a tool loaded from an MCP server by the adapter.""" + + async def call(runtime: Any = None, **arguments: Any) -> Any: + if seen is not None: + seen.update(arguments) + return "called", None + + schema: dict[str, Any] = { + "type": "object", + "properties": properties + if properties is not None + else {"dataset_id": {"type": "string"}, "aoi": GEOJSON_SCHEMA}, + "required": required if required is not None else ["dataset_id", "aoi"], + } + if defs is not None: + schema["$defs"] = defs + return StructuredTool( + name=name, + description=name, + args_schema=schema, + coroutine=call, + response_format="content_and_artifact", + metadata={"_meta": meta} if meta else None, + ) + + +def narrowed(*parameters: str) -> dict[str, Any]: + return {NOT_AUTHORED_META_KEY: list(parameters)} + + +async def run_tools(tools: list, state: dict[str, Any]) -> dict[str, Any]: + graph = StateGraph(AgentState) + graph.add_node("tools", ToolNode(tools)) + graph.add_edge(START, "tools") + graph.add_edge("tools", END) + return await graph.compile().ainvoke(state) + + +def tool_call(name: str, args: dict[str, Any]) -> AIMessage: + return AIMessage( + content="", + tool_calls=[{"name": name, "args": args, "id": "1", "type": "tool_call"}], + ) + + +def stored(**entries: Any) -> dict[str, StateEntry]: + return { + key: StateEntry(value=value, tool="search_datasets", seq=index) + for index, (key, value) in enumerate(entries.items(), start=1) + } + + +# --- the server side ------------------------------------------------------ + + +def test_marker_is_read_off_the_signature() -> None: + @tool + async def clip_raster( + dataset_id: str, aoi: Annotated[dict, NotAuthored()] + ) -> ToolResult: + """Clip a dataset.""" + return ToolResult(message="clipped") + + assert not_authored(clip_raster) == ["aoi"] + + +def test_stamped_into_meta_and_onto_the_description() -> None: + """Both, deliberately: ``_meta`` for us, the description for everyone else.""" + + @tool + async def clip_raster( + dataset_id: str, aoi: Annotated[dict, NotAuthored()] + ) -> ToolResult: + """Clip a dataset.""" + return ToolResult(message="clipped") + + served = with_state_meta("raster-ops", [clip_raster], [to_fastmcp(clip_raster)])[0] + + assert served.meta[NOT_AUTHORED_META_KEY] == ["aoi"] + assert NOT_AUTHORED_NOTE in served.parameters["properties"]["aoi"]["description"] + # Untagged parameters are left exactly as they were. + assert NOT_AUTHORED_NOTE not in str(served.parameters["properties"]["dataset_id"]) + + +def test_tagging_a_non_parameter_fails_the_build() -> None: + """A typo is caught at ``build_server``, not at connect.""" + + async def clip_raster(dataset_id: str, aoi: dict) -> ToolResult: + """Clip a dataset.""" + return ToolResult(message="clipped") + + clip_raster.__annotations__["ghost"] = Annotated[dict, NotAuthored()] + built = StructuredTool.from_function(coroutine=clip_raster, name="clip_raster") + + with pytest.raises(RuntimeError, match="ghost"): + with_state_meta("raster-ops", [built], []) + + +def test_read_back_from_meta_by_the_client() -> None: + tool_with = remote_tool(meta=narrowed("aoi")) + assert not_authored_for(tool_with) == frozenset({"aoi"}) + assert not_authored_for(remote_tool()) == frozenset() + + +# --- the schema the model sees -------------------------------------------- + + +def test_schema_accepts_a_handle_and_nothing_else() -> None: + bound = bind_injected(remote_tool(meta=narrowed("aoi"))) + aoi = bound.args_schema["properties"]["aoi"] + + assert aoi["type"] == "string" + assert aoi["pattern"] == f"^{HANDLE_PREFIX}" + assert "anyOf" not in aoi + # Still required — narrowing changes what fits, not whether it is needed. + assert "aoi" in bound.args_schema["required"] + + +def test_other_parameters_keep_their_literal_arm() -> None: + bound = bind_injected( + remote_tool( + properties={"aoi": GEOJSON_SCHEMA, "footprint": GEOJSON_SCHEMA}, + required=["aoi"], + meta=narrowed("aoi"), + ) + ) + + assert "anyOf" in bound.args_schema["properties"]["footprint"] + assert "anyOf" not in bound.args_schema["properties"]["aoi"] + + +def test_the_parameter_description_survives_narrowing() -> None: + """It is the sentence saying *why*, and the only prose a model reads.""" + bound = bind_injected( + remote_tool( + properties={"aoi": {**GEOJSON_SCHEMA, "description": "The area."}}, + required=["aoi"], + meta=narrowed("aoi"), + ) + ) + + assert bound.args_schema["properties"]["aoi"]["description"].startswith("The area.") + + +def test_orphaned_definitions_are_dropped() -> None: + """Narrowing removes the only ``$ref``; the definition must go with it.""" + bound = bind_injected( + remote_tool( + properties={"aoi": {"$ref": "#/$defs/FeatureCollection"}}, + required=["aoi"], + defs={ + "FeatureCollection": { + "type": "object", + "properties": {"features": {"$ref": "#/$defs/Feature"}}, + }, + "Feature": {"type": "object"}, + }, + meta=narrowed("aoi"), + ) + ) + + assert "$defs" not in bound.args_schema + + +def test_definitions_still_referenced_are_kept() -> None: + bound = bind_injected( + remote_tool( + properties={ + "aoi": {"$ref": "#/$defs/FeatureCollection"}, + "footprint": {"$ref": "#/$defs/FeatureCollection"}, + }, + required=["aoi"], + defs={"FeatureCollection": {"type": "object"}}, + meta=narrowed("aoi"), + ) + ) + + assert "FeatureCollection" in bound.args_schema["$defs"] + + +# --- what happens at call time -------------------------------------------- + + +async def test_a_handle_is_substituted_and_the_tool_sees_the_value() -> None: + seen: dict[str, Any] = {} + bound = bind_injected(remote_tool(meta=narrowed("aoi"), seen=seen)) + + await run_tools( + [bound], + { + "messages": [ + tool_call( + "clip_raster", + { + "dataset_id": "era5", + "aoi": handle_for("dataset-search/geometry"), + }, + ) + ], + "tool_state": stored(**{"dataset-search/geometry": AOI}), + }, + ) + + assert seen["aoi"] == AOI + + +async def test_a_written_value_is_refused_and_the_options_listed() -> None: + """The schema should have stopped this; the check is what makes it true.""" + seen: dict[str, Any] = {} + bound = bind_injected(remote_tool(meta=narrowed("aoi"), seen=seen)) + + result = await run_tools( + [bound], + { + "messages": [tool_call("clip_raster", {"dataset_id": "era5", "aoi": AOI})], + "tool_state": stored(**{"dataset-search/geometry": AOI}), + }, + ) + + message = result["messages"][-1] + assert message.status == "error" + assert "a value you wrote" in message.text + assert "dataset-search/geometry" in message.text + assert seen == {} + + +async def test_a_required_narrowed_parameter_left_out_is_refused() -> None: + seen: dict[str, Any] = {} + bound = bind_injected(remote_tool(meta=narrowed("aoi"), seen=seen)) + + result = await run_tools( + [bound], + { + "messages": [tool_call("clip_raster", {"dataset_id": "era5"})], + "tool_state": {}, + }, + ) + + message = result["messages"][-1] + assert message.status == "error" + assert "Nothing has been published" in message.text + assert seen == {} + + +async def test_an_optional_narrowed_parameter_left_out_is_fine() -> None: + seen: dict[str, Any] = {} + bound = bind_injected( + remote_tool( + properties={"dataset_id": {"type": "string"}, "aoi": GEOJSON_SCHEMA}, + required=["dataset_id"], + meta=narrowed("aoi"), + seen=seen, + ) + ) + + await run_tools( + [bound], + { + "messages": [tool_call("clip_raster", {"dataset_id": "era5"})], + "tool_state": {}, + }, + ) + + assert seen == {"dataset_id": "era5"} + + +async def test_a_handle_naming_nothing_is_refused_by_the_existing_check() -> None: + seen: dict[str, Any] = {} + bound = bind_injected(remote_tool(meta=narrowed("aoi"), seen=seen)) + + result = await run_tools( + [bound], + { + "messages": [ + tool_call( + "clip_raster", + {"dataset_id": "era5", "aoi": handle_for("nope")}, + ) + ], + "tool_state": stored(**{"dataset-search/geometry": AOI}), + }, + ) + + assert result["messages"][-1].status == "error" + assert seen == {} + + +def test_refusals_are_state_refusals() -> None: + """So a host can tell "this call was blocked" from "the tool failed".""" + assert issubclass(StateRefusal, Exception) + + +def test_a_tool_with_only_a_narrowed_parameter_is_still_wrapped() -> None: + """No Kind declarations anywhere: the narrowing alone has to trigger it.""" + plain = remote_tool( + properties={"aoi": {"type": "string"}}, + required=["aoi"], + meta=narrowed("aoi"), + ) + + assert bind_injected(plain).args_schema is not plain.args_schema + + +def test_the_health_payload_reports_narrowed_parameters() -> None: + """The index needs it: it names what a deployment cannot satisfy alone.""" + from mcp_runtime.declarations import state_declarations + from mcp_runtime.index import StateDeclarations + + @tool + async def clip_raster( + dataset_id: str, aoi: Annotated[dict, NotAuthored()] + ) -> ToolResult: + """Clip a dataset.""" + return ToolResult(message="clipped") + + declared = state_declarations("raster-ops", [clip_raster]) + assert declared["not_authored"] == [{"tool": "clip_raster", "parameter": "aoi"}] + # And it survives the model the index actually serves, rather than being + # dropped as an unknown key. + assert StateDeclarations(**declared).not_authored[0]["parameter"] == "aoi" diff --git a/tests/mcp_state/test_provenance.py b/tests/mcp_state/test_provenance.py new file mode 100644 index 0000000..74990c2 --- /dev/null +++ b/tests/mcp_state/test_provenance.py @@ -0,0 +1,415 @@ +"""What a call was given, recorded on every value it produced. + +A tool owns its outputs, and nothing here second-guesses them. What the client +knows for certain is where each *argument* came from — a handle names a stored +value, anything else the model wrote — and that is what a state entry carries. + +Recorded, never enforced: no call is refused on the strength of it. It exists +so a value the model authored is *visible* where it is later reused, which is +the one place a transcript cannot help because the call that made it has +scrolled away. +""" + +from typing import Any + +from langchain.agents.middleware.types import ToolCallRequest +from langchain_core.messages import ToolMessage +from langchain_core.tools import StructuredTool +from langgraph.types import Command + +from mcp_runtime.declarations import PRODUCES_META_KEY +from mcp_state.handles import available, handle_for +from mcp_state.middleware import ( + DEFAULT_CAPTURE_BYTES, + StateCaptureMiddleware, + call_inputs, + publications, +) +from mcp_state.state import MODEL_AUTHORED, TOOL_STATE_KEY, StateEntry, authored + +AOI_KEY = "dataset-search/search/area_of_interest" +AOI = {"type": "FeatureCollection", "features": [{"id": "poly"}]} + + +def big(chars: int = DEFAULT_CAPTURE_BYTES + 100) -> str: + return "x" * chars + + +def remote_tool(name: str, produces: list[dict[str, Any]] | None = None) -> Any: + """A stand-in for a tool the adapter loaded from an MCP server.""" + + async def call(**arguments: Any) -> Any: + return "called", None + + return StructuredTool( + name=name, + description=name, + args_schema={"type": "object", "properties": {}}, + coroutine=call, + metadata={"_meta": {PRODUCES_META_KEY: produces}} if produces else None, + ) + + +async def capture( + middleware: StateCaptureMiddleware, + tool_name: str, + payload: dict[str, Any], + arguments: dict[str, Any] | None = None, +) -> ToolMessage | Command[Any]: + """Run one tool return through the middleware, as one call's answer.""" + message = ToolMessage( + content="raw", + name=tool_name, + tool_call_id="1", + artifact={"structured_content": payload}, + ) + + async def handler(_request: Any) -> ToolMessage: + return message + + request = ToolCallRequest( + tool_call={ + "name": tool_name, + "args": arguments or {}, + "id": "1", + "type": "tool_call", + }, + tool=None, + state={}, + runtime=None, # type: ignore[arg-type] + ) + return await middleware.awrap_tool_call(request, handler) + + +# --- reading one call's arguments ---------------------------------------- + + +def test_a_handle_resolves_to_the_key_it_named() -> None: + assert call_inputs({"aoi": handle_for(AOI_KEY)}) == {"aoi": AOI_KEY} + + +def test_anything_else_is_the_models_own() -> None: + assert call_inputs({"dataset_id": "chirps", "n": 3, "box": [1, 2]}) == { + "dataset_id": MODEL_AUTHORED, + "n": MODEL_AUTHORED, + "box": MODEL_AUTHORED, + } + + +def test_a_call_with_no_arguments_records_nothing() -> None: + """Absent is not the same claim as empty, so it stays absent.""" + assert call_inputs({}) == {} + + +def test_nothing_but_names_is_stored() -> None: + """The record must stay cheap however large the call was.""" + written = call_inputs({"payload": {"huge": big()}}) + assert written == {"payload": MODEL_AUTHORED} + assert big() not in str(written) + + +# --- what capture writes -------------------------------------------------- + + +PUBLISHES = [ + {"stateKey": "raster-ops/clip/geometry", "field": "geometry"}, + {"stateKey": "raster-ops/clip/dataset", "field": "dataset"}, +] + + +async def test_an_entry_records_what_its_producing_call_was_given() -> None: + middleware = StateCaptureMiddleware(publications([remote_tool("clip", PUBLISHES)])) + + result = await capture( + middleware, + "clip", + {"message": "clipped", "geometry": AOI}, + {"aoi": handle_for(AOI_KEY), "dataset_id": "chirps"}, + ) + + assert isinstance(result, Command) + entry = result.update[TOOL_STATE_KEY]["raster-ops/clip/geometry"] + assert entry["inputs"] == {"aoi": AOI_KEY, "dataset_id": MODEL_AUTHORED} + + +async def test_every_field_of_one_return_carries_the_same_record() -> None: + """It describes the *call*. Which output derives from which input is the + tool's business, and the tool does not say.""" + middleware = StateCaptureMiddleware(publications([remote_tool("clip", PUBLISHES)])) + + result = await capture( + middleware, + "clip", + {"message": "clipped", "geometry": AOI, "dataset": "chirps"}, + {"aoi": handle_for(AOI_KEY), "dataset_id": "chirps"}, + ) + + assert isinstance(result, Command) + written = result.update[TOOL_STATE_KEY] + assert ( + written["raster-ops/clip/geometry"]["inputs"] + == written["raster-ops/clip/dataset"]["inputs"] + ) + + +async def test_a_no_argument_call_leaves_the_field_off() -> None: + middleware = StateCaptureMiddleware(publications([remote_tool("clip", PUBLISHES)])) + + result = await capture(middleware, "clip", {"message": "ok", "geometry": AOI}) + + assert isinstance(result, Command) + assert "inputs" not in result.update[TOOL_STATE_KEY]["raster-ops/clip/geometry"] + + +async def test_a_size_captured_value_records_it_too() -> None: + """A server that declared nothing still had its call's arguments read.""" + middleware = StateCaptureMiddleware(publications([remote_tool("terrain")])) + + result = await capture( + middleware, "terrain", {"message": "sampled", "samples": big()}, {"n": 400} + ) + + assert isinstance(result, Command) + entry = result.update[TOOL_STATE_KEY]["terrain/samples"] + assert entry["inputs"] == {"n": MODEL_AUTHORED} + + +# --- the laundering case, which is the point ------------------------------ + + +async def test_a_value_the_tool_merely_echoed_is_visible_as_the_models() -> None: + """The hole `NotAuthored` alone leaves open. + + A model writes a bounding box, passes it to a tool with an ordinary + parameter, and the tool returns it. Captured, it wears that tool's name and + is otherwise indistinguishable from one the tool computed. The record of + what the call was given is what tells them apart. + """ + middleware = StateCaptureMiddleware( + publications( + [ + remote_tool( + "get_aoi", [{"stateKey": "gazet/get_aoi/bbox", "field": "bbox"}] + ) + ] + ) + ) + invented = [12.4, 55.6, 12.7, 55.8] + + result = await capture( + middleware, "get_aoi", {"message": "ok", "bbox": invented}, {"bbox": invented} + ) + + assert isinstance(result, Command) + entry = result.update[TOOL_STATE_KEY]["gazet/get_aoi/bbox"] + assert entry["tool"] == "get_aoi" # still says a tool published it + assert authored(entry) == ["bbox"] # and now also says the model wrote it + + +async def test_a_derived_value_says_nothing_of_the_sort() -> None: + """The same tool called with a place name: the output is its own work.""" + middleware = StateCaptureMiddleware( + publications( + [ + remote_tool( + "get_aoi", [{"stateKey": "gazet/get_aoi/bbox", "field": "bbox"}] + ) + ] + ) + ) + + result = await capture( + middleware, + "get_aoi", + {"message": "ok", "bbox": [12.4, 55.6, 12.7, 55.8]}, + {"place": "Copenhagen"}, + ) + + assert isinstance(result, Command) + # `place` was model-authored, and that is what the record says — the client + # never claims the *output* is or is not derived. + assert authored(result.update[TOOL_STATE_KEY]["gazet/get_aoi/bbox"]) == ["place"] + + +async def test_a_model_authored_value_is_still_accepted() -> None: + """Recorded, not enforced. + + Refusing here 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 they can see beats a right + one they cannot obtain. + """ + from langchain_core.messages import AIMessage + from langgraph.graph import END, START, StateGraph + from langgraph.prebuilt.tool_node import ToolNode + + from mcp_runtime.declarations import NOT_AUTHORED_META_KEY + from mcp_state.injection import bind_injected + from mcp_state.state import AgentState + + seen: dict[str, Any] = {} + + async def call(runtime: Any = None, **arguments: Any) -> Any: + seen.update(arguments) + return "called", None + + narrowed = StructuredTool( + name="submit", + description="submit", + args_schema={ + "type": "object", + "properties": {"area": {"type": "array"}}, + "required": ["area"], + }, + coroutine=call, + response_format="content_and_artifact", + metadata={"_meta": {NOT_AUTHORED_META_KEY: ["area"]}}, + ) + # Laundered: the model wrote this bbox, a tool echoed it, and here it is. + laundered = StateEntry( + value=[12.4, 55.6, 12.7, 55.8], + tool="get_aoi", + seq=1, + inputs={"bbox": MODEL_AUTHORED}, + ) + + graph = StateGraph(AgentState) + graph.add_node("tools", ToolNode([bind_injected(narrowed)])) + graph.add_edge(START, "tools") + graph.add_edge("tools", END) + await graph.compile().ainvoke( + { + "messages": [ + AIMessage( + content="", + tool_calls=[ + { + "name": "submit", + "args": {"area": handle_for("gazet/get_aoi/bbox")}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + "tool_state": {"gazet/get_aoi/bbox": laundered}, + } + ) + + assert seen["area"] == [12.4, 55.6, 12.7, 55.8] + + +# --- reading it back ------------------------------------------------------ + + +def test_authored_names_only_the_models_own_and_sorts_them() -> None: + entry = StateEntry( + value=1, inputs={"z": MODEL_AUTHORED, "aoi": AOI_KEY, "a": MODEL_AUTHORED} + ) + assert authored(entry) == ["a", "z"] + + +def test_authored_is_empty_where_nothing_was_recorded() -> None: + assert authored(StateEntry(value=1)) == [] + assert authored(None) == [] + + +def test_provenance_is_a_chain_the_reader_may_walk() -> None: + """Each recorded input names either the model or another key, so the + history is a walk over facts rather than a propagated flag.""" + state = { + AOI_KEY: StateEntry(value=AOI, tool="search", seq=1, inputs={"query": "model"}), + "raster-ops/clip/geometry": StateEntry( + value=AOI, + tool="clip", + seq=2, + inputs={"aoi": AOI_KEY, "dataset_id": MODEL_AUTHORED}, + ), + } + + # One level: the clip rests on a model-authored dataset_id. + assert authored(state["raster-ops/clip/geometry"]) == ["dataset_id"] + # And the reader can follow `aoi` to the entry beneath it, which is its own + # fact rather than one this one inherited. + assert authored(state[state["raster-ops/clip/geometry"]["inputs"]["aoi"]]) == [ + "query" + ] + + +def test_the_listing_names_what_the_model_wrote() -> None: + listed = available( + { + "gazet/get_aoi/bbox": StateEntry( + value=[1, 2, 3, 4], tool="get_aoi", seq=1, inputs={"bbox": "model"} + ) + } + ) + assert listed == [ + "@state:gazet/get_aoi/bbox — 4 item(s), from get_aoi (you wrote: bbox)" + ] + + +def test_a_call_that_drew_on_state_names_nothing_it_also_wrote() -> None: + """The note warns that a value has no tool-found input behind it, so a + call that had one says nothing — including about the scalars beside it. + + Filtering the arguments instead would invert it: this call would carry six + names and a value invented from one argument would carry one, marking the + trustworthy value as the more suspect of the two. + """ + listed = available( + { + "cds/submit_request/job": StateEntry( + value={"job_id": "x"}, + tool="submit_request", + seq=1, + inputs={ + "area": AOI_KEY, + "dataset": MODEL_AUTHORED, + "variable": MODEL_AUTHORED, + "year": MODEL_AUTHORED, + "month": MODEL_AUTHORED, + "day": MODEL_AUTHORED, + "time": MODEL_AUTHORED, + }, + ) + } + ) + + assert listed == [ + "@state:cds/submit_request/job — object with 1 key(s), from submit_request" + ] + + +def test_a_wholly_invented_call_is_summarised_past_a_few_arguments() -> None: + """Naming them is the signal while there are few enough to read; past that + the names are what stops the rest of the line being read.""" + listed = available( + { + "search/run/results": StateEntry( + value=[1], + tool="run", + seq=1, + inputs=dict.fromkeys("abcd", MODEL_AUTHORED), + ) + } + ) + + assert listed == [ + "@state:search/run/results — 1 item(s), from run " + "(you wrote every argument: 4 of them)" + ] + + +def test_the_listing_stays_quiet_where_there_is_nothing_to_say() -> None: + """A parameter filled from state is the unremarkable case, and saying so + would cost tokens on every line of every refusal.""" + listed = available( + { + "raster-ops/clip/geometry": StateEntry( + value=[1], tool="clip", seq=1, inputs={"aoi": AOI_KEY} + ) + } + ) + assert listed == ["@state:raster-ops/clip/geometry — 1 item(s), from clip"] diff --git a/tests/mcp_state/test_receipts.py b/tests/mcp_state/test_receipts.py index da39ae4..0e14968 100644 --- a/tests/mcp_state/test_receipts.py +++ b/tests/mcp_state/test_receipts.py @@ -12,34 +12,29 @@ from langchain_core.tools import StructuredTool from langgraph.types import Command -from mcp_runtime.declarations import CONSUMES_META_KEY, PRODUCES_META_KEY -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST +from mcp_runtime.declarations import NOT_AUTHORED_META_KEY, PRODUCES_META_KEY from mcp_state.handles import dereference, dereference_with_receipts, handle_for from mcp_state.injection import bind_injected from mcp_state.middleware import StateCaptureMiddleware, publications from mcp_state.receipts import ( - BY_DECLARATION, - BY_HANDLE, INJECTED_ARTIFACT_KEY, Receipt, - breadcrumb, describe_receipt, receipts_of, - supplied, ) from mcp_state.state import StateEntry -from tests.mcp_state.test_injection import declaration, run_tools, tool_call +from tests.mcp_state.test_injection import run_tools, tool_call AOI = {"type": "FeatureCollection", "features": [{"id": "big-polygon"}]} -KEY = "dataset-search/geometry" -PUBLISHED = {KEY: StateEntry(value=AOI, kind=GEOJSON_AREA_OF_INTEREST, tool="search")} +KEY = "dataset-search/search/geometry" +PUBLISHED = {KEY: StateEntry(value=AOI, tool="search")} def consumer( name: str = "clip_raster", *, properties: dict[str, Any] | None = None, - consumes: list[dict[str, Any]] | None = None, + narrowed: list[str] | None = None, returns: dict[str, Any] | None = None, response_format: str = "content_and_artifact", ) -> StructuredTool: @@ -61,7 +56,7 @@ async def call(runtime: Any = None, **arguments: Any) -> Any: }, coroutine=call, response_format=response_format, # type: ignore[arg-type] - metadata={"_meta": {CONSUMES_META_KEY: consumes}} if consumes else None, + metadata=({"_meta": {NOT_AUTHORED_META_KEY: narrowed}} if narrowed else None), ) @@ -77,34 +72,18 @@ async def run(tool: StructuredTool, arguments: dict[str, Any]) -> ToolMessage: # --- the record ---------------------------------------------------------- -async def test_a_filled_parameter_records_where_its_value_came_from() -> None: - """The whole point: which stored value the tool ran against, and whose.""" - bound = bind_injected(consumer(consumes=[declaration()])) +async def test_a_handle_records_where_its_value_came_from() -> None: + """The key is in the transcript; who published it is not, so this is.""" + bound = bind_injected(consumer()) - message = await run(bound, {}) + message = await run(bound, {"aoi": handle_for(KEY)}) - assert receipts_of(message.artifact) == { - "aoi": { - "key": KEY, - "via": BY_DECLARATION, - "kind": GEOJSON_AREA_OF_INTEREST, - "tool": "search", - } - } - - -async def test_a_handle_is_recorded_too_and_says_so() -> None: - """Both paths land in the same place; ``via`` is what tells them apart.""" - bound = bind_injected(consumer("describe", properties={"g": {"type": "object"}})) - - message = await run(bound, {"g": handle_for(KEY)}) - - assert receipts_of(message.artifact)["g"]["via"] == BY_HANDLE + assert receipts_of(message.artifact) == {"aoi": Receipt(key=KEY, tool="search")} async def test_an_explicitly_passed_value_earns_no_receipt() -> None: - """Nothing came from state, so there is nothing to trace.""" - bound = bind_injected(consumer(consumes=[declaration()])) + """Nothing came out of state, so there is nothing to record.""" + bound = bind_injected(consumer()) message = await run(bound, {"aoi": {"type": "FeatureCollection", "features": []}}) @@ -126,9 +105,9 @@ async def test_the_wrapped_tools_return_shape_is_never_changed() -> None: Its receipts go unrecorded. Changing the shape a tool declared would break it outright, which is a worse trade than losing the record. """ - bound = bind_injected(consumer(consumes=[declaration()], response_format="content")) + bound = bind_injected(consumer(response_format="content")) - message = await run(bound, {}) + message = await run(bound, {"aoi": handle_for(KEY)}) assert message.content == "called" assert message.artifact is None @@ -156,10 +135,10 @@ async def call(runtime: Any = None, **arguments: Any) -> Any: }, coroutine=call, response_format="content", - metadata={"_meta": {CONSUMES_META_KEY: [declaration()]}}, + metadata={"_meta": {NOT_AUTHORED_META_KEY: ["aoi"]}}, ) - message = await run(bind_injected(tool), {}) + message = await run(bind_injected(tool), {"aoi": handle_for(KEY)}) assert "12.4" in str(message.content) and "3.1" in str(message.content) assert INJECTED_ARTIFACT_KEY not in str(message.content) @@ -186,10 +165,10 @@ async def call(runtime: Any = None, **arguments: Any) -> Any: }, coroutine=call, response_format="content_and_artifact", - metadata={"_meta": {CONSUMES_META_KEY: [declaration()]}}, + metadata={"_meta": {NOT_AUTHORED_META_KEY: ["aoi"]}}, ) - message = await run(bind_injected(tool), {}) + message = await run(bind_injected(tool), {"aoi": handle_for(KEY)}) assert message.artifact == ["a", "b"] @@ -228,75 +207,54 @@ async def handler(_: ToolCallRequest) -> ToolMessage: return result.update["messages"][0] if isinstance(result, Command) else result -async def test_the_model_is_told_which_stored_value_it_was_given() -> None: - """Without this the model cannot describe, or correct, what it ran against.""" - bound = bind_injected( - consumer(consumes=[declaration()], returns={"message": "clipped 3 rasters"}) - ) +async def test_nothing_is_echoed_back_to_the_model() -> None: + """The model wrote the key itself; repeating it would buy nothing. - message = await capture_of(await run(bound, {})) + The record still exists on the artifact, which is where a host reads it. + """ + bound = bind_injected(consumer(returns={"message": "clipped 3 rasters"})) - assert message.content == ( - "clipped 3 rasters\n\n[state used: aoi ← dataset-search/geometry, " - "published by search]" - ) + message = await capture_of(await run(bound, {"aoi": handle_for(KEY)})) + assert message.content == "clipped 3 rasters" + assert "state used" not in str(message.content) -async def test_a_consumer_returning_nothing_structured_still_reports() -> None: - """Capture has nothing to do here, so it used to return before saying so.""" - bound = bind_injected(consumer(consumes=[declaration()])) - message = await capture_of(await run(bound, {})) +async def test_a_consumer_returning_nothing_structured_still_records() -> None: + """Capture has nothing to do here, and the receipt survives it anyway.""" + bound = bind_injected(consumer()) - assert "[state used: aoi ← dataset-search/geometry" in message.content + message = await capture_of(await run(bound, {"aoi": handle_for(KEY)})) + assert receipts_of(message.artifact)["aoi"]["key"] == KEY -async def test_a_third_party_return_capture_leaves_alone_still_reports() -> None: + +async def test_a_third_party_return_capture_leaves_alone_still_records() -> None: """Structured, but no ``message`` and nothing big enough to capture. - Capture has no reason to touch this message, so it is the second of the two - paths that return before saying anything — and a tool on an untouched - third-party server is exactly the shape that takes it. + Capture has no reason to touch this message — and a tool on an untouched + third-party server is exactly that shape. """ - bound = bind_injected( - consumer(consumes=[declaration()], returns={"vertices": 2000}) - ) + bound = bind_injected(consumer(returns={"vertices": 2000})) - message = await capture_of(await run(bound, {})) + message = await capture_of(await run(bound, {"aoi": handle_for(KEY)})) - # The result itself is untouched — only the note is added below it. - assert message.content == ( - "called\n\n[state used: aoi ← dataset-search/geometry, published by search]" - ) assert message.artifact["structured_content"] == {"vertices": 2000} - - -async def test_a_handle_is_not_echoed_back_to_the_model() -> None: - """The model wrote the key itself; repeating it buys nothing.""" - bound = bind_injected(consumer("describe", properties={"g": {"type": "object"}})) - - message = await capture_of(await run(bound, {"g": handle_for(KEY)})) - - assert "state used" not in str(message.content) - assert receipts_of(message.artifact)["g"]["via"] == BY_HANDLE + assert receipts_of(message.artifact)["aoi"]["key"] == KEY async def test_both_directions_are_reported_on_one_message() -> None: """A tool that takes from state and publishes to it says so, in order.""" tool = consumer( "reproject", - consumes=[declaration()], + narrowed=["aoi"], returns={"message": "reprojected", "geometry": AOI}, ) tool.metadata = { "_meta": { - CONSUMES_META_KEY: [declaration()], + NOT_AUTHORED_META_KEY: ["aoi"], PRODUCES_META_KEY: [ - { - "stateKey": "raster-ops/geometry", - "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, - } + {"stateKey": "raster-ops/reproject/geometry", "field": "geometry"} ], } } @@ -307,7 +265,7 @@ async def test_both_directions_are_reported_on_one_message() -> None: state={}, runtime=None, # type: ignore[arg-type] ) - incoming = await run(bind_injected(tool), {}) + incoming = await run(bind_injected(tool), {"aoi": handle_for(KEY)}) async def handler(_: ToolCallRequest) -> ToolMessage: return incoming @@ -316,9 +274,7 @@ async def handler(_: ToolCallRequest) -> ToolMessage: assert isinstance(result, Command) message = result.update["messages"][0] - used = message.content.index("[state used:") - written = message.content.index("[state updated:") - assert used < written # inputs before outputs + assert "[state updated: raster-ops/reproject/geometry" in message.content # Rewriting the artifact must not lose the receipt a UI host reads. assert receipts_of(message.artifact)["aoi"]["key"] == KEY @@ -328,33 +284,13 @@ async def handler(_: ToolCallRequest) -> ToolMessage: def test_a_receipt_without_a_publisher_still_names_the_key() -> None: """``tool`` is optional on a state entry, so it is optional here.""" - assert ( - describe_receipt("aoi", Receipt(key=KEY, via=BY_DECLARATION)) == f"aoi ← {KEY}" - ) - + assert describe_receipt("aoi", Receipt(key=KEY)) == f"aoi ← {KEY}" -def test_nothing_declared_means_no_note_at_all() -> None: - assert breadcrumb({}) is None - assert breadcrumb({"g": Receipt(key=KEY, via=BY_HANDLE)}) is None - -def test_several_filled_parameters_are_listed_in_one_note() -> None: - note = breadcrumb( - { - "aoi": Receipt(key=KEY, via=BY_DECLARATION, tool="search"), - "bbox": Receipt(key="a/bbox", via=BY_DECLARATION), - } +def test_a_receipt_with_a_publisher_names_it() -> None: + assert describe_receipt("aoi", Receipt(key=KEY, tool="search")) == ( + f"aoi ← {KEY}, published by search" ) - assert note == f"[state used: aoi ← {KEY}, published by search; bbox ← a/bbox]" - - -def test_a_host_is_shown_only_what_the_arguments_do_not_already_say() -> None: - """A handle is in the arguments the model wrote; a declared fill is not.""" - receipts = { - "aoi": Receipt(key=KEY, via=BY_DECLARATION), - "g": Receipt(key=KEY, via=BY_HANDLE), - } - assert list(supplied(receipts, {"g": handle_for(KEY), "id": "era5"})) == ["aoi"] def test_a_message_that_never_saw_state_reads_as_empty() -> None: diff --git a/tests/mcp_state/test_wiring.py b/tests/mcp_state/test_wiring.py deleted file mode 100644 index 1639bcd..0000000 --- a/tests/mcp_state/test_wiring.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Whether a declared parameter has anything that could ever fill it.""" - -from typing import Any - -import pytest -from langchain_core.tools import StructuredTool -from langchain_core.utils.function_calling import convert_to_openai_tool - -from mcp_runtime.declarations import CONSUMES_META_KEY, PRODUCES_META_KEY -from mcp_runtime.kinds import GEOJSON_AREA_OF_INTEREST, GEOJSON_FOOTPRINT -from mcp_state.injection import bind_all_injected -from mcp_state.wiring import raise_unsatisfiable, unsatisfiable, partition_usable - -PUBLISHES_AOI = { - PRODUCES_META_KEY: [ - { - "stateKey": "dataset-search/geometry", - "field": "geometry", - "kind": GEOJSON_AREA_OF_INTEREST, - } - ] -} - - -def injects(kind: str, *, required: bool = True) -> dict[str, Any]: - """A parameter the tool said a model must not invent. - - ``modelGeneratable=False`` is the only combination that can be fatal, so it - is what these tests are about; the generatable cases are exercised - explicitly further down. - """ - return { - CONSUMES_META_KEY: [ - { - "parameter": "aoi", - "kind": kind, - "required": required, - "modelGeneratable": False, - } - ] - } - - -def mcp_tool(name: str, meta: dict[str, Any] | None = None) -> StructuredTool: - async def call(**arguments: Any) -> Any: - return "ok", None - - return StructuredTool( - name=name, - description=name, - args_schema={"type": "object", "properties": {}}, - coroutine=call, - metadata={"_meta": meta} if meta else None, - ) - - -def test_a_connected_publisher_satisfies_a_consumer() -> None: - tools = [ - mcp_tool("search", PUBLISHES_AOI), - mcp_tool("clip", injects(GEOJSON_AREA_OF_INTEREST)), - ] - assert unsatisfiable(tools) == [] - - -def test_a_consumer_with_no_publisher_is_reported() -> None: - (found,) = unsatisfiable([mcp_tool("clip", injects(GEOJSON_AREA_OF_INTEREST))]) - assert (found.tool, found.parameter, found.wants) == ( - "clip", - "aoi", - GEOJSON_AREA_OF_INTEREST, - ) - assert found.required - - -def test_a_mistyped_kind_shows_up_as_unsatisfiable() -> None: - """What lets the vocabulary stay open: the wiring is checked, not the name.""" - tools = [ - mcp_tool("search", PUBLISHES_AOI), - mcp_tool("clip", injects("geojson.AreaofInterest")), - ] - (found,) = unsatisfiable(tools) - assert found.wants == "geojson.AreaofInterest" - - -def test_a_publisher_of_the_wrong_kind_does_not_count() -> None: - publishes_footprint = { - PRODUCES_META_KEY: [ - {"stateKey": "x/f", "field": "f", "kind": GEOJSON_FOOTPRINT} - ] - } - tools = [ - mcp_tool("coverage", publishes_footprint), - mcp_tool("clip", injects(GEOJSON_AREA_OF_INTEREST)), - ] - assert len(unsatisfiable(tools)) == 1 - - -def test_an_unfillable_tool_is_withheld_from_the_agent() -> None: - """The model is never offered a tool whose every call would raise. - - No publisher of the kind `clip` wants is connected, so it is withheld — - and only it: `weather` is untouched. - """ - tools = [mcp_tool("weather"), mcp_tool("clip", injects(GEOJSON_AREA_OF_INTEREST))] - agent_tools, withheld = partition_usable(tools) - assert [tool.name for tool in agent_tools] == ["weather"] - assert [item.tool for item in withheld] == ["clip"] - - -def test_a_satisfiable_tool_stays_available_before_anything_is_published() -> None: - """Satisfiability is about a connected publisher, not a published value. - - `clip` must stay callable so the model can be told to run `search` first — - the error path in scenario 4 of docs/SESSION-STATE.md. `partition_usable` is handed - no state at all, which is what guarantees it. - """ - tools = [ - mcp_tool("search", PUBLISHES_AOI), - mcp_tool("clip", injects(GEOJSON_AREA_OF_INTEREST)), - ] - agent_tools, withheld = partition_usable(tools) - assert {tool.name for tool in agent_tools} == {"search", "clip"} - assert withheld == [] - - -def test_an_optional_parameter_never_withholds_its_tool() -> None: - tools = [mcp_tool("clip", injects(GEOJSON_AREA_OF_INTEREST, required=False))] - agent_tools, withheld = partition_usable(tools) - assert [tool.name for tool in agent_tools] == ["clip"] - assert withheld == [] - assert len(unsatisfiable(tools)) == 1 # still reported - - -def test_third_party_tools_are_never_implicated() -> None: - tools = [mcp_tool("weather"), mcp_tool("search", PUBLISHES_AOI)] - assert unsatisfiable(tools) == [] - assert len(partition_usable(tools)[0]) == 2 - - -def test_a_fallback_parameter_is_handed_back_to_the_model() -> None: - """With no publisher connected, the tool degrades to plain MCP. - - The parameter is in the server's advertised schema either way, so leaving - it there is exactly what a client implementing none of this would do — and - strictly better than deleting a usable tool. - """ - falls_back = { - CONSUMES_META_KEY: [ - { - "parameter": "bbox", - "kind": "geo.BoundingBox", - "required": True, - "modelGeneratable": True, - } - ] - } - tool = StructuredTool( - name="clip", - description="clip", - args_schema={ - "type": "object", - "properties": {"id": {"type": "string"}, "bbox": {"type": "array"}}, - "required": ["id", "bbox"], - }, - coroutine=mcp_tool("x").coroutine, - metadata={"_meta": falls_back}, - ) - agent_tools, withheld = partition_usable([tool]) - assert agent_tools == [tool] - assert withheld == [] - - (reported,) = unsatisfiable([tool]) - assert reported.model_generatable and not reported.fatal - - bound = bind_all_injected([tool])[0] - assert ( - "bbox" in convert_to_openai_tool(bound)["function"]["parameters"]["properties"] - ) - - -def test_a_fallback_parameter_is_still_injected_when_it_can_be() -> None: - """Fallback is the degraded path, not the normal one.""" - falls_back = { - CONSUMES_META_KEY: [ - { - "parameter": "aoi", - "kind": GEOJSON_AREA_OF_INTEREST, - "required": True, - "modelGeneratable": True, - } - ] - } - tool = StructuredTool( - name="clip", - description="clip", - args_schema={ - "type": "object", - "properties": {"aoi": {"type": "object"}}, - "required": ["aoi"], - }, - coroutine=mcp_tool("x").coroutine, - metadata={"_meta": falls_back}, - ) - bound = bind_all_injected([mcp_tool("search", PUBLISHES_AOI), tool])[1] - parameters = convert_to_openai_tool(bound)["function"]["parameters"] - assert "aoi" not in parameters["properties"] - - -def test_raise_unsatisfiable_names_the_broken_wire() -> None: - tools = [mcp_tool("clip", injects(GEOJSON_AREA_OF_INTEREST))] - with pytest.raises(RuntimeError, match="clip.aoi"): - raise_unsatisfiable(tools) - - -def test_raise_unsatisfiable_ignores_non_fatal_by_default() -> None: - tools = [mcp_tool("clip", injects(GEOJSON_AREA_OF_INTEREST, required=False))] - raise_unsatisfiable(tools) - with pytest.raises(RuntimeError): - raise_unsatisfiable(tools, fatal_only=False)