de7725bfb2
Filtering by product and status on order lines was only half the job — the date lived one hop away, on the order. classic-models-api now denormalises requireddate and order_status onto the line itself, so this is one MCP call per status rather than one per order. Additive only: this is not the committed-quantity deduction that turned out to be wrong, since stock is already net of it. It just says what open orders expect and by when. A straight chain of ten nodes rather than two branches joining on Answer — whether n8n waits for both inputs before running a fan-in node isn't something to bet correctness on without watching it execute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
507 lines
20 KiB
JSON
507 lines
20 KiB
JSON
{
|
|
"name": "Supply \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-supply"
|
|
},
|
|
{
|
|
"name": "client_secret",
|
|
"value": "={{ $env.SUPPLY_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\": \"supply\", \"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 = $('Read stock').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\n// What the reactive half noticed, if anything. `product.stock_changed` is\n// consumed as it happens, so a crossing that occurred twenty minutes ago is\n// known here \u2014 and \"it fell below this afternoon, under demand\" is a different\n// decision from \"it has been sitting below for a month\".\n//\n// Read from the watchlist, and absent if the watchlist could not be read: an\n// availability answer must not depend on it.\nlet movement = null;\ntry {\n const watch = $('Recent movement').first()?.json ?? {};\n movement = (watch.entries ?? []).find((e) => e.productcode === product.productcode) ?? null;\n} catch (e) {\n movement = null;\n}\n\n// Only while the reference is still low. An entry stays on the watchlist until\n// a human clears it, so a reference that has since been restocked still has\n// one \u2014 and \"300 in stock \u2026 it crossed the threshold, from 68\" reads as a\n// contradiction even though both halves are true.\nlet context = '';\nif (movement && !available) {\n const when = movement.last_movement_at ?? movement.observed_at;\n const driver = movement.driver === 'demand'\n ? 'driven by orders'\n : 'a stock correction, not demand';\n context = movement.stock_out\n ? ` It ran out at ${when} (${driver}).`\n : ` It crossed the threshold at ${when} (${driver}), from ${movement.was}.`;\n}\n\n// This caveat used to read \"quantities committed on open orders are not\n// deducted\". It is no longer true, and worse, deducting them now would be\n// **wrong**: placing an order line decrements stock in the same transaction, so\n// an open order has already taken what it needs. Subtracting it again would\n// count it twice.\n//\n// What remains, and is worth saying once: the order lines that came with the\n// seed data predate that behaviour and never decremented anything. They are\n// history, not commitments.\n\n// What this reference already commits to, and by when. `list-order-details`\n// filtered by product and status carries requireddate/order_status on the line\n// itself now \u2014 no second call per order to learn it.\n//\n// A failure here (a status filter that finds nothing is not a failure; an\n// unreachable sales domain is) must not break the availability answer: this\n// context is additive, never load-bearing.\nfunction readOpenLines(nodeName) {\n try {\n const item = $(nodeName).first();\n const raw = item?.json ?? {};\n const body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n const line = String(body).split('\\n').find((l) => l.startsWith('data:'));\n if (!line) return [];\n const rpc = JSON.parse(line.slice(5).trim());\n if (rpc.error || rpc.result?.isError) return [];\n const page = JSON.parse(rpc.result.content[0].text);\n return page.results ?? [];\n } catch (e) {\n return [];\n }\n}\n\nconst openLines = [\n ...readOpenLines('Read commitments (In Process)'),\n ...readOpenLines('Read commitments (On Hold)'),\n].sort((a, b) => (a.requireddate < b.requireddate ? -1 : 1));\n\nlet commitment = '';\nif (openLines.length === 1) {\n const l = openLines[0];\n commitment = ` ${l.quantityordered} unit(s) already committed to order ${l.ordernumber}, `\n + `due ${l.requireddate} (${l.order_status}).`;\n} else if (openLines.length > 1) {\n const soonest = openLines[0];\n commitment = ` ${openLines.length} open order(s) already commit to this reference; `\n + `the nearest is due ${soonest.requireddate} (order ${soonest.ordernumber}, ${soonest.order_status}).`;\n}\n\nconst basis = 'stock on hand, already net of order lines placed through the API';\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 + `Basis: ${basis}.${context}${commitment}`\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 + `Basis: ${basis}.${context}${commitment}`,\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
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "GET",
|
|
"url": "http://n8n.n8n.svc.cluster.local/webhook/stock-watchlist",
|
|
"options": {
|
|
"timeout": 5000
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1100,
|
|
0
|
|
],
|
|
"id": "recent-movement",
|
|
"name": "Recent movement",
|
|
"onError": "continueRegularOutput",
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"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-supply"
|
|
},
|
|
{
|
|
"name": "client_secret",
|
|
"value": "={{ $env.SUPPLY_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-sales-audience token-exchange-broker-audience"
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"response": {
|
|
"response": {
|
|
"neverError": true
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"id": "exchange-sales-token",
|
|
"name": "Exchange sales token",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1320,
|
|
0
|
|
],
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://sales-dev.sttlab.local/sales-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\": \"supply\", \"version\": \"0.1.0\"}}}",
|
|
"options": {
|
|
"response": {
|
|
"response": {
|
|
"fullResponse": true
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"id": "mcp-initialize-sales",
|
|
"name": "MCP initialize (sales)",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1540,
|
|
0
|
|
],
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://sales-dev.sttlab.local/sales-mcp",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Authorization",
|
|
"value": "=Bearer {{ $('Exchange sales token').item.json.access_token }}"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "application/json, text/event-stream"
|
|
},
|
|
{
|
|
"name": "mcp-session-id",
|
|
"value": "={{ $('MCP initialize (sales)').item.json.headers['mcp-session-id'] }}"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'list-order-details', arguments: { query_productcode: $('Decide the product').item.json.productcode, query_ordernumber__status: 'In Process' } } }) }}",
|
|
"options": {
|
|
"response": {
|
|
"response": {
|
|
"neverError": true
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"id": "read-commitments-in-process",
|
|
"name": "Read commitments (In Process)",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1760,
|
|
0
|
|
],
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://sales-dev.sttlab.local/sales-mcp",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Authorization",
|
|
"value": "=Bearer {{ $('Exchange sales token').item.json.access_token }}"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "application/json, text/event-stream"
|
|
},
|
|
{
|
|
"name": "mcp-session-id",
|
|
"value": "={{ $('MCP initialize (sales)').item.json.headers['mcp-session-id'] }}"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'list-order-details', arguments: { query_productcode: $('Decide the product').item.json.productcode, query_ordernumber__status: 'On Hold' } } }) }}",
|
|
"options": {
|
|
"response": {
|
|
"response": {
|
|
"neverError": true
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"id": "read-commitments-on-hold",
|
|
"name": "Read commitments (On Hold)",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1980,
|
|
0
|
|
],
|
|
"alwaysOutputData": true
|
|
}
|
|
],
|
|
"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": "Recent movement",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Answer": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Respond",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Recent movement": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Exchange sales token",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Exchange sales token": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "MCP initialize (sales)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"MCP initialize (sales)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Read commitments (In Process)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Read commitments (In Process)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Read commitments (On Hold)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Read commitments (On Hold)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Answer",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
}
|
|
}
|