diff --git a/adolf/openclaw.json b/adolf/openclaw.json index 5bc1528..90d1945 100644 --- a/adolf/openclaw.json +++ b/adolf/openclaw.json @@ -122,10 +122,41 @@ // OpenClaw's documented CLI-native alias for transport: "streamable-http". mcp: { servers: { + // kb#144 (A2A-12): toolFilter.include is OpenClaw's OWN per-server + // tool-scoping mechanism (mcp.servers.*.toolFilter, zod-validated; + // applied in agent-bundle-mcp-materialize.js when THIS container's own + // tool bundle is built). CORRECTION (kb#144 second pass, 2026-07-22): + // this does NOT reach the model on Adolf's kimi backbone -- Kimi CLI + // (inside the separate adolf-llm container) reads its own + // project-root .mcp.json, seeded from openai/shared-mcp.json, and + // applies ITS OWN enabledTools/disabledTools (McpServerCommonFields, + // computeEnabledNames). Live wire.jsonl verification (restart + one + // real turn) proved OpenClaw's toolFilter alone left Kimi's actual + // tool counts unchanged. This block is still correct for OpenClaw's + // own MCP client surface -- see openai/shared-mcp.json for the layer + // that actually scopes what the model sees. + // + // Scoped to Adolf's CORE memory ops: recall/retain/reflect (the + // hooks below do IMPLICIT recall/retain automatically; these MCP + // tools cover explicit "remember this" / "what do you recall about + // X" turns) plus single-memory read/update and directive CRUD + // (standing instructions). Cuts 20 rarely-used/admin tools: the + // mental-model CRUD (7), document CRUD (3), operation-tracking (3), + // tags (1), bank admin (4), sync_retain, invalidate_memory -- none + // of which Adolf's Matrix persona drives turn-to-turn; reach the + // hindsight MCP directly (unscoped) for that admin work instead of + // paying for it on every Adolf turn. 29 tools -> 9. hindsight: { type: "http", url: "http://hindsight:8888/mcp/adolf/", + toolFilter: { + include: ["recall", "retain", "reflect", "list_memories", "get_memory", "update_memory", "list_directives", "create_directive", "delete_directive"], + }, }, + // Already minimal (5 tools -- message_send/cron_create/cron_list/ + // nodes_invoke/browser_invoke, all core to Adolf's matrix-chat + + // cron-scheduling + browser capabilities) -- deliberately NO + // toolFilter here, not an oversight. "openclaw-tools": { type: "http", url: "http://openclaw-tools:8020/mcp", @@ -138,9 +169,18 @@ // instance on :3103 (app-wide jsonrpc token). Not part of the openai // compose network, so reached via host.docker.internal (already // extra_hosts-mapped for this container) rather than a service name. + // kb#144: scoped to task-triage/proactive-monitoring core -- + // list/read/search/create/update/move/status/assign/comment/ + // activity. Cuts subtask CRUD, task-link CRUD, tag CRUD, and + // destructive remove_task/remove_comment (9 tools) -- deep + // task-graph management is claude-coder's job (the actual task + // worker), not Adolf's lighter triage/reporting role. 23 -> 14. kanboard: { type: "http", url: "http://host.docker.internal:3104/mcp", + toolFilter: { + include: ["kanboard_list_projects", "kanboard_get_project", "kanboard_list_tasks", "kanboard_my_tasks", "kanboard_get_task", "kanboard_search_tasks", "kanboard_list_users", "kanboard_project_activity", "kanboard_create_task", "kanboard_update_task", "kanboard_move_task", "kanboard_change_task_status", "kanboard_assign_task", "kanboard_add_comment"], + }, }, // marketplace-mcp (kb task #61) -- the SAME shared marketplace-mcp // instance Claude Code and OpenWebUI use (single service, port 3101, @@ -149,21 +189,48 @@ // shared bearer token (MARKETPLACE_MCP_TOKEN in Vaultwarden / this // container's env, injected via openai/.env -> docker-compose.yml). // Reached via host.docker.internal, same reasoning as kanboard above. + // kb#144: scoped to READ-ONLY discovery (find_best/search/product/ + // recommendations/reviews/compare/status). Cuts the checkout + // surface -- add_to_cart, get_cart, login, open_vnc, save_session, + // submit_sms (6 tools) -- real-money/session-auth actions Adolf + // shouldn't take unattended from background Matrix chat. This + // whole server is also the FIRST candidate to move OUT to Torgash + // (kb#130's marketplace-analyst persona, not yet built) per + // design §2/§8; scoping now both cuts tokens and removes purchase + // risk in the meantime rather than waiting on Torgash to exist. + // 13 -> 7. marketplace: { type: "http", url: "http://host.docker.internal:3101/mcp", headers: { Authorization: "Bearer ${MARKETPLACE_MCP_TOKEN}", }, + toolFilter: { + include: ["marketplace_find_best", "marketplace_search", "marketplace_get_product", "marketplace_get_recommendations", "marketplace_get_reviews", "marketplace_compare_prices", "marketplace_status"], + }, }, // agap-mcp (kb#64) -- the SAME shared agap-mcp instance Claude Code uses // (network_mode: host, :3100, unauthenticated on localhost). Grants Adolf // the same access as Claude: vault (vw_* for credential fetching) plus // gitea/ha/zabbix/radicale. Reached via host.docker.internal like // kanboard/marketplace above. + // kb#144: scoped to Adolf's proactive-auditor/personal-assistant + // core -- vault read/write (trusted, credential help), HA + // (smart-home monitoring), Zabbix (the literal "proactive + // auditor" job), calendar read/write (minus admin + // create/delete_calendar), Todoist. Cuts all 6 gitea_* tools + // (repo/wiki/issue management is claude-coder's infra-ops domain, + // not Adolf's Matrix persona) plus radicale_create_calendar/ + // delete_calendar (rare calendar-admin ops) -- 8 tools. 32 -> 24. + // kb#95: added wiki_search/wiki_read/wiki_edit (family MediaWiki, + // family.alogins.net / РодоВики -- source of truth for relatives, + // dates, events) -- squarely Adolf's persona domain. 35 -> 27. agap: { type: "http", url: "http://host.docker.internal:3100/mcp", + toolFilter: { + include: ["vw_get_password", "vw_get_item", "vw_list_items", "vw_create_login", "vw_update_password", "ha_get_state", "ha_list_entities", "ha_call_service", "ha_get_history", "zabbix_get_problems", "zabbix_get_hosts", "zabbix_get_items", "zabbix_get_triggers", "radicale_list_calendars", "radicale_list_events", "radicale_get_event", "radicale_put_event", "radicale_delete_event", "radicale_move_event", "todoist_list_tasks", "todoist_list_projects", "todoist_create_task", "todoist_update_task", "todoist_complete_task", "wiki_search", "wiki_read", "wiki_edit"], + }, }, }, }, @@ -198,6 +265,21 @@ "quota-command": { enabled: true, }, + // Kimi quota footer (kb #85) — installed external plugin, bind-mounted + // read-only from openai/kimi-quota-footer-plugin (see that project's + // docker-compose.yml adolf.volumes) onto + // .openclaw/extensions/kimi-quota-footer. Appends a compact Kimi + // usage line to every outgoing reply via the reply_payload_sending + // hook (not a raw conversation hook, so no allowConversationAccess/ + // allowPromptInjection opt-in needed), reusing the same LLM-free + // adolf-llm:8010/usage route as quota-command above. Verified this + // hook fires on Adolf's actual send path (sendDurableMessageBatch -> + // deliverOutboundPayloadsInternal) as long as channels.matrix.streaming + // stays unset/"off" as it is today — see the plugin's index.js header + // comment for the streaming caveat if that ever changes. + "kimi-quota-footer": { + enabled: true, + }, }, }, } diff --git a/agap-mcp/.gitignore b/agap-mcp/.gitignore index 4c49bd7..2d7ec5c 100644 --- a/agap-mcp/.gitignore +++ b/agap-mcp/.gitignore @@ -1 +1,2 @@ .env +node_modules/ diff --git a/agap-mcp/docker-compose.yml b/agap-mcp/docker-compose.yml index b33bab8..107eb8b 100644 --- a/agap-mcp/docker-compose.yml +++ b/agap-mcp/docker-compose.yml @@ -21,5 +21,24 @@ services: - ZABBIX_URL=http://192.168.1.4:81 - RADICALE_URL=http://localhost:5232 - RADICALE_USER=alvis + # kb#147 (A2A-15) — vault trust gate, OFF by default (0). Flipping this + # to 1 requires a container restart AND real values for + # AGENT_REGISTRY_PATH/AGAP_MCP_AGENT_TOKENS below to be populated first + # (see src/trust-gate.js) — that restart is the deliberate handover + # step this task does NOT perform (never restart the live agap-mcp + # service unattended). Until both are set, vw_* tools behave exactly + # as before this change. + - AGAP_MCP_ENFORCE_VAULT_TRUST=0 + - AGENT_REGISTRY_PATH=/agent-registry.yaml + # JSON map {"": ""}. Real per-agent tokens must + # be generated, stored in Vaultwarden (e.g. AGAP_MCP_TOKEN_ADOLF, + # AGAP_MCP_TOKEN_CLAUDE_CODER), and referenced here via .env — never + # inlined in this committed file. Empty object = no caller resolves to + # any agent, i.e. fail-closed once ENFORCE is turned on. + - AGAP_MCP_AGENT_TOKENS=${AGAP_MCP_AGENT_TOKENS:-{}} volumes: - /home/alvis/.config/Bitwarden CLI:/bw-data + # Read-only: agent-registry.yaml is the version-controlled source of + # truth for trust classes (kb#134/kb#147) — mounted, never copied, so + # a registry edit takes effect on container restart with no rebuild. + - /home/alvis/agap_git/openai/agent-registry.yaml:/agent-registry.yaml:ro diff --git a/agap-mcp/package-lock.json b/agap-mcp/package-lock.json new file mode 100644 index 0000000..8a4d39b --- /dev/null +++ b/agap-mcp/package-lock.json @@ -0,0 +1,1611 @@ +{ + "name": "agap-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agap-mcp", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "express": "^4.19.0", + "js-yaml": "^4.1.0", + "zod": "^3.23.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/agap-mcp/package.json b/agap-mcp/package.json index 57a42a9..a8a509b 100644 --- a/agap-mcp/package.json +++ b/agap-mcp/package.json @@ -5,6 +5,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "express": "^4.19.0", + "js-yaml": "^4.1.0", "zod": "^3.23.0" } } diff --git a/agap-mcp/src/kanboard.js b/agap-mcp/src/kanboard.js deleted file mode 100644 index f18eb96..0000000 --- a/agap-mcp/src/kanboard.js +++ /dev/null @@ -1,215 +0,0 @@ -const BASE = () => process.env.KANBOARD_URL || 'http://localhost:4800'; -const BOT_USER = () => process.env.KANBOARD_BOT_USER || 'claude'; - -let _token = null; -let _botId = null; - -export function initKanboard(token) { - _token = token; - console.log('Kanboard: ready'); -} - -function token() { - if (!_token) throw new Error('Kanboard not initialized'); - return _token; -} - -// JSON-RPC over the app-wide token (login "jsonrpc:") — full access to all projects. -async function rpc(method, params = {}) { - const auth = Buffer.from(`jsonrpc:${token()}`).toString('base64'); - const res = await fetch(`${BASE()}/jsonrpc.php`, { - method: 'POST', - headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', method, id: 1, params }), - }); - if (!res.ok) throw new Error(`Kanboard ${method}: ${res.status} ${await res.text()}`); - const json = await res.json(); - if (json.error) throw new Error(`Kanboard ${method}: ${json.error.message || JSON.stringify(json.error)}`); - return json.result; -} - -// Resolve + cache the bot user's id (default author for writes, filter for "my tasks"). -async function botUserId() { - if (_botId != null) return _botId; - const user = await rpc('getUserByName', { username: BOT_USER() }); - if (!user || !user.id) throw new Error(`Bot user not found in Kanboard: ${BOT_USER()}`); - _botId = parseInt(user.id); - return _botId; -} - -const STATUS = { open: 1, closed: 0 }; - -// --- Read / monitor --- - -export async function kbListProjects() { - const projects = await rpc('getAllProjects'); - return projects.map(p => ({ - id: parseInt(p.id), name: p.name, is_active: parseInt(p.is_active), - is_private: parseInt(p.is_private), owner_id: parseInt(p.owner_id), - })); -} - -export async function kbGetProject(projectId) { - const [project, columns, swimlanes] = await Promise.all([ - rpc('getProjectById', { project_id: projectId }), - rpc('getColumns', { project_id: projectId }), - rpc('getAllSwimlanes', { project_id: projectId }), - ]); - if (!project) throw new Error(`Project not found: ${projectId}`); - return { - id: parseInt(project.id), name: project.name, description: project.description, - is_active: parseInt(project.is_active), owner_id: parseInt(project.owner_id), - columns: columns.map(c => ({ id: parseInt(c.id), title: c.title, position: parseInt(c.position) })), - swimlanes: swimlanes.map(s => ({ id: parseInt(s.id), name: s.name, is_active: parseInt(s.is_active) })), - }; -} - -function compactTask(t) { - return { - id: parseInt(t.id), title: t.title, project_id: parseInt(t.project_id), - column_id: parseInt(t.column_id), swimlane_id: parseInt(t.swimlane_id), - owner_id: parseInt(t.owner_id), is_active: parseInt(t.is_active), - color_id: t.color_id, priority: parseInt(t.priority || 0), - date_due: parseInt(t.date_due) || 0, reference: t.reference || '', - }; -} - -export async function kbListTasks(projectId, status = 'open') { - let tasks = []; - if (status === 'all') { - const [open, closed] = await Promise.all([ - rpc('getAllTasks', { project_id: projectId, status_id: STATUS.open }), - rpc('getAllTasks', { project_id: projectId, status_id: STATUS.closed }), - ]); - tasks = [...open, ...closed]; - } else { - tasks = await rpc('getAllTasks', { project_id: projectId, status_id: STATUS[status] ?? STATUS.open }); - } - return tasks.map(compactTask); -} - -// Tasks assigned to the bot across every project. status: open | closed | all -export async function kbMyTasks(status = 'open') { - const botId = await botUserId(); - const projects = await rpc('getAllProjects'); - const out = []; - for (const p of projects) { - const tasks = await kbListTasks(parseInt(p.id), status); - for (const t of tasks) { - if (t.owner_id === botId) out.push({ ...t, project_name: p.name }); - } - } - return out; -} - -export async function kbGetTask(taskId) { - const task = await rpc('getTask', { task_id: taskId }); - if (!task) throw new Error(`Task not found: ${taskId}`); - const [subtasks, comments] = await Promise.all([ - rpc('getAllSubtasks', { task_id: taskId }), - rpc('getAllComments', { task_id: taskId }), - ]); - return { - task, - subtasks: (subtasks || []).map(s => ({ - id: parseInt(s.id), title: s.title, status: parseInt(s.status), - status_name: s.status_name, user_id: parseInt(s.user_id) || 0, assignee: s.name || s.username || null, - })), - comments: (comments || []).map(c => ({ - id: parseInt(c.id), user_id: parseInt(c.user_id), author: c.name || c.username, - date: parseInt(c.date_creation), content: c.comment, - })), - }; -} - -export async function kbSearchTasks(projectId, query) { - const tasks = await rpc('searchTasks', { project_id: projectId, query }); - return (tasks || []).map(compactTask); -} - -export async function kbListUsers() { - const users = await rpc('getAllUsers'); - return users.map(u => ({ - id: parseInt(u.id), username: u.username, name: u.name, - role: u.role, is_active: parseInt(u.is_active), email: u.email, - })); -} - -export async function kbProjectActivity(projectId) { - return rpc('getProjectActivity', { project_id: projectId }); -} - -// --- Write (creation/comments authored by the bot user) --- - -export async function kbCreateTask(params) { - const body = { ...params }; - if (body.creator_id == null) body.creator_id = await botUserId(); - const id = await rpc('createTask', body); - if (!id) throw new Error('createTask returned false (check required fields: title, project_id)'); - return { task_id: parseInt(id) }; -} - -export async function kbUpdateTask(params) { - const ok = await rpc('updateTask', params); - return { updated: !!ok }; -} - -// Move a card. project_id and swimlane_id are resolved from the task when omitted. -export async function kbMoveTask({ task_id, column_id, position = 1, swimlane_id, project_id }) { - if (project_id == null || swimlane_id == null) { - const task = await rpc('getTask', { task_id }); - if (!task) throw new Error(`Task not found: ${task_id}`); - if (project_id == null) project_id = parseInt(task.project_id); - if (swimlane_id == null) swimlane_id = parseInt(task.swimlane_id); - } - const ok = await rpc('moveTaskPosition', { project_id, task_id, column_id, position, swimlane_id }); - return { moved: !!ok }; -} - -export async function kbChangeTaskStatus(taskId, action) { - const method = action === 'close' ? 'closeTask' : 'openTask'; - const ok = await rpc(method, { task_id: taskId }); - return { [action === 'close' ? 'closed' : 'opened']: !!ok }; -} - -// Assign a task to a user (username or numeric id). -export async function kbAssignTask(taskId, owner) { - let ownerId = owner; - if (typeof owner === 'string' && !/^\d+$/.test(owner)) { - const user = await rpc('getUserByName', { username: owner }); - if (!user || !user.id) throw new Error(`User not found: ${owner}`); - ownerId = parseInt(user.id); - } - const ok = await rpc('updateTask', { id: taskId, owner_id: parseInt(ownerId) }); - return { assigned: !!ok, owner_id: parseInt(ownerId) }; -} - -export async function kbAddComment(taskId, content, userId) { - const uid = userId != null ? userId : await botUserId(); - const id = await rpc('createComment', { task_id: taskId, user_id: uid, content }); - if (!id) throw new Error('createComment returned false'); - return { comment_id: parseInt(id) }; -} - -export async function kbCreateSubtask(params) { - const body = { ...params }; - if (body.user_id == null) body.user_id = await botUserId(); - const id = await rpc('createSubtask', body); - if (!id) throw new Error('createSubtask returned false (required: task_id, title)'); - return { subtask_id: parseInt(id) }; -} - -export async function kbUpdateSubtask(params) { - const ok = await rpc('updateSubtask', params); - return { updated: !!ok }; -} - -export async function kbRemoveTask(taskId) { - const ok = await rpc('removeTask', { task_id: taskId }); - return { removed: !!ok }; -} - -export async function kbRemoveComment(commentId) { - const ok = await rpc('removeComment', { comment_id: commentId }); - return { removed: !!ok }; -} diff --git a/agap-mcp/src/mediawiki.js b/agap-mcp/src/mediawiki.js new file mode 100644 index 0000000..68d1043 --- /dev/null +++ b/agap-mcp/src/mediawiki.js @@ -0,0 +1,160 @@ +// MediaWiki (РодоВики family wiki) tools for agap-mcp — kb#95. +// Target is family.alogins.net (МediaWiki), NOT Gitea. Follows the exact +// login->edit flow documented in the `rodowiki` skill: login token -> +// action=login -> session cookie -> CSRF token -> read/search/edit. Read +// operations also require login ($wgGroupPermissions['*']['read'] = false). +// +// Credentials: Vaultwarden org item "family.alogins.net" (username field +// holds the wiki account name "Claude", password field holds its password), +// fetched once at init like every other agap-mcp integration. +// +// Session handling: Node's fetch does not auto-manage cookies like curl's +// --cookie-jar, so a tiny module-level cookie jar (Map) is kept here and +// reused across calls -- this module is a singleton within the process, so +// one login session serves every MCP request for the process lifetime. +// withAuth() retries once on an auth-shaped failure (expired session) by +// forcing a fresh login, mirroring how a human re-running the skill's shell +// snippet would just log in again. + +const BASE = () => (process.env.MEDIAWIKI_URL || 'http://localhost:8099').replace(/\/$/, ''); + +let _username = null; +let _password = null; +let _cookies = new Map(); +let _loggedIn = false; + +export function initMediaWiki(username, password) { + _username = username; + _password = password; + console.log('MediaWiki: ready'); +} + +function cookieHeader() { + return [..._cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); +} + +function storeCookies(res) { + const setCookies = + typeof res.headers.getSetCookie === 'function' + ? res.headers.getSetCookie() + : res.headers.get('set-cookie') + ? [res.headers.get('set-cookie')] + : []; + for (const sc of setCookies) { + const pair = sc.split(';')[0]; + const idx = pair.indexOf('='); + if (idx > -1) _cookies.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim()); + } +} + +async function api(params, { method = 'GET' } = {}) { + const url = new URL(`${BASE()}/api.php`); + const headers = { Cookie: cookieHeader() }; + let body; + if (method === 'GET') { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } else { + body = new URLSearchParams(params); + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + const res = await fetch(url, { method, headers, body }); + storeCookies(res); + const data = await res.json(); + if (data.error) throw new Error(`MediaWiki API error: ${data.error.info || JSON.stringify(data.error)}`); + return data; +} + +async function login() { + if (!_username || !_password) throw new Error('MediaWiki not initialized'); + const tokenData = await api({ action: 'query', meta: 'tokens', type: 'login', format: 'json' }); + const logintoken = tokenData.query.tokens.logintoken; + const loginData = await api( + { action: 'login', lgname: _username, lgpassword: _password, lgtoken: logintoken, format: 'json' }, + { method: 'POST' } + ); + if (loginData.login?.result !== 'Success') { + throw new Error(`MediaWiki login failed: ${loginData.login?.result || JSON.stringify(loginData)}`); + } +} + +async function withAuth(fn) { + if (!_loggedIn) { + await login(); + _loggedIn = true; + } + try { + return await fn(); + } catch (e) { + // Session likely expired mid-process: force a fresh login and retry once. + if (/notloggedin|readapidenied|permissiondenied|mustbeloggedin/i.test(e.message)) { + _loggedIn = false; + await login(); + _loggedIn = true; + return await fn(); + } + throw e; + } +} + +function stripHtml(s) { + return (s || '').replace(/<[^>]+>/g, ''); +} + +export async function wikiSearch(query, limit = 20) { + if (!query || !query.trim()) throw new Error('query is required'); + return withAuth(async () => { + const data = await api({ + action: 'query', + list: 'search', + srsearch: query, + srlimit: String(limit), + format: 'json', + }); + return data.query.search.map((p) => ({ + title: p.title, + snippet: stripHtml(p.snippet), + wordcount: p.wordcount, + })); + }); +} + +export async function wikiRead(title) { + if (!title || !title.trim()) throw new Error('title is required'); + return withAuth(async () => { + const data = await api({ + action: 'query', + titles: title, + prop: 'revisions', + rvprop: 'content', + rvslots: 'main', + format: 'json', + }); + const page = Object.values(data.query.pages)[0]; + if (page.missing !== undefined) throw new Error(`Page not found: ${title}`); + return page.revisions?.[0]?.slots?.main?.['*'] ?? ''; + }); +} + +export async function wikiEdit(title, text, summary) { + if (!title || !title.trim()) throw new Error('title is required'); + if (text === undefined || text === null) throw new Error('text is required'); + return withAuth(async () => { + const tokenData = await api({ action: 'query', meta: 'tokens', format: 'json' }); + const csrftoken = tokenData.query.tokens.csrftoken; + const editData = await api( + { + action: 'edit', + title, + text, + summary: summary || `Update ${title}`, + token: csrftoken, + format: 'json', + }, + { method: 'POST' } + ); + if (editData.edit?.result !== 'Success') { + throw new Error(`MediaWiki edit failed: ${JSON.stringify(editData.edit || editData)}`); + } + return { title, result: editData.edit.result, newrevid: editData.edit.newrevid, oldrevid: editData.edit.oldrevid }; + }); +} diff --git a/agap-mcp/src/server.js b/agap-mcp/src/server.js index 24dbe98..40f6a65 100644 --- a/agap-mcp/src/server.js +++ b/agap-mcp/src/server.js @@ -11,10 +11,34 @@ import { initGitea, giteaListRepos, giteaReadFile, giteaWikiList, giteaWikiRead, import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from './homeassistant.js'; import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js'; import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js'; -import { initKanboard, kbListProjects, kbGetProject, kbListTasks, kbMyTasks, kbGetTask, kbSearchTasks, kbListUsers, kbProjectActivity, kbCreateTask, kbUpdateTask, kbMoveTask, kbChangeTaskStatus, kbAssignTask, kbAddComment, kbCreateSubtask, kbUpdateSubtask, kbRemoveTask, kbRemoveComment } from './kanboard.js'; +import { initTodoist, todoistListTasks, todoistListProjects, todoistCreateTask, todoistUpdateTask, todoistCompleteTask } from './todoist.js'; +import { initMediaWiki, wikiSearch, wikiRead, wikiEdit } from './mediawiki.js'; +import { loadTokenMap, resolveCallerAgent, vaultAllowed, authHeaderToken } from './trust-gate.js'; const PORT = parseInt(process.env.PORT || '3100'); +// --- kb#147: vault trust gate (A2A-15) --------------------------------- +// OFF by default (ENFORCE_VAULT_TRUST=false) so merging this file changes +// NOTHING about the live service's behavior until an operator deliberately +// sets AGAP_MCP_ENFORCE_VAULT_TRUST=1 AND populates AGAP_MCP_AGENT_TOKENS +// with real per-agent bearer tokens (stored in Vaultwarden, injected via +// this container's .env — never committed to git). That two-step +// activation is the kb#147 handover: it requires a docker-compose env +// change + container restart, which this task deliberately does not do +// (see DESIGN-a2a-agents.md v2.1 §5 — vault access = trusted only). +const ENFORCE_VAULT_TRUST = process.env.AGAP_MCP_ENFORCE_VAULT_TRUST === '1'; +const AGENT_TOKENS = loadTokenMap(); + +function requireVaultAccess(callerAgentId) { + if (!ENFORCE_VAULT_TRUST) return; // legacy behavior: unchanged until activated + if (!vaultAllowed(callerAgentId)) { + throw new Error( + `vault access denied: caller ${callerAgentId ? `'${callerAgentId}'` : '(unauthenticated)'} ` + + `is not trust_class >= trusted (kb#147, DESIGN-a2a-agents.md §5)` + ); + } +} + // --- Init services --- async function init() { await initVaultwarden(); @@ -31,13 +55,19 @@ async function init() { const ha_token = orgToken('HA_TOKEN'); const zabbix_token = orgToken('ZABBIX_TOKEN'); const radicale_password = orgToken('RADICALE_PASSWORD'); - const kanboard_token = orgToken('KANBOARD_TOKEN'); + const todoist_token = orgToken('TODOIST_TOKEN'); + + // family.alogins.net (РодоВики MediaWiki) — a login item, not a bare + // token: username + password both needed for the login flow. + const wikiItem = orgItems.find(i => i.name === 'family.alogins.net'); + if (!wikiItem) throw new Error('Token not found in org: family.alogins.net'); initGitea(gitea_token); initHA(ha_token); initZabbix(zabbix_token); initRadicale(radicale_password); - initKanboard(kanboard_token); + initTodoist(todoist_token); + initMediaWiki(wikiItem.login?.username, wikiItem.login?.password); } @@ -50,18 +80,19 @@ function err(e) { return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }; } -function createServer() { +function createServer(callerAgentId = null) { const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' }); -// --- Vaultwarden tools --- +// --- Vaultwarden tools (kb#147: gated to trust_class >= trusted) --- server.tool('vw_get_password', 'Get password for a Vaultwarden item by name', { name: z.string() }, async ({ name }) => { - try { return ok(vwGetPassword(name)); } catch (e) { return err(e); } + try { requireVaultAccess(callerAgentId); return ok(vwGetPassword(name)); } catch (e) { return err(e); } }); server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes)', { name: z.string() }, async ({ name }) => { try { + requireVaultAccess(callerAgentId); const item = vwGetItem(name); return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes }); } catch (e) { return err(e); } @@ -72,6 +103,7 @@ function createServer() { org: z.boolean().optional(), }, async ({ search, org }) => { try { + requireVaultAccess(callerAgentId); const items = org ? vwListOrgItems(search) : vwListItems(search); return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri }))); } catch (e) { return err(e); } @@ -84,14 +116,14 @@ function createServer() { url: z.string().optional(), notes: z.string().optional(), }, async (args) => { - try { return ok(vwCreateLogin(args)); } catch (e) { return err(e); } + try { requireVaultAccess(callerAgentId); return ok(vwCreateLogin(args)); } catch (e) { return err(e); } }); server.tool('vw_update_password', 'Update the password of an existing Vaultwarden item', { name: z.string().describe('Item name or ID'), password: z.string(), }, async ({ name, password }) => { - try { return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); } + try { requireVaultAccess(callerAgentId); return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); } }); // --- Gitea tools --- @@ -253,144 +285,67 @@ function createServer() { try { return ok(await radicaleMoveEvent({ fromCalendar: from_calendar, toCalendar: to_calendar, filename, user })); } catch (e) { return err(e); } }); -// --- Kanboard tools --- - server.tool('kanboard_list_projects', 'List all Kanboard projects (id, name, active, owner)', {}, - async () => { - try { return ok(await kbListProjects()); } catch (e) { return err(e); } + // --- Todoist tools (Todoist API v1) --- + server.tool('todoist_list_tasks', 'List active Todoist tasks. Use `query` for a Todoist filter (e.g. "today", "overdue", "#Work & p1"), or `project_id` to scope to one project. Returns compact tasks.', { + query: z.string().optional().describe('Todoist filter query, e.g. "today", "overdue", "#Project & p1". Omit for all active tasks.'), + project_id: z.string().optional().describe('Restrict to a project id (from todoist_list_projects). Ignored when query is set.'), + }, async ({ query, project_id }) => { + try { return ok(await todoistListTasks({ query, project_id })); } catch (e) { return err(e); } }); - server.tool('kanboard_get_project', 'Get a Kanboard project with its columns and swimlanes (needed to move cards)', { - project_id: z.number().describe('Project ID'), -}, async ({ project_id }) => { - try { return ok(await kbGetProject(project_id)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_list_tasks', 'List tasks in a Kanboard project', { - project_id: z.number(), - status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'), -}, async ({ project_id, status }) => { - try { return ok(await kbListTasks(project_id, status)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_my_tasks', "List tasks assigned to the bot user (claude) across all projects", { - status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'), -}, async ({ status }) => { - try { return ok(await kbMyTasks(status)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_get_task', 'Get full detail of a Kanboard task, including its subtasks and comments', { - task_id: z.number(), -}, async ({ task_id }) => { - try { return ok(await kbGetTask(task_id)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_search_tasks', 'Search tasks in a project using Kanboard query syntax (e.g. "assignee:claude status:open", "due:today", "color:red")', { - project_id: z.number(), - query: z.string(), -}, async ({ project_id, query }) => { - try { return ok(await kbSearchTasks(project_id, query)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_list_users', 'List all Kanboard users (id, username, name, role)', {}, - async () => { - try { return ok(await kbListUsers()); } catch (e) { return err(e); } + server.tool('todoist_list_projects', 'List Todoist projects (id + name).', {}, async () => { + try { return ok(await todoistListProjects()); } catch (e) { return err(e); } }); - server.tool('kanboard_project_activity', 'Get the recent activity stream for a project (who did what)', { - project_id: z.number(), -}, async ({ project_id }) => { - try { return ok(await kbProjectActivity(project_id)); } catch (e) { return err(e); } -}); + server.tool('todoist_create_task', 'Create a Todoist task. `due_string` is natural language ("tomorrow 9am", "every monday"). `priority`: API 1=normal..4=urgent (note: Todoist UI p1 = API 4). Defaults to the Inbox project.', { + content: z.string().describe('Task title/content (required).'), + description: z.string().optional(), + due_string: z.string().optional().describe('Natural-language due date, e.g. "today", "tomorrow 9am", "next monday".'), + priority: z.number().int().min(1).max(4).optional().describe('API priority 1=normal .. 4=urgent.'), + project_id: z.string().optional().describe('Target project id (default Inbox).'), + labels: z.array(z.string()).optional(), + }, async (args) => { + try { return ok(await todoistCreateTask(args)); } catch (e) { return err(e); } + }); - server.tool('kanboard_create_task', 'Create a Kanboard task (authored by claude). At minimum provide title and project_id', { - title: z.string(), - project_id: z.number(), - description: z.string().optional().describe('Markdown'), - column_id: z.number().optional(), - owner_id: z.number().optional().describe('Assignee user id'), - color_id: z.string().optional().describe('e.g. yellow, blue, red, green'), - date_due: z.string().optional().describe('YYYY-MM-DD'), - priority: z.number().optional(), - swimlane_id: z.number().optional(), -}, async (args) => { - try { return ok(await kbCreateTask(args)); } catch (e) { return err(e); } -}); + server.tool('todoist_update_task', 'Update a Todoist task (reschedule/edit). Give the task id plus the fields to change.', { + id: z.string(), + content: z.string().optional(), + description: z.string().optional(), + due_string: z.string().optional(), + priority: z.number().int().min(1).max(4).optional(), + labels: z.array(z.string()).optional(), + }, async (args) => { + try { return ok(await todoistUpdateTask(args)); } catch (e) { return err(e); } + }); - server.tool('kanboard_update_task', 'Update fields of a Kanboard task', { - id: z.number().describe('Task id'), - title: z.string().optional(), - description: z.string().optional(), - owner_id: z.number().optional(), - color_id: z.string().optional(), - date_due: z.string().optional().describe('YYYY-MM-DD'), - priority: z.number().optional(), - category_id: z.number().optional(), -}, async (args) => { - try { return ok(await kbUpdateTask(args)); } catch (e) { return err(e); } -}); + server.tool('todoist_complete_task', 'Complete (close) a Todoist task by id.', { + id: z.string().describe('Task id from todoist_list_tasks.'), + }, async ({ id }) => { + try { return ok(await todoistCompleteTask({ id })); } catch (e) { return err(e); } + }); - server.tool('kanboard_move_task', 'Move a task to a column/position (e.g. to "Work in progress" or "Done"). project_id and swimlane_id are auto-resolved if omitted', { - task_id: z.number(), - column_id: z.number().describe('Target column id (see kanboard_get_project)'), - position: z.number().optional().describe('default 1 (top)'), - swimlane_id: z.number().optional(), - project_id: z.number().optional(), -}, async (args) => { - try { return ok(await kbMoveTask(args)); } catch (e) { return err(e); } -}); + // --- MediaWiki (family wiki / РодоВики) tools --- + server.tool('wiki_search', 'Search the family wiki (РодоВики) for pages matching a query. Returns title + snippet.', { + query: z.string().describe('Search text, e.g. a person\'s name or event.'), + limit: z.number().int().min(1).max(50).optional().describe('Max results, default 20.'), + }, async ({ query, limit }) => { + try { return ok(await wikiSearch(query, limit)); } catch (e) { return err(e); } + }); - server.tool('kanboard_change_task_status', 'Open or close (mark done/archive) a task', { - task_id: z.number(), - action: z.enum(['open', 'close']), -}, async ({ task_id, action }) => { - try { return ok(await kbChangeTaskStatus(task_id, action)); } catch (e) { return err(e); } -}); + server.tool('wiki_read', 'Read the raw wikitext content of a family wiki page by exact title.', { + title: z.string().describe('Exact page title, e.g. "Антон Логинс".'), + }, async ({ title }) => { + try { return ok(await wikiRead(title)); } catch (e) { return err(e); } + }); - server.tool('kanboard_assign_task', 'Assign a task to a user (username or numeric id). Use "claude" to take it', { - task_id: z.number(), - owner: z.union([z.string(), z.number()]).describe('Username or user id'), -}, async ({ task_id, owner }) => { - try { return ok(await kbAssignTask(task_id, owner)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_add_comment', 'Add a comment to a task (authored by claude by default)', { - task_id: z.number(), - content: z.string().describe('Markdown'), - user_id: z.number().optional().describe('Override author; defaults to claude'), -}, async ({ task_id, content, user_id }) => { - try { return ok(await kbAddComment(task_id, content, user_id)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_create_subtask', 'Add a subtask to a task (good for tracking execution steps)', { - task_id: z.number(), - title: z.string(), - user_id: z.number().optional().describe('Assignee; defaults to claude'), - status: z.number().optional().describe('0=todo, 1=in progress, 2=done'), -}, async (args) => { - try { return ok(await kbCreateSubtask(args)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_update_subtask', 'Update a subtask (e.g. mark in-progress or done)', { - id: z.number().describe('Subtask id'), - task_id: z.number(), - title: z.string().optional(), - status: z.number().optional().describe('0=todo, 1=in progress, 2=done'), - user_id: z.number().optional(), -}, async (args) => { - try { return ok(await kbUpdateSubtask(args)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_remove_task', 'Permanently delete a task (irreversible)', { - task_id: z.number(), -}, async ({ task_id }) => { - try { return ok(await kbRemoveTask(task_id)); } catch (e) { return err(e); } -}); - - server.tool('kanboard_remove_comment', 'Permanently delete a comment (irreversible)', { - comment_id: z.number(), -}, async ({ comment_id }) => { - try { return ok(await kbRemoveComment(comment_id)); } catch (e) { return err(e); } -}); + server.tool('wiki_edit', 'Create or update a family wiki page. Overwrites the page with the given wikitext.', { + title: z.string().describe('Exact page title to create/update.'), + text: z.string().describe('Full wikitext content of the page.'), + summary: z.string().optional().describe('Edit summary, default "Update ".'), + }, async ({ title, text, summary }) => { + try { return ok(await wikiEdit(title, text, summary)); } catch (e) { return err(e); } + }); return server; } @@ -404,9 +359,10 @@ const sseTransports = new Map(); // Streamable HTTP — stateless: fresh server per request, survives container restarts app.all('/mcp', async (req, res) => { try { + const callerAgentId = resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => transport.close()); - await createServer().connect(transport); + await createServer(callerAgentId).connect(transport); await transport.handleRequest(req, res, req.body); } catch (e) { console.error('MCP request error:', e.message); @@ -418,10 +374,11 @@ app.all('/mcp', async (req, res) => { // Legacy SSE — kept for backward compatibility app.get('/sse', async (req, res) => { + const callerAgentId = resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS); const transport = new SSEServerTransport('/messages', res); sseTransports.set(transport.sessionId, transport); res.on('close', () => sseTransports.delete(transport.sessionId)); - await createServer().connect(transport); + await createServer(callerAgentId).connect(transport); }); app.post('/messages', async (req, res) => { @@ -430,7 +387,7 @@ app.post('/messages', async (req, res) => { await transport.handlePostMessage(req, res); }); -app.get('/health', (_, res) => res.json({ status: 'ok', tools: 45 })); +app.get('/health', (_, res) => res.json({ status: 'ok', tools: 30, vaultTrustEnforced: ENFORCE_VAULT_TRUST })); init() .then(() => { diff --git a/agap-mcp/src/todoist.js b/agap-mcp/src/todoist.js new file mode 100644 index 0000000..15f3c85 --- /dev/null +++ b/agap-mcp/src/todoist.js @@ -0,0 +1,101 @@ +// Todoist tools for agap-mcp (Adolf + Claude). Uses the unified Todoist API v1 +// (https://api.todoist.com/api/v1) — REST v2 / Sync v9 are deprecated (410). +// Bearer auth with a personal API token (Vaultwarden: TODOIST_TOKEN), injected +// at init like the other services. Deliberately a SMALL, curated tool surface: +// every tool schema here is re-sent to Kimi on every Adolf turn (kb#101), so we +// expose only the essentials and return compact task objects. + +const BASE = 'https://api.todoist.com/api/v1'; + +let _token = null; + +export function initTodoist(token) { + _token = token; + console.log('Todoist: ready'); +} + +async function api(method, path, body) { + if (!_token) throw new Error('Todoist not initialized'); + const res = await fetch(`${BASE}${path}`, { + method, + headers: { + Authorization: `Bearer ${_token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`Todoist ${method} ${path}: ${res.status} ${text.slice(0, 300)}`); + return text ? JSON.parse(text) : null; +} + +// Keep tool output (and thus Kimi context) small — return only fields an agent +// needs to reason about or act on, not the full Todoist task object. +function slimTask(t) { + if (!t || typeof t !== 'object') return t; + return { + id: t.id, + content: t.content, + project_id: t.project_id, + priority: t.priority, // API 1=normal .. 4=urgent (inverse of the Todoist UI's p1..p4) + due: t.due?.string || t.due?.date || null, + labels: t.labels && t.labels.length ? t.labels : undefined, + }; +} + +function results(data) { + if (Array.isArray(data?.results)) return data.results; + return Array.isArray(data) ? data : []; +} + +// List tasks — either a Todoist filter query (e.g. "today", "overdue", +// "#Work & p1") via the /tasks/filter endpoint, or all active tasks optionally +// scoped to one project. +export async function todoistListTasks({ query, project_id } = {}) { + let path; + if (query && query.trim()) { + path = `/tasks/filter?query=${encodeURIComponent(query.trim())}`; + } else if (project_id) { + path = `/tasks?project_id=${encodeURIComponent(project_id)}`; + } else { + path = '/tasks'; + } + return results(await api('GET', path)).map(slimTask); +} + +export async function todoistListProjects() { + return results(await api('GET', '/projects')).map((p) => ({ + id: p.id, + name: p.name, + is_favorite: p.is_favorite || undefined, + is_inbox: p.inbox_project || p.is_inbox_project || undefined, + })); +} + +export async function todoistCreateTask({ content, description, due_string, priority, project_id, labels } = {}) { + if (!content || !content.trim()) throw new Error('content is required'); + const body = { content: content.trim() }; + if (description) body.description = description; + if (due_string) body.due_string = due_string; + if (priority) body.priority = priority; + if (project_id) body.project_id = project_id; + if (Array.isArray(labels) && labels.length) body.labels = labels; + return slimTask(await api('POST', '/tasks', body)); +} + +export async function todoistUpdateTask({ id, content, description, due_string, priority, labels } = {}) { + if (!id) throw new Error('id is required'); + const body = {}; + if (content !== undefined) body.content = content; + if (description !== undefined) body.description = description; + if (due_string !== undefined) body.due_string = due_string; + if (priority !== undefined) body.priority = priority; + if (labels !== undefined) body.labels = labels; + return slimTask(await api('POST', `/tasks/${encodeURIComponent(id)}`, body)); +} + +export async function todoistCompleteTask({ id } = {}) { + if (!id) throw new Error('id is required'); + await api('POST', `/tasks/${encodeURIComponent(id)}/close`); + return { id, completed: true }; +} diff --git a/agap-mcp/src/trust-gate-http.test.mjs b/agap-mcp/src/trust-gate-http.test.mjs new file mode 100644 index 0000000..9fa4172 --- /dev/null +++ b/agap-mcp/src/trust-gate-http.test.mjs @@ -0,0 +1,103 @@ +// kb#147 HTTP-layer proof, run with: node src/trust-gate-http.test.mjs +// +// Proves the Authorization-header -> agent-id -> trust-rank path end to end +// over real HTTP, WITHOUT touching the live agap-mcp container (:3100), +// LiteLLM, or Vaultwarden: this spins up a throwaway express app on an +// ephemeral local port using the exact same trust-gate.js functions +// server.js imports, with a synthetic registry + token map (no real bw +// session, no real credentials). It exercises authHeaderToken() (the bit +// trust-gate.test.mjs's pure unit tests can't reach, since it needs a real +// `req` object) on top of the already-unit-tested trustRankOf/vaultAllowed. + +import assert from 'node:assert/strict'; +import express from 'express'; +import { + loadTokenMap, + resolveCallerAgent, + vaultAllowed, + authHeaderToken, + isVaultTool, + _resetRegistryCacheForTests, +} from './trust-gate.js'; + +const registry = { + trust_classes: { + trusted: { rank: 2 }, + sandboxed: { rank: 1 }, + untrusted: { rank: 0 }, + }, + agents: [ + { id: 'adolf', trust_class: 'trusted' }, + { id: 'torgash', trust_class: 'sandboxed' }, + ], +}; +_resetRegistryCacheForTests(registry); + +const tokenMap = loadTokenMap(JSON.stringify({ + 'tok-adolf-e2e-test': 'adolf', + 'tok-torgash-e2e-test': 'torgash', +})); + +// A minimal stand-in for server.js's app.all('/mcp', ...) handler: resolve +// the caller from the Authorization header, then simulate a vw_get_password +// tool call gated the same way requireVaultAccess() gates it in server.js. +const app = express(); +app.post('/mcp', (req, res) => { + const callerAgentId = resolveCallerAgent(authHeaderToken(req), tokenMap); + const toolName = req.body?.tool || 'vw_get_password'; + if (isVaultTool(toolName) && !vaultAllowed(callerAgentId, registry)) { + return res.status(200).json({ isError: true, error: `vault access denied for caller=${callerAgentId || '(none)'}` }); + } + return res.status(200).json({ isError: false, caller: callerAgentId }); +}); + +const server = app.listen(0); +const port = server.address().port; + +async function post(token) { + const res = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ tool: 'vw_get_password' }), + }); + return res.json(); +} + +let passed = 0; +async function check(label, fn) { + await fn(); + passed++; + console.log(`ok - ${label}`); +} + +try { + await check('trusted agent (adolf) bearer token -> vw_get_password allowed over real HTTP', async () => { + const body = await post('tok-adolf-e2e-test'); + assert.equal(body.isError, false); + assert.equal(body.caller, 'adolf'); + }); + + await check('sandboxed agent (torgash) bearer token -> vw_get_password DENIED over real HTTP', async () => { + const body = await post('tok-torgash-e2e-test'); + assert.equal(body.isError, true); + assert.match(body.error, /vault access denied/); + }); + + await check('no Authorization header at all -> vw_get_password DENIED over real HTTP', async () => { + const body = await post(null); + assert.equal(body.isError, true); + }); + + await check('garbage/unknown token -> vw_get_password DENIED over real HTTP', async () => { + const body = await post('this-token-was-never-issued'); + assert.equal(body.isError, true); + }); + + console.log(`\n${passed} passed`); +} finally { + server.close(); + _resetRegistryCacheForTests(null); +} diff --git a/agap-mcp/src/trust-gate.js b/agap-mcp/src/trust-gate.js new file mode 100644 index 0000000..bffdcb0 --- /dev/null +++ b/agap-mcp/src/trust-gate.js @@ -0,0 +1,91 @@ +// trust-gate — kb#147 (A2A-15): vault access = trusted-only, enforced here. +// +// agap-mcp has no per-caller identity today (every MCP client hits the same +// unauthenticated /mcp endpoint on :3100) — that's the gap DESIGN-a2a-agents.md +// v2.1 §5 flags as the crux of "vault access = trusted only" (DECIDED, alvis). +// This module is the enforcement point: it maps a bearer token off the request +// to an agent id (via AGAP_MCP_AGENT_TOKENS, a secret never committed to git), +// then looks up that agent's trust class in agent-registry.yaml (the +// version-controlled source of truth for grants, kb#134) to decide whether +// vault tools (vw_*) may run. +// +// FAIL-CLOSED PRINCIPLE (once enforcement is turned on): no token, an +// unrecognized token, or an agent below `trusted` rank all resolve to "no +// vault access" — there is no default-allow path once AGAP_MCP_ENFORCE_VAULT_TRUST +// is on. See server.js for the off-by-default activation gate: merging this +// module changes zero live behavior until an operator deliberately flips that +// flag AND supplies real per-agent tokens (kb#147 handover step). + +import { readFileSync } from 'fs'; +import yaml from 'js-yaml'; + +const VAULT_TOOL_PREFIX = 'vw_'; +const DEFAULT_REGISTRY_PATH = '/agent-registry.yaml'; +const DEFAULT_TRUSTED_RANK = 2; // matches agent-registry.yaml trust_classes.trusted.rank; used only if the registry can't be read + +let _registryCache = null; + +export function loadRegistry(path = process.env.AGENT_REGISTRY_PATH || DEFAULT_REGISTRY_PATH) { + if (_registryCache) return _registryCache; + try { + _registryCache = yaml.load(readFileSync(path, 'utf8')); + } catch (e) { + // Fail closed, not fail crash: no registry readable means no agent can be + // proven trusted, so vaultAllowed() below returns false for everyone + // rather than the process refusing to start (agap-mcp serves gitea/ha/ + // zabbix/radicale/todoist tools too, which don't depend on this file). + console.error(`trust-gate: could not load agent registry from ${path}: ${e.message}`); + _registryCache = { agents: [], trust_classes: {} }; + } + return _registryCache; +} + +// Test-only: let unit tests inject a registry object instead of touching the +// filesystem, and let the CLI/tests reset the module-level cache between runs. +export function _resetRegistryCacheForTests(registry = null) { + _registryCache = registry; +} + +export function trustedRankThreshold(registry = loadRegistry()) { + return registry.trust_classes?.trusted?.rank ?? DEFAULT_TRUSTED_RANK; +} + +export function trustRankOf(agentId, registry = loadRegistry()) { + if (!agentId) return -1; // unauthenticated caller: rank below every real trust class + const agent = (registry.agents || []).find(a => a.id === agentId); + if (!agent) return -1; // unknown agent id: fail closed, not "assume trusted" + const cls = registry.trust_classes?.[agent.trust_class]; + return cls ? cls.rank : -1; +} + +// tokenMap: { "<bearer-token>": "<agent-id>" } — parsed once at startup from +// AGAP_MCP_AGENT_TOKENS (JSON), itself sourced from per-agent tokens stored in +// Vaultwarden and injected via this container's .env, never inlined in git. +export function loadTokenMap(raw = process.env.AGAP_MCP_AGENT_TOKENS) { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return (parsed && typeof parsed === 'object') ? parsed : {}; + } catch (e) { + console.error(`trust-gate: AGAP_MCP_AGENT_TOKENS is not valid JSON: ${e.message}`); + return {}; + } +} + +export function resolveCallerAgent(bearerToken, tokenMap) { + if (!bearerToken) return null; + return tokenMap[bearerToken] || null; +} + +export function isVaultTool(toolName) { + return toolName.startsWith(VAULT_TOOL_PREFIX); +} + +export function vaultAllowed(agentId, registry = loadRegistry()) { + return trustRankOf(agentId, registry) >= trustedRankThreshold(registry); +} + +export function authHeaderToken(req) { + const header = req.headers?.['authorization'] || req.headers?.['Authorization'] || ''; + return header.startsWith('Bearer ') ? header.slice(7).trim() : null; +} diff --git a/agap-mcp/src/trust-gate.test.mjs b/agap-mcp/src/trust-gate.test.mjs new file mode 100644 index 0000000..2723f84 --- /dev/null +++ b/agap-mcp/src/trust-gate.test.mjs @@ -0,0 +1,101 @@ +// Proof-of-enforcement for kb#147, run with: node src/trust-gate.test.mjs +// +// Deliberately does NOT touch the live agap-mcp container, LiteLLM, or +// Vaultwarden — it exercises the exact exported functions server.js calls +// (trustRankOf/vaultAllowed/resolveCallerAgent/isVaultTool) against a +// synthetic registry + token map, so this is a real test of the enforcement +// logic itself, not a mock of it. + +import assert from 'node:assert/strict'; +import { + trustRankOf, + vaultAllowed, + resolveCallerAgent, + isVaultTool, + trustedRankThreshold, + _resetRegistryCacheForTests, +} from './trust-gate.js'; + +const registry = { + trust_classes: { + human: { rank: 3 }, + trusted: { rank: 2 }, + sandboxed: { rank: 1 }, + untrusted: { rank: 0 }, + }, + agents: [ + { id: 'adolf', trust_class: 'trusted' }, + { id: 'claude-coder', trust_class: 'trusted' }, + { id: 'torgash', trust_class: 'sandboxed' }, + { id: 'researcher', trust_class: 'sandboxed' }, + { id: 'kimi-endpoint', trust_class: 'untrusted' }, + ], +}; + +const tokenMap = { + 'tok-adolf': 'adolf', + 'tok-claude-coder': 'claude-coder', + 'tok-torgash': 'torgash', + 'tok-researcher': 'researcher', +}; + +let passed = 0; +function check(label, fn) { + fn(); + passed++; + console.log(`ok - ${label}`); +} + +check('trusted rank threshold resolves from registry', () => { + assert.equal(trustedRankThreshold(registry), 2); +}); + +check('trusted agents (adolf, claude-coder) can reach vault', () => { + assert.equal(vaultAllowed('adolf', registry), true); + assert.equal(vaultAllowed('claude-coder', registry), true); +}); + +check('sandboxed agents (torgash, researcher) CANNOT reach vault', () => { + assert.equal(vaultAllowed('torgash', registry), false); + assert.equal(vaultAllowed('researcher', registry), false); +}); + +check('untrusted agent cannot reach vault', () => { + assert.equal(vaultAllowed('kimi-endpoint', registry), false); +}); + +check('unauthenticated caller (no token resolved) cannot reach vault', () => { + assert.equal(vaultAllowed(null, registry), false); + assert.equal(trustRankOf(null, registry), -1); +}); + +check('unknown/unregistered agent id fails closed, not open', () => { + assert.equal(vaultAllowed('some-new-agent-nobody-declared', registry), false); +}); + +check('resolveCallerAgent maps bearer token -> agent id, else null', () => { + assert.equal(resolveCallerAgent('tok-torgash', tokenMap), 'torgash'); + assert.equal(resolveCallerAgent('tok-adolf', tokenMap), 'adolf'); + assert.equal(resolveCallerAgent('not-a-real-token', tokenMap), null); + assert.equal(resolveCallerAgent(null, tokenMap), null); +}); + +check('end-to-end: a sandboxed agent\'s token provably cannot reach vw_* tools', () => { + const callerAgentId = resolveCallerAgent('tok-torgash', tokenMap); + assert.equal(callerAgentId, 'torgash'); + assert.equal(isVaultTool('vw_get_password'), true); + assert.equal(vaultAllowed(callerAgentId, registry), false); // <- the acceptance bar +}); + +check('end-to-end: a trusted agent\'s token can reach vw_* tools', () => { + const callerAgentId = resolveCallerAgent('tok-adolf', tokenMap); + assert.equal(vaultAllowed(callerAgentId, registry), true); +}); + +check('non-vault tool name is unaffected by the gate', () => { + assert.equal(isVaultTool('gitea_read_file'), false); + assert.equal(isVaultTool('zabbix_get_problems'), false); +}); + +_resetRegistryCacheForTests(null); +console.log(`\n${passed} passed`); diff --git a/openai/agent-registry.yaml b/openai/agent-registry.yaml index 13832fe..8ca2ef7 100644 --- a/openai/agent-registry.yaml +++ b/openai/agent-registry.yaml @@ -44,11 +44,18 @@ schema_version: 1 # ── trust classes (§5) ────────────────────────────────────────────────── # "human > trusted > sandboxed > untrusted". Numeric rank lets routing/grant # code do `>=` comparisons instead of string-matching an ordered list. +# +# default_budget_usd/budget_duration (kb#147): defense-in-depth defaults fed +# into each agent's LiteLLM virtual-key spec (agent_registry.py: +# litellm_key_spec()) when the agent doesn't override them. Moot for money +# TODAY (§3a: no metered API by default, only free local-small is reachable +# without opt-in) but real once anything metered is opted into a tier pool — +# a budget cap should already exist rather than being bolted on later. trust_classes: human: { rank: 3, note: "vault access yes; not MCP-tool-scoped, IS the reasoning" } - trusted: { rank: 2, note: "vault access yes (DECIDED, kb#147); outward actions per ask-first rules" } - sandboxed: { rank: 1, note: "no vault, no outward sends; scoped MCP allowlist; KB access project-scoped" } - untrusted: { rank: 0, note: "anything ingesting the open web; its OUTPUTS are tainted, not just its access restricted" } + trusted: { rank: 2, note: "vault access yes (DECIDED, kb#147); outward actions per ask-first rules", default_budget_usd: 50.0, budget_duration: "30d" } + sandboxed: { rank: 1, note: "no vault, no outward sends; scoped MCP allowlist; KB access project-scoped", default_budget_usd: 5.0, budget_duration: "30d" } + untrusted: { rank: 0, note: "anything ingesting the open web; its OUTPUTS are tainted, not just its access restricted", default_budget_usd: 0.0, budget_duration: "30d" } # ── runtimes ───────────────────────────────────────────────────────────── # Backbones deliberately OUTSIDE model-registry.yaml's `models:` list. @@ -88,11 +95,83 @@ agents: mcp_servers: [hindsight, openclaw-tools, kanboard, marketplace, agap] gateway_tools: [cron, nodes, browser] # openclaw.json gateway.tools.allow, live 2026-07-21 vault_access: true # trusted-only per §5 DECIDED; reaches vw_* via the `agap` MCP server + # kb#144 (A2A-12): PER-TOOL scoping, TWO layers — 2026-07-22, second + # pass after a live-verified miss on the first. Each server below + # keeps its own justification comment in its config; this is the + # registry's copy of the same lists (source of truth this side — + # validate_capability_grants.py cross-checks against BOTH). + # + # Layer 1 — OpenClaw's own mcp.servers.*.toolFilter.include in + # adolf/openclaw.json, applied when OpenClaw (the `adolf` container) + # builds ITS OWN tool bundle. Landed first pass, verified schema-valid + # via `openclaw config validate` / `openclaw mcp probe`. Real, correct + # for OpenClaw's own client — but NOT what determines the model's + # actual per-turn context on Adolf's kimi backbone. + # + # Layer 2 — shared-mcp.json's per-server `enabledTools`, which + # adolf-llm/server.js's writeMcpConfig() seeds into each Kimi CLI + # session's project-root .mcp.json (Gate 1). THIS is the layer that + # actually reaches the model: Kimi CLI auto-discovers that file, not + # OpenClaw's config, and applies its own McpServerCommonFields. + # enabledTools/disabledTools via computeEnabledNames (an allowlist + # when only enabledTools is set — confirmed by decompiling the + # installed @moonshot-ai/kimi-code package's dist/main.mjs, both + # copies of the function, packages/agent-core{,-v2}/src/agent/mcp/ + # connection-manager.ts). The first kb#144 pass got this backwards — + # see the release comment on kb#144 for the exact wire.jsonl proof + # (tool counts unchanged post-restart) that caught it: Layer 1 alone + # is invisible to Kimi. + # + # Counts (same tool lists both layers, confirmed identical by + # validate_capability_grants.py, exit 0): + # agap 32->24, hindsight 29->9, kanboard 23->14, + # openclaw-tools 5->5 (already minimal, no filter needed). + # marketplace stays UNFILTERED at layer 1 (7/13 kept) but is not in + # shared-mcp.json AT ALL — Kimi's session never had it in the first + # place (pre-existing gap between what OpenClaw offers Adolf and + # what reaches Kimi, out of kb#144's scope to close). + # Reachable-by-Kimi total: hindsight+kanboard+openclaw-tools+agap + # 102-13(marketplace, never reached Kimi)=89 -> 9+14+5+24=52 tools + # (-42%). Byte-measured (chars/4) against each server's real + # tools/list JSON schemas: est. ~7K tokens saved/turn — estimate + # pending the real wire.jsonl number, which needs the adolf-llm + # container restart alvis owns (shared-mcp.json is bind-mounted + # read-only but adolf-llm's server.js caches its content at process + # start, so editing the file alone does not take effect — see + # capability_grant_status below for the confirm-post-restart command). + # + # kb#95 (2026-07-23): added wiki_search/wiki_read/wiki_edit (family + # MediaWiki / РодоВики, family.alogins.net) to agap-mcp and to both + # layers' agap allowlist below — Adolf's persona domain (relatives, + # dates, events), same reasoning as HA/Zabbix/Todoist above. This ages + # the counts comment above (agap 32->24, total 52) by +3/+3; not + # recomputed here since it needs the same live wire.jsonl proof kb#144 + # used and this task does not touch the running containers (see + # shared_mcp_kimi_allowlist below for the exact confirm command). + mcp_tool_filter: + hindsight: [recall, retain, reflect, list_memories, get_memory, update_memory, list_directives, create_directive, delete_directive] + kanboard: [kanboard_list_projects, kanboard_get_project, kanboard_list_tasks, kanboard_my_tasks, kanboard_get_task, kanboard_search_tasks, kanboard_list_users, kanboard_project_activity, kanboard_create_task, kanboard_update_task, kanboard_move_task, kanboard_change_task_status, kanboard_assign_task, kanboard_add_comment] + marketplace: [marketplace_find_best, marketplace_search, marketplace_get_product, marketplace_get_recommendations, marketplace_get_reviews, marketplace_compare_prices, marketplace_status] + agap: [vw_get_password, vw_get_item, vw_list_items, vw_create_login, vw_update_password, ha_get_state, ha_list_entities, ha_call_service, ha_get_history, zabbix_get_problems, zabbix_get_hosts, zabbix_get_items, zabbix_get_triggers, radicale_list_calendars, radicale_list_events, radicale_get_event, radicale_put_event, radicale_delete_event, radicale_move_event, todoist_list_tasks, todoist_list_projects, todoist_create_task, todoist_update_task, todoist_complete_task, wiki_search, wiki_read, wiki_edit] + openclaw-tools: null # no filter in openclaw.json — already minimal (5/5 kept) note: > - "scoped core tools" per kb#134's brief — this IS openai/openclaw.json's - live mcp.servers block, not a narrower aspirational allowlist. Per-tool - (not per-server) scoping + LiteLLM virtual-key budgets are kb#147's job; - this field is the input #147 consumes. + "scoped core tools" per kb#134's brief, now REAL at both levels: this + IS openai/openclaw.json's live mcp.servers block (server selection) + plus its per-server toolFilter.include (tool selection, kb#144). Not + a narrower aspirational allowlist — validate_capability_grants.py + cross-checks both against the live config. LiteLLM virtual-key + budgets remain kb#147's separate job; this field is the input #147 + consumes for MCP scope (litellm_key_spec() handles the model side). + capability_grant: # kb#147 — the enforcement input for LiteLLM + agap-mcp + litellm_key_alias: adolf + mcp_auth_token_env: AGAP_MCP_TOKEN_ADOLF # secret lives in Vaultwarden + this container's env, never in git; see agap-mcp/docker-compose.yml AGAP_MCP_AGENT_TOKENS + note: > + Reachable model tiers derived at read time from preferred_tier via + agent_registry.py:litellm_key_spec() — not duplicated here. Actual + virtual-key provisioning happens via openai/provision_litellm_keys.py + against the live LiteLLM proxy; NOT run automatically by this + registry (privileged action, requires the LiteLLM master key — + kb#147 handover step, see task comment). memory: banks: - { id: adolf-alvis, role: private, interlocutor: alvis } @@ -127,6 +206,10 @@ agents: Widest-scoped agent by design (this session's own tool surface) — gated by ask-first rules on outward/destructive actions rather than MCP allowlisting. Per kb#147, a real virtual-key budget still applies. + capability_grant: + litellm_key_alias: claude-coder + mcp_auth_token_env: AGAP_MCP_TOKEN_CLAUDE_CODER + note: "same mechanism as adolf's capability_grant above; see that note." memory: banks: [] model: "session (ephemeral, per invocation) + repo state (git history, CLAUDE.md files, kanboard task/comment history) — no persistent Hindsight bank" @@ -150,6 +233,15 @@ agents: vault_access: false # sandboxed — hard rule §5, no exceptions outward_sends: false note: "scoped to mcp__marketplace__* tools only; no gitea/ha/zabbix/radicale, no kanboard project outside its own." + capability_grant: + litellm_key_alias: torgash + mcp_auth_token_env: AGAP_MCP_TOKEN_TORGASH + note: > + Not provisioned yet (agent not built — see persona.prompt_source + above). litellm_key_spec('torgash') already resolves correctly + against the registry today (preferred_tier: small -> models=[local- + small's litellm_model_name] only, no large/paid-fallback) — verified + by kb#147's provision_litellm_keys.py --dry-run. memory: banks: [{ id: torgash, role: private }] current_state: "bank not yet created — target state, same as the persona itself" @@ -178,6 +270,10 @@ agents: native_tools: [WebSearch, WebFetch] vault_access: false outward_sends: false + capability_grant: + litellm_key_alias: researcher + mcp_auth_token_env: AGAP_MCP_TOKEN_RESEARCHER + note: "not provisioned yet (agent not built) — same verification status as torgash's capability_grant above." memory: banks: [{ id: researcher, role: private }] current_state: "bank not yet created — target state" @@ -293,3 +389,84 @@ routing_consumption: >= required, and current a(t) == 1 (resolved via agent_registry.py's effective_card(), which dereferences `backbone` into model-registry.yaml or `runtimes:` above) — no submitter-side hardcoded agent id needed. + +# ── kb#147 (A2A-15) implementation status ──────────────────────────────── +# What "enforced" means as of this task, per enforcement point (§5 lists +# three: agap-mcp vault tools, OpenClaw per-agent MCP scoping, LiteLLM +# virtual keys). Recorded here — not just in the KB task comment — because +# this file is the grants source of truth the design demands. +capability_grant_status: + agap_mcp_vault_gate: > + IMPLEMENTED, tested, NOT ACTIVATED. agap-mcp/src/trust-gate.js resolves + a bearer token -> agent id -> trust rank (reading THIS file, mounted + read-only) and agap-mcp/src/server.js gates every vw_* tool behind it. + Proven with two real, run-now test suites (no live container touched): + src/trust-gate.test.mjs (pure logic, 10/10) and + src/trust-gate-http.test.mjs (real HTTP request/response, 4/4) — the + latter shows a sandboxed-agent token, no token, and an unknown token all + get "vault access denied" while a trusted-agent token passes. OFF by + default (AGAP_MCP_ENFORCE_VAULT_TRUST=0 in docker-compose.yml) so this + change is zero-impact until an operator: (1) generates real per-agent + bearer tokens, stores them in Vaultwarden, wires them into + AGAP_MCP_AGENT_TOKENS, (2) sets AGAP_MCP_ENFORCE_VAULT_TRUST=1, (3) + restarts the agap-mcp container — deliberately not done by this task + (never restart the live agap-mcp service unattended). + litellm_virtual_keys: > + SPEC'D, NOT PROVISIONED. agent_registry.py:litellm_key_spec() computes + each agent's model allow-list (derived from preferred_tier x + model-registry.yaml routing.tiers, non-metered only unless opted in) + and a default budget from trust_classes[...].default_budget_usd. + openai/provision_litellm_keys.py turns that spec into LiteLLM + /key/generate calls. Verified with --dry-run (prints the exact payload + per agent, no network call) — actually creating keys needs + LITELLM_MASTER_KEY against the live proxy, a privileged write this task + does not perform unattended; see the kb#147 task comment for the exact + command to run once approved. + openclaw_mcp_allowlist: > + STRUCTURAL at both server AND tool level, cross-checked (kb#144 extended + this from server-only). adolf/openclaw.json's mcp.servers block is + adolf's real, live MCP surface (git-controlled): its server set matches + tool_allowlist.mcp_servers, and each server's toolFilter.include (added + kb#144 first pass) matches tool_allowlist.mcp_tool_filter — exactly. + openai/validate_capability_grants.py checks this automatically, read-only, + no live changes. Real and correct for OpenClaw's OWN MCP client surface — + but per the kb#144 first-pass release comment's wire.jsonl proof, this + layer alone does NOT reach the model on Adolf's kimi backbone (see + shared_mcp_kimi_allowlist below, the layer that does). NOT restarted by + this task for this file's change either way (openclaw.json is + bind-mounted read-only as the live config; `docker compose restart adolf` + is the activation step, alvis's call). + shared_mcp_kimi_allowlist: > + STRUCTURAL, cross-checked, NOT YET ACTIVATED — kb#144 SECOND pass + (2026-07-22), added after live verification (restart + one real turn, + wire.jsonl inspection) proved the first pass's openclaw.json-only fix + left Kimi's actual tool bundle unchanged (23/32/29/5, not 14/24/9/5). + Root cause: Kimi CLI auto-discovers a project-root `.mcp.json` that + adolf-llm/server.js's writeMcpConfig() seeds from openai/shared-mcp.json + — a completely separate config from openclaw.json, read by a separate + MCP client (Kimi CLI inside the adolf-llm container, not OpenClaw inside + the adolf container). shared-mcp.json now carries the same per-server + tool lists as `enabledTools` (Kimi's own allowlist field — + McpServerCommonFields.enabledTools, applied via computeEnabledNames; + confirmed by decompiling the installed @moonshot-ai/kimi-code package's + dist/main.mjs, both copies of the function/schema, no live container + touched). openai/validate_capability_grants.py now cross-checks THIS + file too (load_shared_mcp_enabled_tools), same exit-0-or-fail contract + as the openclaw.json check. marketplace is absent from shared-mcp.json + entirely (pre-existing: Kimi's session never had it) — not asserted by + the validator for that server, by design, not a gap this task opened. + NOT YET ACTIVATED: shared-mcp.json IS bind-mounted read-only into + adolf-llm (`./shared-mcp.json:/shared-mcp.json:ro` in + openai/docker-compose.yml) so the file on disk is already what the + container would read — but adolf-llm/server.js loads it ONCE into a + module-level variable at process start (not per-request), so editing the + file alone does not take effect; `docker compose restart adolf-llm` is + the only remaining step, deliberately not run by this task (never + restart a live service unattended). Confirm the real post-restart + per-turn token delta via the adolf-llm container's Kimi session wire + log: `docker exec adolf-llm sh -c "tail -1 + /root/.kimi-code/sessions/*/agents/main/wire.jsonl"` (after one real + turn against a NEW session, since existing sessions' .mcp.json is + rewritten on their next turn too) and compare per-server tool counts + against 9/14/5/24 (hindsight/kanboard/openclaw-tools/agap) — the exact + same command the first-pass verification used to catch the miss. diff --git a/openai/shared-mcp.json b/openai/shared-mcp.json index b6f4291..0320386 100644 --- a/openai/shared-mcp.json +++ b/openai/shared-mcp.json @@ -2,7 +2,8 @@ "mcpServers": { "hindsight": { "type": "http", - "url": "http://hindsight:8888/mcp/adolf/" + "url": "http://hindsight:8888/mcp/adolf/", + "enabledTools": ["recall", "retain", "reflect", "list_memories", "get_memory", "update_memory", "list_directives", "create_directive", "delete_directive"] }, "openclaw-tools": { "type": "http", @@ -10,11 +11,19 @@ }, "kanboard": { "type": "http", - "url": "http://host.docker.internal:3104/mcp" + "url": "http://host.docker.internal:3104/mcp", + "enabledTools": ["kanboard_list_projects", "kanboard_get_project", "kanboard_list_tasks", "kanboard_my_tasks", "kanboard_get_task", "kanboard_search_tasks", "kanboard_list_users", "kanboard_project_activity", "kanboard_create_task", "kanboard_update_task", "kanboard_move_task", "kanboard_change_task_status", "kanboard_assign_task", "kanboard_add_comment"] }, "agap": { "type": "http", - "url": "http://host.docker.internal:3100/mcp" + "url": "http://host.docker.internal:3100/mcp", + "enabledTools": ["vw_get_password", "vw_get_item", "vw_list_items", "vw_create_login", "vw_update_password", "ha_get_state", "ha_list_entities", "ha_call_service", "ha_get_history", "zabbix_get_problems", "zabbix_get_hosts", "zabbix_get_items", "zabbix_get_triggers", "radicale_list_calendars", "radicale_list_events", "radicale_get_event", "radicale_put_event", "radicale_delete_event", "radicale_move_event", "todoist_list_tasks", "todoist_list_projects", "todoist_create_task", "todoist_update_task", "todoist_complete_task", "wiki_search", "wiki_read", "wiki_edit"] + }, + "marketplace": { + "type": "http", + "url": "http://host.docker.internal:3101/mcp", + "bearerTokenEnvVar": "MARKETPLACE_MCP_TOKEN", + "enabledTools": ["marketplace_find_best", "marketplace_search", "marketplace_get_product", "marketplace_get_recommendations", "marketplace_get_reviews", "marketplace_compare_prices", "marketplace_status"] } } } diff --git a/openai/validate_capability_grants.py b/openai/validate_capability_grants.py new file mode 100755 index 0000000..6ddeebf --- /dev/null +++ b/openai/validate_capability_grants.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""validate_capability_grants — kb#147 (A2A-15), extended by kb#144 (A2A-12): +cross-check that agent-registry.yaml's declared tool_allowlist.mcp_servers +(server-level) AND tool_allowlist.mcp_tool_filter (per-tool level, kb#144) +for each agent match what that agent's real, git-controlled config actually +grants it -- at BOTH layers that materialize a tool bundle for Adolf: + + 1. OpenClaw's own mcp.servers.*.toolFilter.include in adolf/openclaw.json + (adolf's own MCP client surface). + 2. shared-mcp.json's per-server `enabledTools` (kb#144 verification pass, + 2026-07-22): what adolf-llm/server.js's writeMcpConfig() seeds into + each Kimi CLI session's project-root .mcp.json -- the layer that + ACTUALLY determines the MODEL's tool bundle for Adolf's kimi backbone. + Layer 1 alone shipped a false "Done" once already (kb#144 first pass): + wire.jsonl proved Kimi's tool counts were unchanged because OpenClaw's + toolFilter never reaches the Kimi CLI, which reads its own + enabledTools/disabledTools (McpServerCommonFields, computeEnabledNames + -- confirmed by decompiling the installed @moonshot-ai/kimi-code + package's dist/main.mjs). Checking only layer 1 would pass this + validator while leaving the real per-turn token bloat unfixed again. + +Read-only. Makes no live changes and touches no running service — it just +diffs already-committed files so a registry edit that silently drifts from +an agent's real config fails loudly (exit 1) instead of rotting quietly, +which is exactly the "scattered configs" failure mode kb#147's acceptance +bar ("grants live in the agent registry, not scattered configs") exists to +prevent. kb#144's acceptance bar ("Adolf's tools are sourced from the +registry") means both layers, not just the one OpenClaw itself reads. + +Only agents with a `prompt_source` pointing at a real openclaw.json-shaped +config are checked; agents that are registry-only target state (torgash, +researcher — no config file exists yet) are reported as skipped, not failed. + +Usage: + ./validate_capability_grants.py + ./validate_capability_grants.py --openclaw-json ../adolf/openclaw.json --id adolf +""" + +import argparse +import json +import re +import sys + +import agent_registry as ar + +HERE_ADOLF_OPENCLAW_JSON = "../adolf/openclaw.json" +SHARED_MCP_JSON = "shared-mcp.json" + +# id -> path to the git-controlled OpenClaw config that IS this agent's live +# MCP surface. Only adolf has one today (claude-coder has no openclaw.json -- +# it's a CLAUDE.md-driven persona, not an OpenClaw runtime; see its +# capability_grant note in agent-registry.yaml). +KNOWN_CONFIGS = { + "adolf": HERE_ADOLF_OPENCLAW_JSON, +} + +# id -> path to the shared-mcp.json this agent's backbone runtime seeds its +# session .mcp.json from (kb#144 layer-2 check, see module docstring). Only +# agents on a Kimi-CLI-shaped backbone go through this file at all. +KNOWN_SHARED_MCP = { + "adolf": SHARED_MCP_JSON, +} + + +def _strip_jsonc_comments(text): + """Drop // line comments. Good enough for this read-only check: this + file's comments are all on their own line or trail real content with no + '//' inside a string value today -- verified by hand.""" + out = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("//"): + continue + m = re.search(r'(?<!:)//', line) + if m and line[: m.start()].count('"') % 2 == 0: + line = line[: m.start()] + out.append(line) + return "\n".join(out) + + +def _find_key_brace(text, key, start=0): + """Find `key: {` (bare or quoted key) at or after `start`; return the + index of the matching '{'.""" + m = re.search(rf'["\']?{re.escape(key)}["\']?\s*:\s*\{{', text[start:]) + if not m: + raise ValueError(f"key {key!r} not found from offset {start}") + return start + m.end() - 1 # index of the '{' itself + + +def _matching_close_brace(text, open_idx): + depth = 0 + for i in range(open_idx, len(text)): + if text[i] == '{': + depth += 1 + elif text[i] == '}': + depth -= 1 + if depth == 0: + return i + raise ValueError("unbalanced braces") + + +def _extract_object_top_level_keys(text, key_path): + """openclaw.json is JS-object-literal JSON5 (bare identifier keys, + trailing commas) -- `json.loads` can't touch it and pulling in a JSON5 + parser is overkill for one narrow read. Instead: locate `key_path` + (e.g. ["mcp", "servers"]) by finding each key's opening '{' in turn, then + brace-depth-scan that object collecting only its DIRECT child keys + (`name: {` at depth 1). Sufficient and honest for this validation + script's one job; not a general JSON5 reader.""" + pos = 0 + open_idx = 0 + for key in key_path: + open_idx = _find_key_brace(text, key, pos) + pos = open_idx + 1 + close_idx = _matching_close_brace(text, open_idx) + body = text[open_idx + 1 : close_idx] + + # Depth-0 identifiers immediately followed by ": {" are this object's + # direct child keys (mcp.servers' entries are always objects). Only try + # a key match right after a boundary ('{', ',', or start-of-body) so an + # identifier can't be "matched" starting mid-token from inside a nested + # value (the earlier, buggy version of this scanner did exactly that). + keys = [] + depth = 0 + i, n = 0, len(body) + prev_boundary = True + key_re = re.compile(r'["\']?([A-Za-z0-9_-]+)["\']?\s*:\s*\{') + while i < n: + ch = body[i] + if ch in ' \t\r\n': + i += 1 + continue + if depth == 0 and prev_boundary: + m = key_re.match(body, i) + if m: + keys.append(m.group(1)) + i = m.end() - 1 # land on the key's '{' so the normal handling below opens depth 1 + prev_boundary = False + continue + if ch == '{': + depth += 1 + prev_boundary = True + elif ch == '}': + depth -= 1 + prev_boundary = True + elif ch == ',': + prev_boundary = True + else: + prev_boundary = False + i += 1 + return sorted(keys) + + +def load_mcp_servers(path): + with open(path) as f: + raw = f.read() + text = _strip_jsonc_comments(raw) + return _extract_object_top_level_keys(text, ["mcp", "servers"]) + + +def _locate_object(text, key_path, start=0): + """Chase `key_path` (e.g. ["mcp", "servers", "hindsight"]) through nested + `key: {` objects, same navigation _extract_object_top_level_keys does + internally, exposed standalone so other extractors (toolFilter below) + can reuse it instead of re-deriving brace offsets.""" + pos = start + open_idx = start + for key in key_path: + open_idx = _find_key_brace(text, key, pos) + pos = open_idx + 1 + close_idx = _matching_close_brace(text, open_idx) + return open_idx, close_idx + + +def _matching_close_bracket(text, open_idx): + """Same brace-depth-scan as _matching_close_brace, for '[' / ']' — needed + to bound a toolFilter.include array (a list, not an object).""" + depth = 0 + for i in range(open_idx, len(text)): + if text[i] == '[': + depth += 1 + elif text[i] == ']': + depth -= 1 + if depth == 0: + return i + raise ValueError("unbalanced brackets") + + +def load_tool_filter(path, server_name): + """kb#144: extract mcp.servers.<server_name>.toolFilter.include as a + sorted list of tool names, or None if that server has no toolFilter (or + no include list) at all — OpenClaw's own semantics for "no toolFilter": + every tool the server offers stays eligible (see schema-BqdpWz19.js: + "When omitted, all server tools remain eligible unless excluded."). + exclude-only filters are not modeled here (none of Adolf's servers use + exclude today) and are reported as None (unrestricted) rather than + silently mis-parsed. + """ + with open(path) as f: + raw = f.read() + text = _strip_jsonc_comments(raw) + try: + server_open, server_close = _locate_object(text, ["mcp", "servers", server_name]) + except ValueError: + return None # server not present in this config at all + body = text[server_open : server_close + 1] + try: + tf_open, tf_close = _locate_object(body, ["toolFilter"]) + except ValueError: + return None # no toolFilter -> unrestricted, by OpenClaw's own semantics + tf_body = body[tf_open : tf_close + 1] + m = re.search(r'["\']?include["\']?\s*:\s*\[', tf_body) + if not m: + return None # exclude-only or empty toolFilter -- not modeled, treat as unrestricted + bracket_open = tf_body.index('[', m.start()) + bracket_close = _matching_close_bracket(tf_body, bracket_open) + arr_body = tf_body[bracket_open + 1 : bracket_close] + return sorted(re.findall(r'["\']([A-Za-z0-9_.\-\*]+)["\']', arr_body)) + + +def load_shared_mcp_enabled_tools(path): + """kb#144 layer-2 check (see module docstring): shared-mcp.json is + strict JSON (no JSON5 quirks, unlike openclaw.json), so a plain + `json.load` is enough -- no brace-scanner needed here. Returns + {server_name: sorted-tool-list-or-None}, None meaning no `enabledTools` + key on that server (unfiltered -- every tool it offers stays eligible, + same "omitted = unrestricted" semantics as OpenClaw's own toolFilter). + """ + with open(path) as f: + data = json.load(f) + out = {} + for name, cfg in (data.get("mcpServers") or {}).items(): + tools = cfg.get("enabledTools") + out[name] = sorted(tools) if tools else None + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--registry", default=None) + ap.add_argument("--openclaw-json", default=None, help="override path for --id's config") + ap.add_argument("--id", default=None, help="only this agent id (default: every id in KNOWN_CONFIGS)") + args = ap.parse_args() + + reg = ar.load_registry(args.registry) + ids = [args.id] if args.id else list(KNOWN_CONFIGS) + + failures = 0 + for agent_id in ids: + agent = ar.get_agent(reg, agent_id) + declared = sorted((agent.get("tool_allowlist") or {}).get("mcp_servers") or []) + config_path = args.openclaw_json or KNOWN_CONFIGS.get(agent_id) + if not config_path: + print(f"SKIP {agent_id}: no known live config to cross-check (target-state agent)") + continue + try: + live = load_mcp_servers(config_path) + except FileNotFoundError: + print(f"SKIP {agent_id}: config not found at {config_path}") + continue + if declared == live: + print(f"OK {agent_id}: registry tool_allowlist.mcp_servers == live {config_path} mcp.servers -> {live}") + else: + failures += 1 + print(f"FAIL {agent_id}: registry says {declared} but {config_path} actually grants {live}") + + # kb#144: per-tool cross-check, same idea one level down. Only + # meaningful for servers the registry actually declares a filter + # for (mcp_tool_filter); a server absent from that map is not + # asserted either way here (it may be intentionally unfiltered). + declared_filters = (agent.get("tool_allowlist") or {}).get("mcp_tool_filter") or {} + for server_name, declared_tools in declared_filters.items(): + live_tools = load_tool_filter(config_path, server_name) + declared_sorted = sorted(declared_tools) if declared_tools else None + if declared_sorted == live_tools: + shown = live_tools if live_tools is not None else "(unfiltered)" + print(f"OK {agent_id}/{server_name}: registry mcp_tool_filter == live toolFilter.include -> {shown}") + else: + failures += 1 + print(f"FAIL {agent_id}/{server_name}: registry mcp_tool_filter says {declared_sorted} but live toolFilter.include is {live_tools}") + + # kb#144 layer-2: the file that actually reaches the MODEL for a + # Kimi-backed agent (see module docstring for why layer 1 alone + # missed the real bug once already). Servers the registry declares a + # filter for but that don't appear in shared-mcp.json at all (e.g. + # marketplace, which OpenClaw carries but Kimi's session never sees) + # are not asserted here -- that's a separate, pre-existing gap + # between what OpenClaw offers Adolf and what reaches Kimi, not a + # drift this validator's job to catch. + shared_mcp_path = KNOWN_SHARED_MCP.get(agent_id) + if shared_mcp_path: + try: + live_shared = load_shared_mcp_enabled_tools(shared_mcp_path) + except FileNotFoundError: + print(f"SKIP {agent_id}: shared-mcp.json not found at {shared_mcp_path}") + else: + for server_name, declared_tools in declared_filters.items(): + if server_name not in live_shared: + continue + declared_sorted = sorted(declared_tools) if declared_tools else None + live_tools = live_shared[server_name] + if declared_sorted == live_tools: + shown = live_tools if live_tools is not None else "(unfiltered)" + print(f"OK {agent_id}/{server_name}: registry mcp_tool_filter == live shared-mcp.json enabledTools -> {shown}") + else: + failures += 1 + print(f"FAIL {agent_id}/{server_name}: registry mcp_tool_filter says {declared_sorted} but shared-mcp.json enabledTools is {live_tools}") + + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main()