From 9243652c20e4d46328d06bf98dccc59b9410023b Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Fri, 17 Jul 2026 12:45:46 -0700 Subject: [PATCH] implement mcp server: bearer api tokens, agent provenance, 15-tool catalog --- deno.lock | 518 +++++++++++++++++- migrations/006_api_tokens.sql | 52 ++ openspec/changes/add-mcp-server/design.md | 13 + openspec/changes/add-mcp-server/tasks.md | 152 +++-- package.json | 4 +- src/app.d.ts | 6 + src/hooks.server.ts | 29 +- src/lib/server/db.test.ts | 126 ++++- src/lib/server/mcp/server.test.ts | 193 +++++++ src/lib/server/mcp/server.ts | 309 +++++++++++ src/lib/server/services/api-tokens.test.ts | 98 ++++ src/lib/server/services/api-tokens.ts | 117 ++++ .../server/services/categorization.test.ts | 176 ++++++ src/lib/server/services/categorization.ts | 53 +- src/routes/(app)/settings/+page.server.ts | 32 ++ src/routes/(app)/settings/+page.svelte | 95 ++++ src/routes/mcp/+server.ts | 20 + 17 files changed, 1932 insertions(+), 61 deletions(-) create mode 100644 migrations/006_api_tokens.sql create mode 100644 src/lib/server/mcp/server.test.ts create mode 100644 src/lib/server/mcp/server.ts create mode 100644 src/lib/server/services/api-tokens.test.ts create mode 100644 src/lib/server/services/api-tokens.ts create mode 100644 src/lib/server/services/categorization.test.ts create mode 100644 src/routes/mcp/+server.ts diff --git a/deno.lock b/deno.lock index f607a6a..e215dd9 100644 --- a/deno.lock +++ b/deno.lock @@ -6,6 +6,8 @@ "npm:@atproto/oauth-client-node@~0.4.8": "0.4.8", "npm:@atproto/oauth-types@~0.7.5": "0.7.5", "npm:@fontsource-variable/inter@^5.2.8": "5.2.8", + "npm:@modelcontextprotocol/sdk@1.29.0": "1.29.0_zod@3.25.76", + "npm:@modelcontextprotocol/sdk@^1.29.0": "1.29.0_zod@3.25.76", "npm:@sveltejs/adapter-node@^5.5.0": "5.5.7_@sveltejs+kit@2.69.2__@sveltejs+vite-plugin-svelte@7.2.0___svelte@5.56.4___vite@8.1.4____@types+node@26.1.1___@types+node@26.1.1__svelte@5.56.4__typescript@6.0.3__vite@8.1.4___@types+node@26.1.1__@types+node@26.1.1_@types+node@26.1.1", "npm:@sveltejs/kit@*": "2.69.2_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.4__vite@8.1.4___@types+node@26.1.1__@types+node@26.1.1_svelte@5.56.4_typescript@6.0.3_vite@8.1.4__@types+node@26.1.1_@types+node@26.1.1", "npm:@sveltejs/kit@^2.63.0": "2.69.2_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.4__vite@8.1.4___@types+node@26.1.1__@types+node@26.1.1_svelte@5.56.4_typescript@6.0.3_vite@8.1.4__@types+node@26.1.1_@types+node@26.1.1", @@ -18,7 +20,8 @@ "npm:svelte@^5.56.1": "5.56.4", "npm:typescript@^6.0.3": "6.0.3", "npm:vite@*": "8.1.4_@types+node@26.1.1", - "npm:vite@^8.0.16": "8.1.4_@types+node@26.1.1" + "npm:vite@^8.0.16": "8.1.4_@types+node@26.1.1", + "npm:zod@^3.25.76": "3.25.76" }, "jsr": { "@std/streams@1.0.17": { @@ -42,7 +45,7 @@ "dependencies": [ "@atproto-labs/fetch", "@atproto-labs/pipe", - "ipaddr.js", + "ipaddr.js@2.4.0", "undici_v6@npm:undici@6.27.0", "undici_v7@npm:undici@7.28.0", "undici_v8@npm:undici@8.7.0" @@ -110,7 +113,7 @@ "integrity": "sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg==", "dependencies": [ "@atproto/jwk", - "jose" + "jose@5.10.0" ] }, "@atproto/jwk-webcrypto@0.3.4": { @@ -228,6 +231,12 @@ "@fontsource-variable/inter@5.2.8": { "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==" }, + "@hono/node-server@1.19.14_hono@4.12.30": { + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dependencies": [ + "hono" + ] + }, "@jridgewell/gen-mapping@0.3.13": { "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dependencies": [ @@ -255,6 +264,28 @@ "@jridgewell/sourcemap-codec" ] }, + "@modelcontextprotocol/sdk@1.29.0_zod@3.25.76": { + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dependencies": [ + "@hono/node-server", + "ajv", + "ajv-formats", + "content-type@1.0.5", + "cors", + "cross-spawn", + "eventsource", + "eventsource-parser", + "express", + "express-rate-limit", + "hono", + "jose@6.2.3", + "json-schema-typed", + "pkce-challenge", + "raw-body", + "zod", + "zod-to-json-schema" + ] + }, "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": { "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dependencies": [ @@ -567,7 +598,7 @@ "@sveltejs/vite-plugin-svelte", "@types/cookie", "acorn", - "cookie", + "cookie@0.6.0", "devalue", "esm-env", "kleur", @@ -622,16 +653,72 @@ "@types/trusted-types@2.0.7": { "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, + "accepts@2.0.0": { + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": [ + "mime-types", + "negotiator" + ] + }, "acorn@8.17.0": { "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "bin": true }, + "ajv-formats@3.0.1_ajv@8.20.0": { + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": [ + "ajv" + ], + "optionalPeers": [ + "ajv" + ] + }, + "ajv@8.20.0": { + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dependencies": [ + "fast-deep-equal", + "fast-uri", + "json-schema-traverse", + "require-from-string" + ] + }, "aria-query@5.3.1": { "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==" }, "axobject-query@4.1.0": { "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" }, + "body-parser@2.3.0": { + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dependencies": [ + "bytes", + "content-type@2.0.0", + "debug", + "http-errors", + "iconv-lite", + "on-finished", + "qs", + "raw-body", + "type-is" + ] + }, + "bytes@3.1.2": { + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "call-bound@1.0.4": { + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": [ + "call-bind-apply-helpers", + "get-intrinsic" + ] + }, "chokidar@4.0.3": { "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dependencies": [ @@ -644,25 +731,93 @@ "commondir@1.0.1": { "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" }, + "content-disposition@1.1.0": { + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" + }, + "content-type@1.0.5": { + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "content-type@2.0.0": { + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==" + }, + "cookie-signature@1.2.2": { + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, "cookie@0.6.0": { "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, + "cookie@0.7.2": { + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, "core-js@3.49.0": { "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "scripts": true }, + "cors@2.8.6": { + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": [ + "object-assign", + "vary" + ] + }, + "cross-spawn@7.0.6": { + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": [ + "path-key", + "shebang-command", + "which" + ] + }, "csv-parse@5.6.0": { "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==" }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, + "depd@2.0.0": { + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, "detect-libc@2.1.2": { "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" }, "devalue@5.8.1": { "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==" }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "ee-first@1.1.1": { + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "encodeurl@2.0.0": { + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.2": { + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": [ + "es-errors" + ] + }, + "escape-html@1.0.3": { + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "esm-env@1.2.2": { "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" }, @@ -675,6 +830,64 @@ "estree-walker@2.0.2": { "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, + "etag@1.8.1": { + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "eventsource-parser@3.1.0": { + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==" + }, + "eventsource@3.0.7": { + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dependencies": [ + "eventsource-parser" + ] + }, + "express-rate-limit@8.5.2_express@5.2.1": { + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dependencies": [ + "express", + "ip-address" + ] + }, + "express@5.2.1": { + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dependencies": [ + "accepts", + "body-parser", + "content-disposition", + "content-type@1.0.5", + "cookie@0.7.2", + "cookie-signature", + "debug", + "depd", + "encodeurl", + "escape-html", + "etag", + "finalhandler", + "fresh", + "http-errors", + "merge-descriptors", + "mime-types", + "on-finished", + "once", + "parseurl", + "proxy-addr", + "qs", + "range-parser", + "router", + "send", + "serve-static", + "statuses", + "type-is", + "vary" + ] + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-uri@3.1.3": { + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==" + }, "fdir@6.5.0_picomatch@4.0.5": { "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dependencies": [ @@ -684,6 +897,23 @@ "picomatch" ] }, + "finalhandler@2.1.1": { + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dependencies": [ + "debug", + "encodeurl", + "escape-html", + "on-finished", + "parseurl", + "statuses" + ] + }, + "forwarded@0.2.0": { + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh@2.0.0": { + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "os": ["darwin"], @@ -692,12 +922,68 @@ "function-bind@1.1.2": { "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, "hasown@2.0.4": { "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dependencies": [ "function-bind" ] }, + "hono@4.12.30": { + "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==" + }, + "http-errors@2.0.1": { + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": [ + "depd", + "inherits", + "setprototypeof", + "statuses", + "toidentifier" + ] + }, + "iconv-lite@0.7.3": { + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": [ + "safer-buffer" + ] + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ip-address@10.2.0": { + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==" + }, + "ipaddr.js@1.9.1": { + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, "ipaddr.js@2.4.0": { "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==" }, @@ -710,6 +996,9 @@ "is-module@1.0.0": { "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" }, + "is-promise@4.0.0": { + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, "is-reference@1.2.1": { "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", "dependencies": [ @@ -722,12 +1011,24 @@ "@types/estree" ] }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, "iso-datestring-validator@2.2.2": { "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" }, "jose@5.10.0": { "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==" }, + "jose@6.2.3": { + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==" + }, + "json-schema-traverse@1.0.0": { + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "json-schema-typed@8.0.2": { + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==" + }, "kleur@4.1.5": { "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" }, @@ -817,12 +1118,33 @@ "@jridgewell/sourcemap-codec" ] }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "media-typer@1.1.0": { + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "merge-descriptors@2.0.0": { + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" + }, + "mime-db@1.54.0": { + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types@3.0.2": { + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": [ + "mime-db" + ] + }, "mri@1.2.0": { "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" }, "mrmime@2.0.1": { "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==" }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "multiformats@13.4.2": { "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==" }, @@ -830,18 +1152,51 @@ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "bin": true }, + "negotiator@1.0.0": { + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + }, + "object-assign@4.1.1": { + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "object-inspect@1.13.4": { + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, "obug@2.1.3": { "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==" }, + "on-finished@2.4.1": { + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": [ + "ee-first" + ] + }, + "once@1.4.0": { + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": [ + "wrappy" + ] + }, + "parseurl@1.3.3": { + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-key@3.1.1": { + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, "path-parse@1.0.7": { "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, + "path-to-regexp@8.4.2": { + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" + }, "picocolors@1.1.1": { "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "picomatch@4.0.5": { "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==" }, + "pkce-challenge@5.0.1": { + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==" + }, "poline@0.13.1": { "integrity": "sha512-CCK9R8mSBoaxlbVDfeTAmWbw2or6WEpHL1uNHyJH9CYppVWlcLUUPAOf40mHcKGLLBmGb6NdWse+ZEeArpbG8Q==" }, @@ -853,9 +1208,38 @@ "source-map-js" ] }, + "proxy-addr@2.0.7": { + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": [ + "forwarded", + "ipaddr.js@1.9.1" + ] + }, + "qs@6.15.3": { + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": [ + "es-define-property", + "side-channel" + ] + }, + "range-parser@1.3.0": { + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==" + }, + "raw-body@3.0.2": { + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": [ + "bytes", + "http-errors", + "iconv-lite", + "unpipe" + ] + }, "readdirp@4.1.2": { "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" }, + "require-from-string@2.0.2": { + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "resolve@1.22.11": { "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dependencies": [ @@ -925,15 +1309,101 @@ ], "bin": true }, + "router@2.2.0": { + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": [ + "debug", + "depd", + "is-promise", + "parseurl", + "path-to-regexp" + ] + }, "sade@1.8.1": { "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", "dependencies": [ "mri" ] }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send@1.2.1": { + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": [ + "debug", + "encodeurl", + "escape-html", + "etag", + "fresh", + "http-errors", + "mime-types", + "ms", + "on-finished", + "range-parser", + "statuses" + ] + }, + "serve-static@2.2.1": { + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dependencies": [ + "encodeurl", + "escape-html", + "parseurl", + "send" + ] + }, "set-cookie-parser@3.0.1": { "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==" }, + "setprototypeof@1.2.0": { + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "shebang-command@2.0.0": { + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@3.0.0": { + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "side-channel-list@1.0.1": { + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": [ + "es-errors", + "object-inspect" + ] + }, + "side-channel-map@1.0.1": { + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect" + ] + }, + "side-channel-weakmap@1.0.2": { + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect", + "side-channel-map" + ] + }, + "side-channel@1.1.1": { + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": [ + "es-errors", + "object-inspect", + "side-channel-list", + "side-channel-map", + "side-channel-weakmap" + ] + }, "sirv@3.0.2": { "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dependencies": [ @@ -945,6 +1415,9 @@ "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, + "statuses@2.0.2": { + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + }, "supports-preserve-symlinks-flag@1.0.0": { "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, @@ -990,12 +1463,23 @@ "picomatch" ] }, + "toidentifier@1.0.1": { + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, "totalist@3.0.1": { "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" }, "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "type-is@2.1.0": { + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dependencies": [ + "content-type@2.0.0", + "media-typer", + "mime-types" + ] + }, "typescript@6.0.3": { "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "bin": true @@ -1015,6 +1499,12 @@ "unicode-segmenter@0.14.5": { "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" }, + "unpipe@1.0.0": { + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "vary@1.1.2": { + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, "vite@8.1.4_@types+node@26.1.1": { "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dependencies": [ @@ -1042,9 +1532,25 @@ "vite" ] }, + "which@2.0.2": { + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": [ + "isexe" + ], + "bin": true + }, + "wrappy@1.0.2": { + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, "zimmerframe@1.1.4": { "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" }, + "zod-to-json-schema@3.25.2_zod@3.25.76": { + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dependencies": [ + "zod" + ] + }, "zod@3.25.76": { "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } @@ -1057,6 +1563,7 @@ "npm:@atproto/oauth-client-node@~0.4.8", "npm:@atproto/oauth-types@~0.7.5", "npm:@fontsource-variable/inter@^5.2.8", + "npm:@modelcontextprotocol/sdk@^1.29.0", "npm:@sveltejs/adapter-node@^5.5.0", "npm:@sveltejs/kit@^2.63.0", "npm:@sveltejs/vite-plugin-svelte@^7.1.2", @@ -1066,7 +1573,8 @@ "npm:svelte-check@^4.6.0", "npm:svelte@^5.56.1", "npm:typescript@^6.0.3", - "npm:vite@^8.0.16" + "npm:vite@^8.0.16", + "npm:zod@^3.25.76" ] } } diff --git a/migrations/006_api_tokens.sql b/migrations/006_api_tokens.sql new file mode 100644 index 0000000..9a1c2e0 --- /dev/null +++ b/migrations/006_api_tokens.sql @@ -0,0 +1,52 @@ +-- MCP server (add-mcp-server). Agents reach the ledger over MCP authenticated by +-- a bearer API token, and their categorizations are recorded as a distinct, +-- honest provenance source rather than wearing a person's identity. + +-- One row per minted token. Only the hash is stored: a leaked database must not +-- hand over live agent credentials (sessions set the same precedent). `user_did` +-- is the creator on a multi-user server and NULL in single-user local mode +-- (add-desktop-local-remote-modes). `last_used_at` turns a forgotten token into +-- something visible and revocable rather than an invisible standing grant. +CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + scope TEXT NOT NULL CHECK (scope IN ('read', 'readwrite')), + user_did TEXT REFERENCES users (did), -- creator; NULL in single-user local mode + created_at TEXT NOT NULL, + last_used_at TEXT, + -- Revocation is soft: a token that has authored agent categorization events + -- is referenced by the append-only event log (api_token_id below), so it is + -- never deleted. Its label must keep resolving in old provenance; verify + -- simply refuses a revoked token. + revoked_at TEXT +); + +-- Widen categorization_events.source to admit 'agent'. SQLite cannot alter a +-- CHECK constraint in place, so the table is rebuilt. It is a leaf in the FK +-- graph (nothing references it; it only references transactions/categories/ +-- rules/users/api_tokens), so the drop-and-rename is safe with foreign_keys ON. +-- The append-only guarantee is preserved: every existing event is copied +-- verbatim, ids intact, api_token_id NULL (none were agent-sourced). +CREATE TABLE categorization_events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transaction_id INTEGER NOT NULL REFERENCES transactions (id), + category_id INTEGER REFERENCES categories (id), -- NULL clears the category + source TEXT NOT NULL CHECK (source IN ('rule', 'manual', 'reconciliation', 'agent')), + rule_id INTEGER REFERENCES rules (id), + actor_did TEXT REFERENCES users (did), + api_token_id INTEGER REFERENCES api_tokens (id), + created_at TEXT NOT NULL, + CHECK (source != 'rule' OR rule_id IS NOT NULL), + CHECK (source != 'manual' OR actor_did IS NOT NULL), + CHECK (source != 'agent' OR api_token_id IS NOT NULL) +); + +INSERT INTO categorization_events_new + (id, transaction_id, category_id, source, rule_id, actor_did, api_token_id, created_at) +SELECT id, transaction_id, category_id, source, rule_id, actor_did, NULL, created_at +FROM categorization_events; + +DROP TABLE categorization_events; +ALTER TABLE categorization_events_new RENAME TO categorization_events; +CREATE INDEX idx_catevents_transaction ON categorization_events (transaction_id); diff --git a/openspec/changes/add-mcp-server/design.md b/openspec/changes/add-mcp-server/design.md index 2a9f349..b0e6279 100644 --- a/openspec/changes/add-mcp-server/design.md +++ b/openspec/changes/add-mcp-server/design.md @@ -273,6 +273,19 @@ current max): Every existing categorization event reads back with `api_token_id IS NULL`, which is correct — none were agent-sourced. Forward-only, no data rewrite of values. +## Resolved During Implementation + +- **Revocation must be soft, not a hard delete.** `categorization_events.api_token_id` + is a foreign key to `api_tokens`, so once a token has authored even one agent + categorization, `DELETE FROM api_tokens` fails the FK constraint — and forcing + it would orphan the provenance the whole feature exists to preserve. Revoke + therefore stamps a `revoked_at` column: `verifyToken` refuses a revoked token + immediately (satisfying "revocation takes effect immediately"), `listTokens` + hides it from the management list, and the row survives so its label keeps + resolving in the append-only event history. Found by revoking a token that had + categorized a live transaction; the migration and service were updated and a + regression test added. `api_tokens` gains `revoked_at TEXT` (nullable). + ## Open Questions - **Token label uniqueness** — should two tokens be allowed the same label? Leaning diff --git a/openspec/changes/add-mcp-server/tasks.md b/openspec/changes/add-mcp-server/tasks.md index 5c0c18d..6bcef9d 100644 --- a/openspec/changes/add-mcp-server/tasks.md +++ b/openspec/changes/add-mcp-server/tasks.md @@ -1,122 +1,186 @@ ## 1. Transport spike (de-risk first) -- [ ] 1.1 Add `@modelcontextprotocol/sdk` (npm) as a dependency. Stand up a +- [x] 1.1 Add `@modelcontextprotocol/sdk` (npm) as a dependency. Stand up a minimal `src/routes/mcp/+server.ts` that binds `McpServer` to `WebStandardStreamableHTTPServerTransport` in stateless mode and registers one trivial read tool. -- [ ] 1.2 Confirm under Deno (both `deno task dev` via Vite and a `deno task +- [x] 1.2 Confirm under Deno (both `deno task dev` via Vite and a `deno task build` node-adapter build) that an MCP client completes `initialize` + `tools/list` + one `tools/call` round-trip against `/mcp`, with no Node `IncomingMessage`/`ServerResponse` shimming required. Confirm `deno task check` stays green with the new dependency (watch for the JSR-vs-npm resolution trap that bit `csv-parse`). + **Spike findings (resolved):** SDK `1.29.0`. Transport is + `WebStandardStreamableHTTPServerTransport` from + `@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js`; + `handleRequest(req: Request): Promise` slots straight into a + SvelteKit `+server.ts` (POST/GET/DELETE all delegate to it). Stateless mode + = `sessionIdGenerator: undefined`; separate HTTP requests work by sending + the `MCP-Protocol-Version` header after `initialize`. Deep `.js` imports + resolve under `moduleResolution: bundler` — `deno task check` green (0 + errors), no JSR/npm trap. `deno task build` (node adapter) bundles the SDK + cleanly. Zod `3.25.76` comes transitively (for task 6 input schemas). The + transport accepts an `authInfo` via `handleRequest`'s options — the seam + for task 6 to pass the bearer principal into tool callbacks. Round-trip + verified live: `initialize` → 200, `tools/list` → `ping`, `tools/call + ping` → `pong`. NOTE: `/mcp` is temporarily in `PUBLIC_PATHS` + (`hooks.server.ts`) for the spike; task 4 removes it and adds bearer auth. + ## 2. Schema -- [ ] 2.1 Add a migration (`migrations/00N_api_tokens.sql`, N = current max + 1) +- [x] 2.1 Add a migration (`migrations/00N_api_tokens.sql`, N = current max + 1) creating `api_tokens`: `id`, `label` (NOT NULL), `token_hash` (NOT NULL, UNIQUE), `scope` NOT NULL CHECK in (`read`, `readwrite`), `user_did` (nullable, FK to users), `created_at` (NOT NULL), `last_used_at` - (nullable). -- [ ] 2.2 In the same migration, add `api_token_id INTEGER REFERENCES api_tokens + (nullable). — `migrations/006_api_tokens.sql`. +- [x] 2.2 In the same migration, add `api_token_id INTEGER REFERENCES api_tokens (id)` (nullable) to `categorization_events`, and widen the `source` value set to admit `agent`. If `source` is constrained by a CHECK, rebuild the table following the existing migration table-rebuild pattern, preserving every row and the append-only guarantee; if it is unconstrained, document - that no rebuild is needed. -- [ ] 2.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to a + that no rebuild is needed. — `source` had a CHECK; table rebuilt + (leaf table, no incoming FKs) with a new `source != 'agent' OR + api_token_id IS NOT NULL` invariant. No prior table-rebuild pattern + existed in migrations; this is the first. +- [x] 2.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to a populated database, that existing `categorization_events` read back with - `api_token_id IS NULL`, and that an `agent`-source event inserts. + `api_token_id IS NULL`, and that an `agent`-source event inserts. Also + relaxed the csv test's brittle exact-migration-count assertion to a + `schema_version` membership check (it broke when 006 was added, and would + have broken on every future migration). ## 3. API token service -- [ ] 3.1 Create `src/lib/server/services/api-tokens.ts`: `mintToken(db, label, +- [x] 3.1 Create `src/lib/server/services/api-tokens.ts`: `mintToken(db, label, scope, userDid?)` returning the one-time plaintext plus the stored record; persist only the hash (hash the `randomBytes(32)` value; do not store plaintext). `verifyToken(db, plaintext)` returning a principal (`{ id, scope, userDid }`) or null, and touching `last_used_at` on success. - `listTokens(db)`, `revokeToken(db, id)`. -- [ ] 3.2 Relative imports within `src/lib/server/` only, so the service and its + `listTokens(db)`, `revokeToken(db, id)`. — SHA-256 over a `qtm_`-prefixed + 256-bit random value; fast hash is sufficient for a high-entropy secret. +- [x] 3.2 Relative imports within `src/lib/server/` only, so the service and its test run under plain `deno test`. Add `api-tokens.test.ts`: mint→verify round-trip, hash-not-plaintext storage, revoked token fails verify, - `last_used_at` advances, unknown token returns null. + `last_used_at` advances, unknown token returns null. — 6 tests, all green. ## 4. Bearer authentication in the hook -- [ ] 4.1 In `src/hooks.server.ts`, extend `handle`: when no session cookie +- [x] 4.1 In `src/hooks.server.ts`, extend `handle`: when no session cookie resolves a user, read `Authorization: Bearer ` and call `verifyToken`. Attach the principal to `event.locals.apiToken` (id, scope, userDid?), leaving `event.locals.user` for cookie sessions. Do not add `/mcp` to `PUBLIC_PATHS`; the existing guard rejects unauthenticated MCP - requests. -- [ ] 4.2 Update `app.d.ts` `App.Locals` with the optional `apiToken` principal. -- [ ] 4.3 Confirm cookie-session routes are entirely unaffected (a request with a + requests. — Removed the spike's temporary `/mcp` public entry. Guard gives + `/mcp` a clean 401 (not the browser login redirect) when unauthenticated. +- [x] 4.2 Update `app.d.ts` `App.Locals` with the optional `apiToken` principal. + — Imports `TokenPrincipal` from the service. +- [x] 4.3 Confirm cookie-session routes are entirely unaffected (a request with a cookie ignores any bearer header; a request with neither is redirected/ - rejected as today). + rejected as today). — Verified live over HTTP: unauthenticated `/mcp` → + 401, valid bearer → 200 initialize, bad bearer → 401, `/ledger` without a + cookie → 303 `/login` (unchanged). ## 5. Agent categorization source -- [ ] 5.1 In `src/lib/server/services/categorization.ts`, add `"agent"` to +- [x] 5.1 In `src/lib/server/services/categorization.ts`, add `"agent"` to `EventSource`; add optional `apiTokenId` to `CategorizationEventInput`; enforce that `agent` events carry an `apiTokenId` and no `actorDid` (mirroring the `manual`-requires-`actorDid` invariant). Persist `api_token_id` in the event insert. -- [ ] 5.2 Add `categorizeByAgent(db, transactionId, categoryId, apiTokenId)` +- [x] 5.2 Add `categorizeByAgent(db, transactionId, categoryId, apiTokenId)` paralleling `categorizeManually`, and extend the manual-outranks rule so an - agent write refuses a transaction whose latest event is `manual`. -- [ ] 5.3 Extend `listEvents` selection and the `CategorizationEvent` shape to + agent write refuses a transaction whose latest event is `manual`. — Returns + `boolean` (false = refused because a manual event is latest); tool wrapper + (task 6) surfaces the refusal to the agent. +- [x] 5.3 Extend `listEvents` selection and the `CategorizationEvent` shape to carry the agent token's label (join `api_tokens`) for provenance display. -- [ ] 5.4 Tests in `categorization.test.ts`: an `agent` event requires a token id; + — Added `apiTokenId` and `actorTokenLabel` to the event shape. +- [x] 5.4 Tests in `categorization.test.ts`: an `agent` event requires a token id; an agent write over a `manual` latest event is refused; over a `rule` or empty latest event it succeeds; history lists the agent event with its - token label. + token label. — New file, 5 tests; full suite 108 passed, check green. ## 6. MCP server and tool catalog -- [ ] 6.1 Create the MCP server module wiring the tool catalog to services. Group +- [x] 6.1 Create the MCP server module wiring the tool catalog to services. Group tools by required scope; read the authenticating principal's scope from `event.locals.apiToken` and refuse write tools to a `read` token at - dispatch, without reaching the service. -- [ ] 6.2 Implement the nine read tools as thin wrappers: `list_accounts`, + dispatch, without reaching the service. — `src/lib/server/mcp/server.ts`; + `buildMcpServer(db, principal)` closes over the principal per request; write + tools call a `requireWrite()` guard that returns an error result for `read` + scope. All 15 tools are always listed (a read token sees them, is refused + execution) per the spec. +- [x] 6.2 Implement the nine read tools as thin wrappers: `list_accounts`, `list_transactions`, `get_transaction_history`, `get_monthly_report`, `get_net_worth`, `list_categories`, `list_rules`, `probe_rule`, `get_sync_status`. Each returns the service's plain data; none mutate. -- [ ] 6.3 Implement the six write tools: `categorize_transaction` +- [x] 6.3 Implement the six write tools: `categorize_transaction` (`categorizeByAgent`, threading the token id), `create_category`, `create_rule`, `set_rule_active`, `apply_rules`, `trigger_sync`. In each tool's description, direct the agent to `probe_rule` before `create_rule`. -- [ ] 6.4 Consider a minimum interval on `trigger_sync` in its wrapper so an agent + — `create_rule` uses `principal.userDid` as creator; errors clearly if the + token has no bound user (the local-mode case this change doesn't yet serve). +- [x] 6.4 Consider a minimum interval on `trigger_sync` in its wrapper so an agent loop cannot hammer the SimpleFIN Bridge (reuse the daily-sync rationale); - decide and document inline. -- [ ] 6.5 Replace the spike route with the real `src/routes/mcp/+server.ts`: a + decide and document inline. — 15-minute minimum; a too-soon call returns a + `skipped` result naming when the last sync ran. +- [x] 6.5 Replace the spike route with the real `src/routes/mcp/+server.ts`: a thin adapter handing the request to the transport-bound handler. Keep the handler free of SvelteKit/cookie reads so the desktop change can remount it. -- [ ] 6.6 Tests: a `read` token lists all tools but is refused each + — Route reads `locals.apiToken` (set by the hook) and delegates to + `handleMcpRequest`; the handler itself takes only `(request, db, principal)`. +- [x] 6.6 Tests: a `read` token lists all tools but is refused each write tool; a `readwrite` token categorizes a transaction and the resulting event is `agent`-sourced with the token id; `probe_rule` mutates nothing. + — `mcp/server.test.ts` drives the real transport in-process via + `handleMcpRequest` + constructed `Request`s; 4 tests, all green. ## 7. Settings: connect an agent -- [ ] 7.1 Add a Settings section "Connect an agent": list existing tokens (label, +- [x] 7.1 Add a Settings section "Connect an agent": list existing tokens (label, scope, created, last used) and a mint form (label + scope). On mint, show the token value once with a copy affordance and a plain caution that it - will not be shown again. -- [ ] 7.2 Add the revoke action with a confirmation naming the token label. -- [ ] 7.3 Render an empty state (one sentence, one action) for no-tokens-yet. + will not be shown again. — Verified live: the mint action returns the + one-time token + label; the reveal block shows it with a Copy button. +- [x] 7.2 Add the revoke action with a confirmation naming the token label. + — Revoke is soft (see design's Resolved-During-Implementation note); the + row's label survives for provenance. Verified the action removes the token + from the live list. +- [x] 7.3 Render an empty state (one sentence, one action) for no-tokens-yet. Style per DESIGN.md: monospace for the token value, tabular numerals on - timestamps, no new red unless data is at risk. + timestamps, no new red unless data is at risk. — The MCP URL and token + value use `--font-mono`; timestamps carry `tnum`; the reveal uses a neutral + `--bg-sunken` panel, no alarm color. ## 8. Verification -- [ ] 8.1 Run `deno task test` and `deno task check`. -- [ ] 8.2 Drive a real MCP client (e.g. Claude with the server added by URL + +- [x] 8.1 Run `deno task test` and `deno task check`. — 113 passed, 0 failed; + check 0 errors across 652 files. +- [x] 8.2 Drive a real MCP client (e.g. Claude with the server added by URL + pasted token) end to end in dev: read the current month's report, list uncategorized transactions, probe a pattern, create a rule, categorize a handful of transactions, and confirm in the web ledger that each shows an `agent` provenance badge with the token label — and that a manually - categorized transaction is left untouched by an agent attempt. -- [ ] 8.3 Confirm a `read`-scope token can perform every read tool and is refused - every write tool. -- [ ] 8.4 Revoke the token mid-session and confirm the next tool call is rejected. -- [ ] 8.5 Confirm the web app is unchanged for cookie users: login, categorize, - logout all behave as before. + categorized transaction is left untouched by an agent attempt. — Driven via + real MCP JSON-RPC over the live `/mcp` route: `initialize`, `tools/list` + (15), read tools (accounts, net worth, sync status, list transactions, + list categories), and `categorize_transaction` on a live uncategorized + row, then `get_transaction_history` confirmed `source: agent` with the + token label and no actor DID. Rule creation, probe, and manual-outranks are + covered by the in-process integration tests (`mcp/server.test.ts`, + `categorization.test.ts`). +- [x] 8.3 Confirm a `read`-scope token can perform every read tool and is refused + every write tool. — Verified live: a read token reads `list_categories` + (no error) and is refused `categorize_transaction` with the read-write + message; the in-process test refuses all six write tools for a read token. +- [x] 8.4 Revoke the token mid-session and confirm the next tool call is rejected. + — Verified live: an authenticated `get_net_worth` returned 200, the token + was soft-revoked, and the identical next call returned 401. +- [x] 8.5 Confirm the web app is unchanged for cookie users: login, categorize, + logout all behave as before. — The hook consults a bearer token only when + no cookie session resolves, so the cookie path is structurally untouched; + verified a cookie session renders `/settings` (including the new section) + and that a request with neither cookie nor token still redirects `/ledger` + → `/login`. Full OAuth login/logout not driven (needs a live ATProto + provider); the cookie-resolution code is unchanged from before this change. diff --git a/package.json b/package.json index 243adbe..dbb649e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "@atproto/oauth-client-node": "^0.4.8", "@atproto/oauth-types": "^0.7.5", "@fontsource-variable/inter": "^5.2.8", - "csv-parse": "^5.6.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "csv-parse": "^5.6.0", + "zod": "^3.25.76" }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.0", diff --git a/src/app.d.ts b/src/app.d.ts index 1eaed63..e4debe9 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,10 +1,16 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { TokenPrincipal } from "$lib/server/services/api-tokens"; + declare global { namespace App { // interface Error {} interface Locals { user: { did: string; handle: string } | null; + // A verified bearer API token, when a request presents one instead of a + // session cookie (add-mcp-server). Null for ordinary cookie-authenticated + // web requests. The MCP endpoint reads its scope to gate write tools. + apiToken: TokenPrincipal | null; } // interface PageData {} // interface PageState {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 4e113c6..5fc5b96 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -7,6 +7,7 @@ import { deleteExpiredSessions, getSessionUser, } from "$lib/server/services/sessions"; +import { verifyToken } from "$lib/server/services/api-tokens"; import { runSync } from "$lib/server/services/sync"; export const init: ServerInit = async () => { @@ -44,12 +45,34 @@ const PUBLIC_PATHS = new Set([ "/healthz", ]); +/** Extract a bearer token from an Authorization header, or null. */ +function readBearer(header: string | null): string | null { + if (!header) return null; + const match = header.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : null; +} + export const handle: Handle = async ({ event, resolve }) => { - const token = event.cookies.get(SESSION_COOKIE); - event.locals.user = token ? getSessionUser(getDb(), token) : null; + const cookie = event.cookies.get(SESSION_COOKIE); + event.locals.user = cookie ? getSessionUser(getDb(), cookie) : null; + + // Bearer API token (add-mcp-server): consulted only when there is no cookie + // session, so the web app's cookie path is entirely unchanged. Resolves the + // principal the MCP endpoint uses for scope gating and agent attribution. + if (!event.locals.user) { + const bearer = readBearer(event.request.headers.get("authorization")); + event.locals.apiToken = bearer ? verifyToken(getDb(), bearer) : null; + } else { + event.locals.apiToken = null; + } const path = event.url.pathname; - if (!event.locals.user && !PUBLIC_PATHS.has(path)) { + const authenticated = event.locals.user || event.locals.apiToken; + if (!authenticated && !PUBLIC_PATHS.has(path)) { + // API endpoints answer 401; browser routes get the login redirect. + if (path === "/mcp" || path.startsWith("/mcp/")) { + return new Response("Unauthorized", { status: 401 }); + } redirect(303, "/login"); } if (event.locals.user && path === "/login") { diff --git a/src/lib/server/db.test.ts b/src/lib/server/db.test.ts index dfc66b8..6f85c9a 100644 --- a/src/lib/server/db.test.ts +++ b/src/lib/server/db.test.ts @@ -88,10 +88,11 @@ Deno.test("csv import migration applies to a populated database", () => { VALUES ('acct-1', 'sfin-1', 1700000000, -1234, 'COFFEE', ?)`, ).run(now); - const applied = runMigrations(db, MIGRATIONS_DIR); - if (applied !== 1) { - throw new Error(`expected only 005 to apply, got ${applied}`); - } + runMigrations(db, MIGRATIONS_DIR); + const applied005 = db.prepare( + "SELECT 1 FROM schema_version WHERE version = 5", + ).get(); + if (!applied005) throw new Error("expected migration 005 to apply"); const row = db.prepare( "SELECT import_id FROM transactions WHERE sfin_id = 'sfin-1'", @@ -134,6 +135,123 @@ Deno.test("imports status is a closed enum", () => { db.close(); }); +Deno.test("api_tokens migration applies to a populated database", () => { + // Stage only the migrations before 006, populate a categorization event, then + // upgrade — the path an existing install actually takes through the rebuild. + const stageDir = Deno.makeTempDirSync(); + for (const entry of Deno.readDirSync(MIGRATIONS_DIR)) { + const version = Number(entry.name.match(/^(\d+)_/)?.[1]); + if (Number.isFinite(version) && version < 6) { + Deno.copyFileSync( + join(MIGRATIONS_DIR, entry.name), + join(stageDir, entry.name), + ); + } + } + + const dbPath = join(Deno.makeTempDirSync(), "test.db"); + const db = openDatabase(dbPath, stageDir); + + const now = new Date().toISOString(); + db.prepare("INSERT INTO connections (access_url, claimed_at) VALUES (?, ?)") + .run("https://example.test", now); + db.prepare( + `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) + VALUES ('acct-1', 1, 'Checking', 'USD', 'ACTIVE', ?)`, + ).run(now); + db.prepare( + `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, created_at) + VALUES ('acct-1', 'sfin-1', 1700000000, -1234, 'COFFEE', ?)`, + ).run(now); + db.prepare("INSERT INTO users (did, handle, created_at) VALUES (?, ?, ?)") + .run("did:plc:alice", "alice.test", now); + const txId = (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") + .get() as { id: number }).id; + // A pre-existing manual event that the rebuild must copy verbatim. + db.prepare( + `INSERT INTO categorization_events (transaction_id, source, actor_did, created_at) + VALUES (?, 'manual', 'did:plc:alice', ?)`, + ).run(txId, now); + + runMigrations(db, MIGRATIONS_DIR); + const applied006 = db.prepare( + "SELECT 1 FROM schema_version WHERE version = 6", + ).get(); + if (!applied006) throw new Error("expected migration 006 to apply"); + + // The pre-existing event survived the table rebuild, ids and columns intact, + // and reads back as non-agent (api_token_id NULL). + const event = db.prepare( + "SELECT source, actor_did, api_token_id FROM categorization_events WHERE transaction_id = ?", + ).get(txId) as + | { source: string; actor_did: string | null; api_token_id: number | null } + | undefined; + if ( + !event || event.source !== "manual" || + event.actor_did !== "did:plc:alice" || event.api_token_id !== null + ) { + throw new Error( + `rebuilt event wrong or lost: ${JSON.stringify(event)}`, + ); + } + + // An agent event can now be inserted, and it must carry an api_token_id. + db.prepare( + `INSERT INTO api_tokens (label, token_hash, scope, created_at) + VALUES ('claude', 'hash-1', 'readwrite', ?)`, + ).run(now); + const tokenId = (db.prepare("SELECT id FROM api_tokens WHERE token_hash = 'hash-1'") + .get() as { id: number }).id; + db.prepare( + `INSERT INTO categorization_events (transaction_id, source, api_token_id, created_at) + VALUES (?, 'agent', ?, ?)`, + ).run(txId, tokenId, now); + + db.close(); +}); + +Deno.test("agent categorization event requires an api_token_id", () => { + const dbPath = join(Deno.makeTempDirSync(), "test.db"); + const db = openDatabase(dbPath, MIGRATIONS_DIR); + const now = new Date().toISOString(); + db.prepare("INSERT INTO connections (access_url, claimed_at) VALUES (?, ?)") + .run("https://example.test", now); + db.prepare( + `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) + VALUES ('acct-1', 1, 'Checking', 'USD', 'ACTIVE', ?)`, + ).run(now); + db.prepare( + `INSERT INTO transactions (account_id, sfin_id, amount_cents, description, created_at) + VALUES ('acct-1', 'sfin-1', -1234, 'COFFEE', ?)`, + ).run(now); + const txId = (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") + .get() as { id: number }).id; + + let threw = false; + try { + db.prepare( + `INSERT INTO categorization_events (transaction_id, source, created_at) + VALUES (?, 'agent', ?)`, + ).run(txId, now); + } catch { + threw = true; + } + if (!threw) throw new Error("expected agent-without-token CHECK violation"); + + let scopeThrew = false; + try { + db.prepare( + `INSERT INTO api_tokens (label, token_hash, scope, created_at) + VALUES ('x', 'h', 'bogus', ?)`, + ).run(now); + } catch { + scopeThrew = true; + } + if (!scopeThrew) throw new Error("expected api_tokens scope CHECK violation"); + + db.close(); +}); + function join(...parts: string[]) { return parts.join("/"); } diff --git a/src/lib/server/mcp/server.test.ts b/src/lib/server/mcp/server.test.ts new file mode 100644 index 0000000..c020afa --- /dev/null +++ b/src/lib/server/mcp/server.test.ts @@ -0,0 +1,193 @@ +/// +import { openDatabase } from "../db.ts"; +import type { DatabaseSync } from "node:sqlite"; +import { handleMcpRequest } from "./server.ts"; +import { mintToken, type TokenPrincipal } from "../services/api-tokens.ts"; + +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace(/^\/([A-Za-z]:)/, "$1"); + +const PROTOCOL = "2025-06-18"; + +function testDb(): DatabaseSync { + const db = openDatabase(`${Deno.makeTempDirSync()}/test.db`, MIGRATIONS_DIR); + const now = new Date().toISOString(); + db.prepare( + "INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t','tester',?)", + ).run(now); + db.prepare( + "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin',?)", + ).run(now); + db.prepare( + `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) + VALUES ('act-1', 1, 'Checking', 'USD', 'ACTIVE', ?)`, + ).run(now); + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Groceries','expense',?)", + ).run(now); + return db; +} + +function insertTxn(db: DatabaseSync, sfinId: string): number { + const result = db.prepare( + `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, created_at) + VALUES ('act-1', ?, 1750000000, -1000, 'COFFEE', 0, ?)`, + ).run(sfinId, new Date().toISOString()); + return Number(result.lastInsertRowid); +} + +/** POST one JSON-RPC message through the real transport and parse the result. */ +async function rpc( + db: DatabaseSync, + principal: TokenPrincipal, + method: string, + params: unknown, + id = 1, +): Promise> { + const request = new Request("http://local/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "mcp-protocol-version": PROTOCOL, + }, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + const response = await handleMcpRequest(request, db, principal); + const body = JSON.parse(await response.text()); + return body.result as Record; +} + +/** The parsed JSON payload a tool returned in its text content. */ +function toolData(result: Record): unknown { + const content = result.content as { type: string; text: string }[]; + return JSON.parse(content[0].text); +} + +function isError(result: Record): boolean { + return result.isError === true; +} + +Deno.test("tools/list exposes the full 15-tool catalog to a read token", async () => { + const db = testDb(); + const { summary } = mintToken(db, "reader", "read"); + const principal: TokenPrincipal = { + id: summary.id, + scope: "read", + userDid: null, + }; + const result = await rpc(db, principal, "tools/list", {}); + const names = (result.tools as { name: string }[]).map((t) => t.name).sort(); + const expected = [ + "apply_rules", + "categorize_transaction", + "create_category", + "create_rule", + "get_monthly_report", + "get_net_worth", + "get_sync_status", + "get_transaction_history", + "list_accounts", + "list_categories", + "list_rules", + "list_transactions", + "probe_rule", + "set_rule_active", + "trigger_sync", + ]; + if (JSON.stringify(names) !== JSON.stringify(expected)) { + throw new Error(`catalog mismatch: ${JSON.stringify(names)}`); + } + db.close(); +}); + +Deno.test("a read token is refused every write tool", async () => { + const db = testDb(); + const txId = insertTxn(db, "t1"); + const { summary } = mintToken(db, "reader", "read"); + const principal: TokenPrincipal = { + id: summary.id, + scope: "read", + userDid: "did:plc:t", + }; + + const writeCalls: [string, unknown][] = [ + ["categorize_transaction", { transactionId: txId, categoryId: 1 }], + ["create_category", { name: "New", kind: "expense" }], + ["create_rule", { + matchType: "contains", + pattern: "COFFEE", + categoryId: 1, + }], + ["set_rule_active", { ruleId: 1, active: false }], + ["apply_rules", {}], + ["trigger_sync", {}], + ]; + for (const [name, args] of writeCalls) { + const result = await rpc(db, principal, "tools/call", { + name, + arguments: args, + }); + if (!isError(result)) { + throw new Error(`write tool ${name} was not refused for a read token`); + } + } + // Nothing was written: the transaction is still uncategorized. + const cat = (db.prepare("SELECT category_id FROM transactions WHERE id = ?") + .get(txId) as { category_id: number | null }).category_id; + if (cat !== null) throw new Error("a refused write still mutated data"); + db.close(); +}); + +Deno.test("a readwrite token categorizes, recording an agent event", async () => { + const db = testDb(); + const txId = insertTxn(db, "t1"); + const { summary } = mintToken(db, "claude", "readwrite"); + const principal: TokenPrincipal = { + id: summary.id, + scope: "readwrite", + userDid: "did:plc:t", + }; + const result = await rpc(db, principal, "tools/call", { + name: "categorize_transaction", + arguments: { transactionId: txId, categoryId: 1 }, + }); + const data = toolData(result) as { applied: boolean }; + if (!data.applied) throw new Error("categorize did not apply"); + + const event = db.prepare( + "SELECT source, api_token_id FROM categorization_events WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", + ).get(txId) as { source: string; api_token_id: number | null }; + if (event.source !== "agent" || event.api_token_id !== summary.id) { + throw new Error(`event not agent-sourced: ${JSON.stringify(event)}`); + } + db.close(); +}); + +Deno.test("probe_rule mutates nothing", async () => { + const db = testDb(); + insertTxn(db, "t1"); + const { summary } = mintToken(db, "reader", "read"); + const principal: TokenPrincipal = { + id: summary.id, + scope: "read", + userDid: null, + }; + const before = (db.prepare( + "SELECT COUNT(*) AS n FROM categorization_events", + ).get() as { n: number }).n; + + const result = await rpc(db, principal, "tools/call", { + name: "probe_rule", + arguments: { matchType: "contains", pattern: "COFFEE" }, + }); + const data = toolData(result) as { uncategorizedMatchCount: number }; + if (data.uncategorizedMatchCount !== 1) { + throw new Error(`expected 1 match, got ${data.uncategorizedMatchCount}`); + } + const after = (db.prepare( + "SELECT COUNT(*) AS n FROM categorization_events", + ).get() as { n: number }).n; + if (before !== after) throw new Error("probe_rule wrote an event"); + db.close(); +}); diff --git a/src/lib/server/mcp/server.ts b/src/lib/server/mcp/server.ts new file mode 100644 index 0000000..43ed36b --- /dev/null +++ b/src/lib/server/mcp/server.ts @@ -0,0 +1,309 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { z } from "zod"; +import type { DatabaseSync } from "node:sqlite"; +import type { TokenPrincipal } from "../services/api-tokens.ts"; + +import { listAccounts, listStaleAccounts } from "../services/accounts.ts"; +import { type LedgerFilters, listLedger, listMonths } from "../services/ledger.ts"; +import { categorizeByAgent, listEvents } from "../services/categorization.ts"; +import { monthlyReport, netWorthSeries, pendingStats } from "../services/reports.ts"; +import { type CategoryKind, createCategory, listCategories } from "../services/categories.ts"; +import { + applyRulesToUncategorized, + countRuleMatches, + createRule, + listRules, + probeRule, + type RuleProbe, + ruleMatchHealth, + setRuleActive, +} from "../services/rules.ts"; +import { getConnectionErrors, getLastSync } from "../services/sync-status.ts"; +import { runSync } from "../services/sync.ts"; + +// A tool result carrying JSON data as text. Every tool returns plain service +// data this way; the agent reads structured JSON, and any MCP host renders it. +type ToolResult = { + content: { type: "text"; text: string }[]; + isError?: boolean; +}; + +function json(data: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function error(message: string): ToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} + +// An agent looping on trigger_sync would hammer the SimpleFIN Bridge, which +// refreshes bank data only ~daily (the rationale behind the server's daily +// cron). Refuse a sync that follows too closely on the last one. +const MIN_SYNC_INTERVAL_MS = 15 * 60 * 1000; + +const probeShape = { + matchType: z.enum(["exact", "contains"]), + pattern: z.string().min(1), + amountCents: z.number().int().nullable().optional(), +}; + +/** + * Build a fresh MCP server for one request, with the database and the + * authenticating token principal captured in closure. Read tools require any + * valid token; write tools require a `readwrite` scope and are refused (not + * hidden) for a `read` token. The server is transport-agnostic — it never reads + * a cookie or SvelteKit event — so the desktop change can remount it verbatim. + */ +export function buildMcpServer( + db: DatabaseSync, + principal: TokenPrincipal, +): McpServer { + const server = new McpServer({ name: "quantum", version: "1.0.0" }); + + const requireWrite = (): ToolResult | null => + principal.scope === "readwrite" + ? null + : error( + "This tool requires a read-write token. The presented token is read-only.", + ); + + // ---- Read tools (any scope) ------------------------------------------ + + server.registerTool("list_accounts", { + description: + "List all accounts with balances and classification, plus which are stale (no recent successful sync).", + }, () => + json({ + accounts: listAccounts(db), + stale: listStaleAccounts(db), + })); + + server.registerTool("list_transactions", { + description: + "List transactions, filterable by account, category (a category id or 'uncategorized'), month (YYYY-MM), pending state, source, and a free-text query. Also returns the months that have data.", + inputSchema: { + accountId: z.string().optional(), + category: z.union([z.number().int(), z.literal("uncategorized")]) + .optional(), + month: z.string().regex(/^\d{4}-\d{2}$/).optional(), + pending: z.boolean().optional(), + source: z.enum(["synced", "imported"]).optional(), + q: z.string().optional(), + limit: z.number().int().positive().optional(), + }, + }, (args: LedgerFilters) => + json({ + transactions: listLedger(db, args), + availableMonths: listMonths(db), + })); + + server.registerTool("get_transaction_history", { + description: + "The full, ordered categorization history (provenance chain) of one transaction: every rule, person, reconciliation, or agent event that set its category.", + inputSchema: { transactionId: z.number().int() }, + }, ({ transactionId }: { transactionId: number }) => + json(listEvents(db, transactionId))); + + server.registerTool("get_monthly_report", { + description: + "Income vs. expense totals by category for one month (YYYY-MM), plus pending-transaction stats for that month.", + inputSchema: { month: z.string().regex(/^\d{4}-\d{2}$/) }, + }, ({ month }: { month: string }) => + json({ + report: monthlyReport(db, month), + pending: pendingStats(db, month), + })); + + server.registerTool("get_net_worth", { + description: + "The net-worth-over-time series, one point per date across all non-hidden accounts.", + }, () => json(netWorthSeries(db))); + + server.registerTool("list_categories", { + description: + "List categories with their kind (income, expense, or transfer). Pass activeOnly to exclude deactivated ones.", + inputSchema: { activeOnly: z.boolean().optional() }, + }, ({ activeOnly }: { activeOnly?: boolean }) => + json(listCategories(db, { activeOnly }))); + + server.registerTool("list_rules", { + description: + "List categorization rules. Pass includeHealth to attach each rule's match health (when it last fired, recent pattern-hit/amount-miss drift).", + inputSchema: { + activeOnly: z.boolean().optional(), + includeHealth: z.boolean().optional(), + }, + }, ( + { activeOnly, includeHealth }: { + activeOnly?: boolean; + includeHealth?: boolean; + }, + ) => { + const rules = listRules(db, { activeOnly }); + if (!includeHealth) return json(rules); + return json( + rules.map((rule) => ({ ...rule, health: ruleMatchHealth(db, rule) })), + ); + }); + + server.registerTool("probe_rule", { + description: + "Dry-run a rule pattern WITHOUT creating it: returns how many uncategorized transactions it would newly categorize, plus the matching transactions labelled by their current status. Always probe before create_rule.", + inputSchema: probeShape, + }, (probe: RuleProbe) => + json({ + uncategorizedMatchCount: countRuleMatches(db, probe), + hits: probeRule(db, probe).slice(0, 200), + })); + + server.registerTool("get_sync_status", { + description: + "The most recent sync's outcome and timestamp, plus any current connection-level errors.", + }, () => + json({ + lastSync: getLastSync(db), + connectionErrors: getConnectionErrors(db), + })); + + // ---- Write tools (readwrite scope) ----------------------------------- + + server.registerTool("categorize_transaction", { + description: + "Set (or clear, with a null category) a transaction's category as the agent. Refused if a person manually set the category — humans outrank agents.", + inputSchema: { + transactionId: z.number().int(), + categoryId: z.number().int().nullable(), + }, + }, ( + { transactionId, categoryId }: { + transactionId: number; + categoryId: number | null; + }, + ) => { + const denied = requireWrite(); + if (denied) return denied; + const applied = categorizeByAgent( + db, + transactionId, + categoryId, + principal.id, + ); + return applied ? json({ applied: true }) : json({ + applied: false, + reason: + "Refused: a person manually set this transaction's category, which an agent may not overwrite.", + }); + }); + + server.registerTool("create_category", { + description: "Create a new category with a name and kind.", + inputSchema: { + name: z.string().min(1), + kind: z.enum(["income", "expense", "transfer"]), + }, + }, ({ name, kind }: { name: string; kind: CategoryKind }) => { + const denied = requireWrite(); + if (denied) return denied; + return json(createCategory(db, name, kind)); + }); + + server.registerTool("create_rule", { + description: + "Create a categorization rule. Probe the pattern with probe_rule first to see its blast radius. A text pattern is required; an amount constraint is optional.", + inputSchema: { + matchType: z.enum(["exact", "contains"]), + pattern: z.string().min(1), + categoryId: z.number().int(), + amountCents: z.number().int().nullable().optional(), + displayName: z.string().nullable().optional(), + }, + }, ( + { matchType, pattern, categoryId, amountCents, displayName }: { + matchType: "exact" | "contains"; + pattern: string; + categoryId: number; + amountCents?: number | null; + displayName?: string | null; + }, + ) => { + const denied = requireWrite(); + if (denied) return denied; + if (!principal.userDid) { + return error( + "Creating a rule requires a token bound to a user; this token has none.", + ); + } + return json(createRule(db, { + matchType, + pattern, + categoryId, + amountCents, + displayName, + createdByDid: principal.userDid, + })); + }); + + server.registerTool("set_rule_active", { + description: "Enable or disable a rule by id.", + inputSchema: { ruleId: z.number().int(), active: z.boolean() }, + }, ({ ruleId, active }: { ruleId: number; active: boolean }) => { + const denied = requireWrite(); + if (denied) return denied; + setRuleActive(db, ruleId, active); + return json({ ruleId, active }); + }); + + server.registerTool("apply_rules", { + description: + "Run the active rulebook over currently-uncategorized transactions, categorizing those that match. Optionally limit to specific rule ids. Never overwrites a manual choice. Returns how many transactions were categorized.", + inputSchema: { ruleIds: z.array(z.number().int()).optional() }, + }, ({ ruleIds }: { ruleIds?: number[] }) => { + const denied = requireWrite(); + if (denied) return denied; + return json({ categorized: applyRulesToUncategorized(db, { ruleIds }) }); + }); + + server.registerTool("trigger_sync", { + description: + "Pull fresh transaction data from SimpleFIN for every connection. SimpleFIN refreshes bank data roughly daily, so this is rate-limited; a call too soon after the last sync is skipped.", + }, async () => { + const denied = requireWrite(); + if (denied) return denied; + const last = getLastSync(db); + if (last) { + const sinceMs = Date.now() - new Date(last.fetchedAt).getTime(); + if (sinceMs < MIN_SYNC_INTERVAL_MS) { + return json({ + skipped: true, + reason: + `Last sync was ${Math.round(sinceMs / 60000)} minute(s) ago. ` + + "SimpleFIN updates roughly daily; try again later.", + lastSync: last, + }); + } + } + return json({ outcomes: await runSync(db) }); + }); + + return server; +} + +/** + * Handle one MCP request: build a per-request server (stateless), connect it to + * a web-standard transport, and let the transport produce the Response. Pure + * `Request → Response`, no SvelteKit or cookie dependency. + */ +export async function handleMcpRequest( + request: Request, + db: DatabaseSync, + principal: TokenPrincipal, +): Promise { + const server = buildMcpServer(db, principal); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless + enableJsonResponse: true, + }); + await server.connect(transport); + return transport.handleRequest(request); +} diff --git a/src/lib/server/services/api-tokens.test.ts b/src/lib/server/services/api-tokens.test.ts new file mode 100644 index 0000000..6c1228a --- /dev/null +++ b/src/lib/server/services/api-tokens.test.ts @@ -0,0 +1,98 @@ +/// +import { openDatabase } from "../db.ts"; +import { + listTokens, + mintToken, + revokeToken, + verifyToken, +} from "./api-tokens.ts"; + +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); + +function freshDb() { + return openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); +} + +Deno.test("mint returns a plaintext token that verifies to its principal", () => { + const db = freshDb(); + const { token, summary } = mintToken(db, "claude", "readwrite"); + if (!token.startsWith("qtm_")) throw new Error("token missing prefix"); + const principal = verifyToken(db, token); + if ( + !principal || principal.id !== summary.id || + principal.scope !== "readwrite" || principal.userDid !== null + ) { + throw new Error(`bad principal: ${JSON.stringify(principal)}`); + } + db.close(); +}); + +Deno.test("only the hash is stored, never the plaintext", () => { + const db = freshDb(); + const { token } = mintToken(db, "claude", "read"); + const row = db.prepare("SELECT token_hash FROM api_tokens").get() as { + token_hash: string; + }; + if (row.token_hash === token) { + throw new Error("plaintext token was stored"); + } + if (row.token_hash.includes(token) || token.includes(row.token_hash)) { + throw new Error("stored value is not an independent hash"); + } + db.close(); +}); + +Deno.test("an unknown token verifies to null", () => { + const db = freshDb(); + mintToken(db, "claude", "read"); + if (verifyToken(db, "qtm_not-a-real-token") !== null) { + throw new Error("unknown token should not verify"); + } + db.close(); +}); + +Deno.test("verify advances last_used_at", () => { + const db = freshDb(); + const { token, summary } = mintToken(db, "claude", "read"); + if (summary.lastUsedAt !== null) throw new Error("fresh token used?"); + verifyToken(db, token); + const used = listTokens(db).find((t) => t.id === summary.id)?.lastUsedAt; + if (!used) throw new Error("last_used_at not set after verify"); + db.close(); +}); + +Deno.test("a revoked token no longer verifies", () => { + const db = freshDb(); + const { token, summary } = mintToken(db, "claude", "readwrite"); + if (!verifyToken(db, token)) throw new Error("should verify before revoke"); + revokeToken(db, summary.id); + if (verifyToken(db, token) !== null) { + throw new Error("revoked token still verifies"); + } + if (listTokens(db).length !== 0) throw new Error("token not removed"); + db.close(); +}); + +Deno.test("scope and creator DID round-trip; list omits secrets", () => { + const db = freshDb(); + // A creating user must exist for the FK. + db.prepare("INSERT INTO users (did, handle, created_at) VALUES (?, ?, ?)") + .run("did:plc:bob", "bob.test", new Date().toISOString()); + mintToken(db, "read-only bot", "read", "did:plc:bob"); + const [summary] = listTokens(db); + if ( + summary.scope !== "read" || summary.userDid !== "did:plc:bob" || + summary.label !== "read-only bot" + ) { + throw new Error(`bad summary: ${JSON.stringify(summary)}`); + } + // The summary type carries no token or hash field at all. + if ("token" in summary || "tokenHash" in summary) { + throw new Error("summary leaked a secret field"); + } + db.close(); +}); diff --git a/src/lib/server/services/api-tokens.ts b/src/lib/server/services/api-tokens.ts new file mode 100644 index 0000000..d89e5f1 --- /dev/null +++ b/src/lib/server/services/api-tokens.ts @@ -0,0 +1,117 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; + +// Bearer API tokens for the MCP endpoint (design add-mcp-server). A token is an +// opaque, high-entropy value shown once at mint time; only its hash is stored, +// so a leaked database yields no live credentials. Because the token carries 256 +// bits of randomness, a plain SHA-256 lookup is sufficient — the slow KDFs that +// protect low-entropy passwords buy nothing here. + +const TOKEN_PREFIX = "qtm_"; // recognizable to the user and to secret scanners + +export type TokenScope = "read" | "readwrite"; + +/** The identity a verified token resolves to, attached to `event.locals`. */ +export interface TokenPrincipal { + id: number; + scope: TokenScope; + userDid: string | null; +} + +/** A token as listed in Settings — never includes the value or its hash. */ +export interface ApiTokenSummary { + id: number; + label: string; + scope: TokenScope; + userDid: string | null; + createdAt: string; + lastUsedAt: string | null; +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} + +/** + * Mint a token. Returns the one-time plaintext (never recoverable afterward) + * alongside the stored summary. `userDid` is the creator on a multi-user server + * and null in single-user local mode. + */ +export function mintToken( + db: DatabaseSync, + label: string, + scope: TokenScope, + userDid: string | null = null, +): { token: string; summary: ApiTokenSummary } { + const token = TOKEN_PREFIX + randomBytes(32).toString("base64url"); + const createdAt = new Date().toISOString(); + const result = db + .prepare( + `INSERT INTO api_tokens (label, token_hash, scope, user_did, created_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(label, hashToken(token), scope, userDid, createdAt); + return { + token, + summary: { + id: Number(result.lastInsertRowid), + label, + scope, + userDid, + createdAt, + lastUsedAt: null, + }, + }; +} + +/** + * Verify a presented token and, on success, advance its `last_used_at`. Returns + * the principal or null. A revoked (deleted) token fails here immediately. + */ +export function verifyToken( + db: DatabaseSync, + token: string, +): TokenPrincipal | null { + const row = db + .prepare( + "SELECT id, scope, user_did FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL", + ) + .get(hashToken(token)) as + | { id: number; scope: TokenScope; user_did: string | null } + | undefined; + if (!row) return null; + db.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?").run( + new Date().toISOString(), + row.id, + ); + return { id: row.id, scope: row.scope, userDid: row.user_did }; +} + +/** Live (non-revoked) tokens, for the management list. */ +export function listTokens(db: DatabaseSync): ApiTokenSummary[] { + const rows = db + .prepare( + `SELECT id, label, scope, user_did, created_at, last_used_at + FROM api_tokens WHERE revoked_at IS NULL ORDER BY id`, + ) + .all() as Record[]; + return rows.map((r) => ({ + id: r.id as number, + label: r.label as string, + scope: r.scope as TokenScope, + userDid: r.user_did as string | null, + createdAt: r.created_at as string, + lastUsedAt: r.last_used_at as string | null, + })); +} + +/** + * Soft-revoke: the token stops authenticating immediately, but the row is kept + * because the append-only categorization log may reference it for provenance. + */ +export function revokeToken(db: DatabaseSync, id: number): void { + db.prepare("UPDATE api_tokens SET revoked_at = ? WHERE id = ?").run( + new Date().toISOString(), + id, + ); +} diff --git a/src/lib/server/services/categorization.test.ts b/src/lib/server/services/categorization.test.ts new file mode 100644 index 0000000..3c7dac3 --- /dev/null +++ b/src/lib/server/services/categorization.test.ts @@ -0,0 +1,176 @@ +/// +import { openDatabase } from "../db.ts"; +import type { DatabaseSync } from "node:sqlite"; +import { + appendCategorizationEvent, + categorizeByAgent, + categorizeManually, + listEvents, +} from "./categorization.ts"; +import { mintToken, revokeToken, verifyToken } from "./api-tokens.ts"; + +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); + +function testDb(): DatabaseSync { + const db = openDatabase(`${Deno.makeTempDirSync()}/test.db`, MIGRATIONS_DIR); + const now = new Date().toISOString(); + db.prepare( + "INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t','tester',?)", + ).run(now); + db.prepare( + "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin',?)", + ).run(now); + db.prepare( + `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) + VALUES ('act-1', 1, 'Checking', 'USD', 'ACTIVE', ?)`, + ).run(now); + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Groceries','expense',?)", + ).run(now); + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Dining','expense',?)", + ).run(now); + return db; +} + +function insertTxn(db: DatabaseSync, sfinId: string): number { + const result = db + .prepare( + `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, created_at) + VALUES ('act-1', ?, 1750000000, -1000, 'COFFEE', 0, ?)`, + ) + .run(sfinId, new Date().toISOString()); + return Number(result.lastInsertRowid); +} + +function currentCategory(db: DatabaseSync, txId: number): number | null { + return (db.prepare("SELECT category_id FROM transactions WHERE id = ?") + .get(txId) as { category_id: number | null }).category_id; +} + +Deno.test("an agent event requires an api token id", () => { + const db = testDb(); + const txId = insertTxn(db, "t1"); + let threw = false; + try { + appendCategorizationEvent(db, { + transactionId: txId, + categoryId: 1, + source: "agent", + }); + } catch { + threw = true; + } + if (!threw) throw new Error("agent event without apiTokenId should throw"); + db.close(); +}); + +Deno.test("an agent event must not carry an actor DID", () => { + const db = testDb(); + const txId = insertTxn(db, "t1"); + const { summary } = mintToken(db, "claude", "readwrite"); + let threw = false; + try { + appendCategorizationEvent(db, { + transactionId: txId, + categoryId: 1, + source: "agent", + apiTokenId: summary.id, + actorDid: "did:plc:t", + }); + } catch { + threw = true; + } + if (!threw) throw new Error("agent event with actorDid should throw"); + db.close(); +}); + +Deno.test("agent categorizes an uncategorized transaction", () => { + const db = testDb(); + const txId = insertTxn(db, "t1"); + const { summary } = mintToken(db, "claude", "readwrite"); + const applied = categorizeByAgent(db, txId, 1, summary.id); + if (!applied) throw new Error("agent should categorize an empty transaction"); + if (currentCategory(db, txId) !== 1) throw new Error("category not applied"); + db.close(); +}); + +Deno.test("agent overwrites a rule-set category but is refused over a manual one", () => { + const db = testDb(); + const { summary } = mintToken(db, "claude", "readwrite"); + + // Latest event is `rule` → agent may recategorize. + const ruled = insertTxn(db, "t-rule"); + db.prepare( + "INSERT INTO rules (pattern, match_type, category_id, created_by_did, active, created_at) VALUES ('COFFEE','contains',1,'did:plc:t',1,?)", + ).run(new Date().toISOString()); + const ruleId = (db.prepare("SELECT id FROM rules").get() as { id: number }).id; + appendCategorizationEvent(db, { + transactionId: ruled, + categoryId: 1, + source: "rule", + ruleId, + }); + if (!categorizeByAgent(db, ruled, 2, summary.id)) { + throw new Error("agent should overwrite a rule-set category"); + } + if (currentCategory(db, ruled) !== 2) { + throw new Error("agent recategorization not applied"); + } + + // Latest event is `manual` → agent is refused, category unchanged. + const manual = insertTxn(db, "t-manual"); + categorizeManually(db, manual, 1, "did:plc:t"); + if (categorizeByAgent(db, manual, 2, summary.id)) { + throw new Error("agent must not overwrite a manual choice"); + } + if (currentCategory(db, manual) !== 1) { + throw new Error("manual category was overwritten"); + } + db.close(); +}); + +Deno.test("a token that authored agent events can still be revoked (soft), preserving provenance", () => { + const db = testDb(); + const txId = insertTxn(db, "t1"); + const { token, summary } = mintToken(db, "claude-bot", "readwrite"); + categorizeByAgent(db, txId, 1, summary.id); + + // Hard deletion would violate the categorization_events FK; soft-revoke must + // succeed and take effect immediately. + revokeToken(db, summary.id); + if (verifyToken(db, token) !== null) { + throw new Error("revoked token still verifies"); + } + // The event and its provenance label survive the revocation. + const agentEvent = listEvents(db, txId).find((e) => e.source === "agent"); + if (agentEvent?.actorTokenLabel !== "claude-bot") { + throw new Error("revocation destroyed the event's provenance label"); + } + db.close(); +}); + +Deno.test("history lists an agent event with its token label", () => { + const db = testDb(); + const txId = insertTxn(db, "t1"); + const { summary } = mintToken(db, "claude-bot", "readwrite"); + categorizeByAgent(db, txId, 1, summary.id); + + const events = listEvents(db, txId); + const agentEvent = events.find((e) => e.source === "agent"); + if (!agentEvent) throw new Error("agent event missing from history"); + if (agentEvent.actorTokenLabel !== "claude-bot") { + throw new Error(`wrong token label: ${agentEvent.actorTokenLabel}`); + } + if (agentEvent.actorDid !== null) { + throw new Error("agent event must not name a person"); + } + if (agentEvent.apiTokenId !== summary.id) { + throw new Error("agent event missing token id"); + } + db.close(); +}); diff --git a/src/lib/server/services/categorization.ts b/src/lib/server/services/categorization.ts index 0518e53..9c42ffa 100644 --- a/src/lib/server/services/categorization.ts +++ b/src/lib/server/services/categorization.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; -export type EventSource = "rule" | "manual" | "reconciliation"; +export type EventSource = "rule" | "manual" | "reconciliation" | "agent"; export interface CategorizationEventInput { transactionId: number; @@ -8,6 +8,7 @@ export interface CategorizationEventInput { source: EventSource; ruleId?: number; actorDid?: string; + apiTokenId?: number; // the acting API token for `agent` events } /** @@ -24,18 +25,26 @@ export function appendCategorizationEvent( if (input.source === "manual" && !input.actorDid) { throw new Error("manual events require actorDid"); } + if (input.source === "agent" && input.apiTokenId == null) { + throw new Error("agent events require apiTokenId"); + } + if (input.source === "agent" && input.actorDid) { + // An agent acts for a person but is never recorded as one. + throw new Error("agent events must not carry actorDid"); + } db.exec("BEGIN"); try { db.prepare( `INSERT INTO categorization_events - (transaction_id, category_id, source, rule_id, actor_did, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, + (transaction_id, category_id, source, rule_id, actor_did, api_token_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run( input.transactionId, input.categoryId, input.source, input.ruleId ?? null, input.actorDid ?? null, + input.apiTokenId ?? null, new Date().toISOString(), ); db.prepare("UPDATE transactions SET category_id = ? WHERE id = ?").run( @@ -59,6 +68,8 @@ export interface CategorizationEvent { rulePattern: string | null; actorDid: string | null; actorHandle: string | null; + apiTokenId: number | null; + actorTokenLabel: string | null; // the agent token's label, for `agent` events createdAt: string; } @@ -71,11 +82,13 @@ export function listEvents( .prepare( `SELECT e.id, e.transaction_id, e.category_id, c.name AS category_name, e.source, e.rule_id, r.pattern AS rule_pattern, - e.actor_did, u.handle AS actor_handle, e.created_at + e.actor_did, u.handle AS actor_handle, + e.api_token_id, k.label AS actor_token_label, e.created_at FROM categorization_events e LEFT JOIN categories c ON c.id = e.category_id LEFT JOIN rules r ON r.id = e.rule_id LEFT JOIN users u ON u.did = e.actor_did + LEFT JOIN api_tokens k ON k.id = e.api_token_id WHERE e.transaction_id = ? ORDER BY e.id`, ) @@ -90,6 +103,8 @@ export function listEvents( rulePattern: r.rule_pattern as string | null, actorDid: r.actor_did as string | null, actorHandle: r.actor_handle as string | null, + apiTokenId: r.api_token_id as number | null, + actorTokenLabel: r.actor_token_label as string | null, createdAt: r.created_at as string, })); } @@ -111,3 +126,33 @@ export function categorizeManually( actorDid, }); } + +/** + * Agent categorization (design add-mcp-server): permitted where no category is + * set or the latest event is `rule`, `reconciliation`, or `agent`, but never + * over a human's manual choice — the same "manual outranks everything" rule the + * rule engine already enforces. Returns `true` when applied, `false` when + * refused because a `manual` event is the transaction's latest. + */ +export function categorizeByAgent( + db: DatabaseSync, + transactionId: number, + categoryId: number | null, + apiTokenId: number, +): boolean { + const latestManual = db + .prepare( + `SELECT 1 FROM categorization_events e + WHERE e.transaction_id = ? AND e.source = 'manual' + AND e.id = (SELECT MAX(id) FROM categorization_events WHERE transaction_id = ?)`, + ) + .get(transactionId, transactionId); + if (latestManual) return false; + appendCategorizationEvent(db, { + transactionId, + categoryId, + source: "agent", + apiTokenId, + }); + return true; +} diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts index 9e02e5f..63f925a 100644 --- a/src/routes/(app)/settings/+page.server.ts +++ b/src/routes/(app)/settings/+page.server.ts @@ -1,5 +1,6 @@ import { fail } from "@sveltejs/kit"; import { getDb } from "$lib/server/db"; +import { getConfig } from "$lib/server/config"; import { claimSetupToken, listConnections, @@ -13,6 +14,12 @@ import { renameCategory, setCategoryActive, } from "$lib/server/services/categories"; +import { + listTokens, + mintToken, + revokeToken, + type TokenScope, +} from "$lib/server/services/api-tokens"; import { ClaimError } from "$lib/server/simplefin"; import type { Actions, PageServerLoad } from "./$types"; @@ -25,6 +32,8 @@ export const load: PageServerLoad = () => { })), lastSync: getLastSync(db), categories: listCategories(db), + apiTokens: listTokens(db), + appUrl: getConfig().appUrl, }; }; @@ -118,4 +127,27 @@ export const actions: Actions = { } return {}; }, + + mintApiToken: async ({ request, locals }) => { + const form = await request.formData(); + const label = String(form.get("label") ?? "").trim(); + const scope = String(form.get("scope") ?? "") as TokenScope; + if (!label) { + return fail(400, { tokenMessage: "Give the token a name." }); + } + if (scope !== "read" && scope !== "readwrite") { + return fail(400, { tokenMessage: "Pick an access level." }); + } + // A protected route: locals.user is present. The token is attributed to + // its creator so agent-authored events trace back to a person. + const { token } = mintToken(getDb(), label, scope, locals.user?.did ?? null); + // Returned once, for display; only its hash is stored. + return { mintedToken: token, mintedLabel: label }; + }, + + revokeApiToken: async ({ request }) => { + const form = await request.formData(); + revokeToken(getDb(), Number(form.get("id"))); + return { tokenMessage: "Token revoked." }; + }, }; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index e6ecfc3..09952db 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -140,6 +140,81 @@ let renamingId = $state(null); +
+

Connect an agent

+

+ Let an AI assistant read your ledger and help categorize it, over the + Model Context Protocol. Point the agent at + {data.appUrl}/mcp and give it a token below. + Read-only tokens can only look; read & write tokens can also + categorize transactions and manage rules. An agent's changes are always + labelled as the agent, never as you. +

+ + {#if form?.mintedToken} +
+

+ Copy {form.mintedLabel} now — it is shown once and + cannot be retrieved again. +

+
+ {form.mintedToken} + +
+
+ {/if} + + {#if form?.tokenMessage}{/if} + + {#if data.apiTokens.length === 0} +

No agent tokens yet. Create one to connect an assistant.

+ {:else} +
    + {#each data.apiTokens as token (token.id)} +
  • + + {token.label} + {token.scope === 'readwrite' ? 'read & write' : 'read-only'} + + · added {dateFmt.format(new Date(token.createdAt))} + {#if token.lastUsedAt} + · last used {dateFmt.format(new Date(token.lastUsedAt))} + {:else} + · never used + {/if} + + + +
    + + +
    +
    +
  • + {/each} +
+ {/if} + +
+ + + +
+
+ diff --git a/src/routes/mcp/+server.ts b/src/routes/mcp/+server.ts new file mode 100644 index 0000000..18f231d --- /dev/null +++ b/src/routes/mcp/+server.ts @@ -0,0 +1,20 @@ +import { getDb } from "$lib/server/db"; +import { handleMcpRequest } from "$lib/server/mcp/server"; +import type { RequestHandler } from "./$types"; + +// Thin adapter (add-mcp-server). The `handle` hook has already verified the +// bearer token and populated `event.locals.apiToken`; an unauthenticated +// request never reaches here (it is answered 401 in the hook). All request +// handling — routing GET/POST/DELETE, JSON-RPC, streaming — lives in the +// transport-bound handler, which stays free of SvelteKit specifics so the +// desktop change can remount it on a loopback listener. +const handle: RequestHandler = ({ request, locals }) => { + // Belt-and-suspenders: the hook guarantees a principal, but never trust a + // route to run behind the guard it expects. + if (!locals.apiToken) return new Response("Unauthorized", { status: 401 }); + return handleMcpRequest(request, getDb(), locals.apiToken); +}; + +export const POST = handle; +export const GET = handle; +export const DELETE = handle; -- 2.51.2