{ "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" } }