Files
classic-models-agent-flows/workflows/approvisionnement-check-availability.json
T
Stéphane Tailland 562987cd69 fix(workflow): reach the catalog, and answer
Two things were wrong, and the second hid the first.

n8n does not re-register an active workflow's definition on PATCH: the running
webhook kept executing the version it was activated with. So the corrected
subject_token expression was never running, and the Keycloak V1 NullPointer it
kept producing was the old empty-token failure — including through the attempt
to capture the request with an echo service, which the old definition never
called. Deactivate, apply, reactivate; written down in workflows/README.md
because it costs an afternoon otherwise.

The token reference itself: $json is the previous node's output, not the
request, so the subject token has to come from $('Webhook').

The regex is now loose after the underscore, so a reference that looks valid but
does not exist reaches the catalog instead of being answered with 'you named no
product', which was false.

Working for the main path and for a question naming no product. A nonexistent
code still returns an empty body — recorded as a known gap, not as acceptable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:24:44 +02:00

259 lines
9.0 KiB
JSON

{
"name": "Approvisionnement \u2014 check_availability",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "check-availability",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "check-availability"
},
{
"parameters": {
"method": "POST",
"url": "https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token",
"sendBody": true,
"contentType": "form-urlencoded",
"bodyParameters": {
"parameters": [
{
"name": "grant_type",
"value": "urn:ietf:params:oauth:grant-type:token-exchange"
},
{
"name": "client_id",
"value": "classic-models-agent-approvisionnement"
},
{
"name": "client_secret",
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}"
},
{
"name": "subject_token",
"value": "={{ $('Webhook').item.json.headers['x-subject-token'] }}"
},
{
"name": "subject_token_type",
"value": "urn:ietf:params:oauth:token-type:access_token"
},
{
"name": "scope",
"value": "openid mcp-catalog-audience token-exchange-broker-audience"
}
]
},
"options": {}
},
"id": "exchange-token",
"name": "Exchange token",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
220,
0
]
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"approvisionnement\", \"version\": \"0.1.0\"}}}",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"id": "mcp-initialize",
"name": "MCP initialize",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
0
]
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('Exchange token').item.json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
},
{
"name": "mcp-session-id",
"value": "={{ $json.headers['mcp-session-id'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'get-product', arguments: { path_productcode: $('Decide the product').item.json.productcode } } }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"id": "read-stock",
"name": "Read stock",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
0
],
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// Pull the product code out of the question.\n//\n// Deliberately loose on the part after the underscore: a reference that\n// looks like one but does not exist must reach the catalog and come back\n// as \"no such product\". Rejecting it here would answer \"you named no\n// product\", which is false and sends the caller looking in the wrong place.\n//\n// A regex rather than a model: this agent's critical path is rules, and putting\n// an LLM on it would make a nightly job both slower and less predictable. If the\n// question does not name a product, the agent escalates instead of guessing \u2014\n// an autonomous agent that meets an ambiguity must hand it back, never resolve\n// it on its own.\nconst question = $('Webhook').first().json.body?.question ?? '';\nconst match = question.match(/\\bS\\d+_[A-Za-z0-9]+\\b/);\nif (!match) {\n return [{ json: { productcode: null, question } }];\n}\nreturn [{ json: { productcode: match[0], question } }];\n"
},
"id": "decide-the-product",
"name": "Decide the product",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
180
]
},
{
"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"
},
"id": "answer",
"name": "Answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"id": "respond",
"name": "Respond",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1100,
0
]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Decide the product",
"type": "main",
"index": 0
}
]
]
},
"Decide the product": {
"main": [
[
{
"node": "Exchange token",
"type": "main",
"index": 0
}
]
]
},
"Exchange token": {
"main": [
[
{
"node": "MCP initialize",
"type": "main",
"index": 0
}
]
]
},
"MCP initialize": {
"main": [
[
{
"node": "Read stock",
"type": "main",
"index": 0
}
]
]
},
"Read stock": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Respond",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}