From f49f3ec36028bbbee919f1830cdcaf2e0a115fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Sat, 29 Aug 2026 13:40:19 +0200 Subject: [PATCH] fix(workflow): answer when the product does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- workflows/README.md | 43 +++++++++++++++---- .../approvisionnement-check-availability.json | 2 +- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/workflows/README.md b/workflows/README.md index 575ac15..9cb680d 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -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 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 -message. The run completes, every node executes including the one that composes -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. +A product code that does not exist returned an **empty body**, and the reason is +worth keeping. -For an autonomous agent, silence is the worst possible answer. Not shipped as -acceptable — recorded as unfinished. +MCP does not report a tool failure as a JSON-RPC `error`. It reports it as a +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". diff --git a/workflows/approvisionnement-check-availability.json b/workflows/approvisionnement-check-availability.json index a7da365..03d74f0 100644 --- a/workflows/approvisionnement-check-availability.json +++ b/workflows/approvisionnement-check-availability.json @@ -157,7 +157,7 @@ }, { "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", "name": "Answer",