A tool's failure travels in the tool's own container

A tool whose answer is JSON must fail in JSON. Its consumers parse the answer; a failure that arrives as a sentence is not a failure they can read — it is an exception thrown at the first letter, and whatever the sentence said is lost with it. This page records how the chat agents' Search tool came to violate that, what it answers now, and why the same fix must NOT be copied onto the MCP surface next door.

What happened

MeshOperations.Search (core) answers a served query with the envelope {count, limit, truncated, coverage:{scope, partitions}, results:[…]}. Since core #4290 it REFUSES a query that names no place to look — no namespace:/path: anchor, no basePath, no partitions:all — because on a Postgres portal that shape was answered from whichever partitions the store happened to enumerate and its zero read as "none exist" (MeshWeaver #4274, Search Coverage and Refusal). The refusal is right.

It is written the way every MeshOperations failure is written: the prose string Error: Query is not sufficiently specified: '…' names no partition. … Anchor it (…), or declare the fan-out (add partitions:all) …. That is the surface's documented dual contract — JSON on success, Error: … on failure — and core's own McpRemoteMeshClient keys on exactly that prefix.

The chat agents' tool, MeshPlugin.Search (src/MeshWeaver.AI/MeshPlugin.cs), handed the string through unchanged. Its consumers parse JSON: the AI module's own tests, and the engine's ThreadExecution.ExtractToolResult, which reads a tool result to record whether the call succeeded. Measured twice on the platform's release bake — main-cd #8577 (09:56Z) and #8581 (10:44Z), 2026-09-14 — two MeshPluginTest cases threw JsonReaderException: 'E' is an invalid start of a value, the Module tests leg went red, and the Plugins publication for each platform identity was skipped. Plugins#1844 re-anchored the two tests (partitions:all), which made the bake green again; it did not change what a refusal looks like to the next reader, and no test asked.

What the tool answers now

MeshPlugin.Search bridges through ToolTask.Bridge with three arms of its own, and every arm is JSON:

The operation said The tool answers
a served envelope passed through untouched
Error: … (a refusal, or a caught fault) {"success": false, "refused": <bool>, "error": "<the operation's text>"}
a bridge fault / an empty completion the same shape, refused: false

Each field has one job:

And deliberately no count, no results. The listing tools' existing failure shape (McpMeshPlugin.Autocomplete: {count: 0, results: [], error}) would parse, and it would be read: a consumer that checks results before error — most of them — takes it for "nothing found". That is the misreading the refusal exists to stop, one level up. A refusal answers nothing; the container must not be mistakable for an answer.

Why the MCP search tool keeps the prose

McpMeshPlugin.Search (src/MeshWeaver.Mcp) exposes the same operation to MCP clients and still answers the refusal as Error: …, on purpose. Core's McpRemoteMeshClient — the instance-sync pull sweep's remote reader — recognises a refusal by the Error: prefix and throws it as the refusal it is; handed a JSON object instead, it would parse it as an envelope with no results, return an empty list, and the #4274 false zero would be back on the remote path. Moving that surface is a coordinated core change: the client learns both shapes first, the server changes second. Until then the two surfaces differ by design, and the MCP tool's description says so in its own words.

Core's Doc/AI/Tools/MeshPlugin.md — loaded for the model by AgentChatClient.LoadToolDocumentationAsync — still describes the prose form; the agents' own ToolsReference.md in this module describes the container. Aligning the core page is the follow-up.

The rule, stated once

A tool's failure arm is part of its contract, not an afterthought of the operation it wraps. When a tool's success is JSON, every arm — the operation's refusal, the operation's fault, the bridge's fault, the bridge's silence — answers JSON carrying success: false, and the test that proves it issues the failing input on purpose. A failure nobody can parse is a failure nobody can act on.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.