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:
success: falseis the engine's structural failure signal.ExtractToolResultreadssuccessoff any JSON object and records an object WITHOUT it as a successful call. So the shape the surface uses elsewhere for a status verdict —{status: "Error", message}onRecycle,Compile,GetDiagnostics,RunTests— would have flipped every refusal from "failed call" (what the prose reads as today) to "succeeded" in every persisted thread. The field is what makes the container a failure and not merely a shape.refusedtells "fix the query" from "something broke". It is derived, never sniffed: the operation refuses exactly when the query is unanchored — nobasePath(which anchors it), notParsedQuery.CrossPartition, notParsedQuery.IsSufficientlySpecified(), and noMeshConfiguration.ResolveRoutingHintspartition — and it refuses BEFORE it touches the mesh, so a failure on a query that judgement serves is a fault. Same public predicate, same inputs; core stays the one judge of whether to serve, the tool only labels what core already refused.MeshPluginTest.Search_Unanchored_IsRefusedAsAJsonEnvelopepins the two agreeing: if core ever serves the bare form, that test sees a results envelope and fails loudly.erroris the operation's own text, remedy and all — carried through, not summarised, so a core reword reaches the model without a Plugins release.
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.