{ "name": "Approvisionnement \u2014 periodic reorder proposal", "nodes": [ { "parameters": { "rule": { "interval": [ { "field": "days", "triggerAtHour": 6 } ] } }, "id": "every-morning", "name": "Every morning", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [ 0, 0 ] }, { "parameters": { "jsCode": "// No user session exists at 6am, so the authority has to be pre-established.\n//\n// NOT IMPLEMENTED. The design calls for an offline_access mandate: the user who\n// authorises the agent obtains a refresh token, it is stored encrypted, and the\n// exchange happens at trigger time \u2014 so `sub` stays the person who mandated and\n// `azp` says which agent acted.\n//\n// Until that exists this run has no identity of its own. It reads\n// APPROVISIONNEMENT_MANDATE_TOKEN from the environment if one was placed there\n// by hand, and stops cleanly otherwise rather than inventing an authority.\nconst token = $env.APPROVISIONNEMENT_MANDATE_TOKEN;\nif (!token) {\n return [{ json: {\n halted: true,\n reason: 'No mandate. This run needs an offline_access token from the user who '\n + 'authorised the agent; none is configured, so it did nothing.',\n }}];\n}\nreturn [{ json: { halted: false, token } }];\n" }, "id": "mandate", "name": "Mandate", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 220, 0 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "version": 2 }, "conditions": [ { "leftValue": "={{ $json.halted }}", "rightValue": false, "operator": { "type": "boolean", "operation": "false" } } ], "combinator": "and" } }, "id": "has-a-mandate?", "name": "Has a mandate?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 440, 0 ] }, { "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": "={{ $json.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": [ 660, -100 ] }, { "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-periodic\", \"version\": \"0.1.0\"}}}", "options": { "response": { "response": { "fullResponse": true } } } }, "id": "mcp-initialize", "name": "MCP initialize", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 880, -100 ] }, { "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": "{\"jsonrpc\": \"2.0\", \"id\": 2, \"method\": \"tools/call\", \"params\": {\"name\": \"list-products\", \"arguments\": {\"query_page\": 1, \"query_page_size\": 200}}}", "options": { "response": { "response": { "neverError": true } } } }, "id": "list-products", "name": "List products", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1100, -100 ], "alwaysOutputData": true }, { "parameters": { "jsCode": "// Which references need a purchase order, and why.\n//\n// The proposal is computed and goes no further, because there is nowhere for it\n// to go: the classicmodels schema has no purchase order, no supplier entity \u2014\n// `productvendor` is a string on the product \u2014 and the catalog's only write\n// tool is create-product, which is the Catalogue agent's business, not this\n// one's. So this run escalates rather than writing, which is also what an\n// autonomous agent meeting a decision it cannot take is supposed to do.\nconst THRESHOLD = 50;\nconst COVER = 3; // reorder up to three times the threshold\n\nconst item = $input.first();\nconst raw = item?.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: { proposals: [], escalate: true,\n reason: 'The catalog did not answer in the expected shape.' }}];\n}\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { proposals: [], escalate: true,\n reason: \"The catalog's reply could not be read.\" }}]; }\n\n// A tool failure is a successful result carrying isError, not a JSON-RPC error.\nif (rpc.error || rpc.result?.isError) {\n const why = rpc.error?.message ?? rpc.result?.content?.[0]?.text ?? 'unknown';\n return [{ json: { proposals: [], escalate: true,\n reason: `The catalog refused the read: ${why}` }}];\n}\n\nconst page = JSON.parse(rpc.result.content[0].text);\nconst products = page.results ?? [];\nconst scanned = products.length;\nconst total = page.count ?? scanned;\n\nconst proposals = products\n .filter((p) => Number(p.quantityinstock) < THRESHOLD)\n .map((p) => ({\n productcode: p.productcode,\n productname: p.productname,\n vendor: p.productvendor,\n on_hand: Number(p.quantityinstock),\n propose_order: THRESHOLD * COVER - Number(p.quantityinstock),\n }));\n\nreturn [{ json: {\n proposals,\n scanned,\n // Loud when the scan was partial: a proposal set computed from part of the\n // catalog looks exactly like one computed from all of it.\n complete: scanned >= total,\n escalate: proposals.length > 0 || scanned < total,\n reason: scanned < total\n ? `Scanned ${scanned} of ${total} references; the rest were not read.`\n : (proposals.length\n ? `${proposals.length} reference(s) below the threshold of ${THRESHOLD}. `\n + 'Nowhere to record a purchase order \u2014 the domain has no such concept \u2014 '\n + 'so this needs a human.'\n : 'Nothing below the threshold.'),\n}}];\n" }, "id": "propose-reorders", "name": "Propose reorders", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1320, -100 ] }, { "parameters": {}, "id": "no-mandate", "name": "No mandate", "type": "n8n-nodes-base.noOp", "typeVersion": 1, "position": [ 660, 120 ] } ], "connections": { "Every morning": { "main": [ [ { "node": "Mandate", "type": "main", "index": 0 } ] ] }, "Mandate": { "main": [ [ { "node": "Has a mandate?", "type": "main", "index": 0 } ] ] }, "Has a mandate?": { "main": [ [ { "node": "Exchange token", "type": "main", "index": 0 } ], [ { "node": "No mandate", "type": "main", "index": 0 } ] ] }, "Exchange token": { "main": [ [ { "node": "MCP initialize", "type": "main", "index": 0 } ] ] }, "MCP initialize": { "main": [ [ { "node": "List products", "type": "main", "index": 0 } ] ] }, "List products": { "main": [ [ { "node": "Propose reorders", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" } }