2bb6faa121
Withdrawn: the offline_access mandate built an hour ago. An agent running on its own account acts for itself. Nobody asked for the 06:00 run, so naming a person as its principal was a fiction — and choosing which person was arbitrary, as the first implementation showed by picking whichever test user was to hand. It also cost more than it was worth. An offline token has no exp, dies only after 30 days unused, is not rotated on use, and is revocable only by killing that user's offline session: a permanent bearer credential standing in for a consent nobody gave. Now client_credentials, with the service account carrying read_only — the least privilege the job needs. That is not a convention the workflow observes: the MCP server maps the role to a consumer group at discovery, so tools/list under this identity returns get-product and list-products and nothing else. create-product is invisible rather than merely unused. Nothing downstream changes. azp still names the agent, audiences are still per-server, and policy still reads roles — a service account has roles like anyone else. The mandate client stays in the realm, unused, for the case where a person's own authority genuinely is in play: Recouvrement's dunning, which is human-validated already. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
7.4 KiB
JSON
204 lines
7.4 KiB
JSON
{
|
|
"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": {
|
|
"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-approvisionnement"
|
|
},
|
|
{
|
|
"name": "client_secret",
|
|
"value": "={{ $env.APPROVISIONNEMENT_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\": \"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": [
|
|
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// 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": [
|
|
880,
|
|
0
|
|
]
|
|
}
|
|
],
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
}
|
|
}
|