fix(workflow): answer when the product does not exist

MCP does not report a tool failure as a JSON-RPC error. It reports it as a
successful result carrying isError: true, with the reason as text. The node
only checked `error`, so it read the failure as data and went on to JSON.parse
the sentence "HTTP call failed with status 404" as a product. The throw is what
made the webhook answer nothing at all.

Now checks result.isError, distinguishes a missing product from any other tool
failure, and guards every parse of a payload it did not construct. Through the
facade, escalate: true becomes a task in state failed rather than a completed
one.

Also corrects this file: the earlier note said the run completed in success
with every node executing. It did not — that was the wrong execution. n8n's
executions list did not return them newest-first as assumed, and the conclusion
drawn from it sent the search to the wrong place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-29 13:40:19 +02:00
parent 666735b0eb
commit f49f3ec360
2 changed files with 36 additions and 9 deletions
+35 -8
View File
@@ -51,13 +51,40 @@ The regex is loose after the underscore on purpose — a reference that looks
valid but does not exist must reach the catalog and come back as "no such valid but does not exist must reach the catalog and come back as "no such
product", because answering "you named no product" would be false. product", because answering "you named no product" would be false.
### Known gap ### The one that took longest
A product code that does not exist returns an **empty body** rather than that A product code that does not exist returned an **empty body**, and the reason is
message. The run completes, every node executes including the one that composes worth keeping.
the answer, and the response is still empty — so the fault is between the
composing node and the webhook response, not in the decision logic.
`alwaysOutputData` on the read node did not change it.
For an autonomous agent, silence is the worst possible answer. Not shipped as MCP does not report a tool failure as a JSON-RPC `error`. It reports it as a
acceptable — recorded as unfinished. perfectly successful **result** carrying `isError: true`, with the reason as
text:
```
data: {"jsonrpc":"2.0","result":{"content":[{"type":"text",
"text":"HTTP call failed with status 404: {\"detail\":\"Not found.\"}"}],
"isError":true},"id":2}
```
A client that only checks `rpc.error` therefore reads a failure as data. This
one went on to `JSON.parse` the sentence "HTTP call failed with status 404" as
if it were a product, threw, and the webhook answered nothing at all.
Two lessons, both now in the node:
- **check `result.isError`, not just `error`** — they mean different things: a
protocol failure versus a tool that ran and failed;
- **guard every `JSON.parse` of a payload you did not construct.** The throw is
what turned a wrong answer into no answer, which for an autonomous agent is
the worse of the two.
Now:
```
"Peut-on promettre S99_NOPE ?"
→ {"answer":"There is no product S99_NOPE in the catalog.","escalate":true}
```
and through the A2A facade, `escalate: true` becomes a task in state **failed**
rather than a completed one — the honest shape for "a human has to look at
this".
@@ -157,7 +157,7 @@
}, },
{ {
"parameters": { "parameters": {
"jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $input.first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nconst rpc = JSON.parse(line.slice(5).trim());\nif (rpc.error) {\n // A refusal is the caller's own permissions being applied. Reported, never\n // worked around.\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\nconst product = JSON.parse(rpc.result.content[0].text);\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}.`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised.`,\n escalate: false,\n}}];\n" "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $input.first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}.`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised.`,\n escalate: false,\n}}];\n"
}, },
"id": "answer", "id": "answer",
"name": "Answer", "name": "Answer",