{ "name": "Supply \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": { "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": "client_credentials" }, { "name": "client_id", "value": "classic-models-agent-supply" }, { "name": "client_secret", "value": "={{ $env.SUPPLY_CLIENT_SECRET }}" }, { "name": "scope", "value": "openid mcp-catalog-audience token-exchange-broker-audience" } ] }, "options": {} }, "id": "machine-token", "name": "Machine 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-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": [ 440, 0 ] }, { "parameters": { "method": "POST", "url": "https://catalog-dev.sttlab.local/catalog-mcp", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "=Bearer {{ $('Machine 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": [ 660, 0 ], "alwaysOutputData": true }, { "parameters": { "jsCode": "// Which references need a purchase order, and why.\n//\n// A proposal becomes a request event, not a write: the domain still has no\n// purchase-order entity, and this run does not decide a delivery date or a\n// supplier's answer \u2014 that is the procurement half of this same service,\n// reacting to reorder.requested on its own schedule.\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\n// Each proposal becomes a request, not a purchase order \u2014 the domain still has\n// no such entity, and this run is still not the one that decides a delivery\n// date or a supplier's answer. It raises a fact and hands the decision to the\n// same service's own procurement half, over Kafka, exactly the way any other\n// consumer would be handed it.\nconst now = new Date().toISOString();\nconst requests = proposals.map((p) => ({\n json: {\n reorder_id: `${Date.now().toString(36)}-${p.productcode}-${Math.random().toString(36).slice(2, 8)}`,\n productcode: p.productcode,\n quantity: p.propose_order,\n requested_at: now,\n },\n}));\n\n// Nothing to request is a normal outcome, not a diagnostic \u2014 this workflow has\n// no reader downstream of an \"escalate\" field the way check_availability's\n// webhook does, so there is nowhere for a warning to usefully go beyond n8n's\n// own execution log. A partial scan (scanned < total) is visible there:\n// `results.length` on the raw page ends up smaller than `count`.\nreturn requests;\n" }, "id": "propose-reorders", "name": "Propose reorders", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 880, 0 ] }, { "parameters": { "topic": "classic-models.procurement", "sendInputData": false, "message": "={{ JSON.stringify({ specversion: '1.0', type: 'com.sttlab.classicmodels.reorder.requested.v1', source: '/supply-agent/procurement', subject: $json.reorder_id, id: $json.reorder_id, time: $json.requested_at, datacontenttype: 'application/json', data: { reorder_id: $json.reorder_id, productcode: $json.productcode, quantity: $json.quantity, requested_at: $json.requested_at } }) }}", "useKey": true, "key": "={{ $json.reorder_id }}", "options": {} }, "id": "emit-reorder-requests", "name": "Emit reorder requests", "type": "n8n-nodes-base.kafka", "typeVersion": 2, "position": [ 1100, 0 ], "credentials": { "kafka": { "id": "8qNZXLzvdx5rWQ0V", "name": "kafka-classic-models" } } } ], "connections": { "Every morning": { "main": [ [ { "node": "Machine token", "type": "main", "index": 0 } ] ] }, "Machine 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 } ] ] }, "Propose reorders": { "main": [ [ { "node": "Emit reorder requests", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" } }