diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 6239d28..8d0a688 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -32,7 +32,7 @@ reviews: instructions: 後で見返した時に分かるように、適切に英語でコメントが書かれている。 mode: warning - name: インポート順 - instructions: TypeScriptのインポート順番は、標準ライブラリ・サードパーティーライブラリ、プロジェクトのライブラリ。さらに英語の昇順で並んでいること。 + instructions: TypeScriptのインポート順番は、biome正準順に従っていること。 mode: warning finishing_touches: simplify: @@ -43,4 +43,4 @@ reviews: instructions: 後で見返した時に分かるように、適切に英語でコメントを記述する - enabled: true name: Import - instructions: TypeScriptのインポート順番を、標準ライブラリ・サードパーティーライブラリ、プロジェクトのライブラリ。さらに英語の昇順に変更する + instructions: TypeScriptのインポート順番を、biome正準順に従って整理する diff --git a/.tmp/tasks.md b/.tmp/tasks.md index 4aa9000..f6b36c9 100644 --- a/.tmp/tasks.md +++ b/.tmp/tasks.md @@ -173,29 +173,27 @@ ## T10. MCP サーバー (F-7) -- [ ] T10.1 MCP SDK 採用(`@modelcontextprotocol/sdk`、Bun 互換性確認) +- [x] T10.1 MCP SDK 採用(`@modelcontextprotocol/sdk`、Bun 互換性確認) - 検証: 最小サーバーが立ち上がる -- [ ] T10.2 stdio トランスポート起動(`dennoh serve`) +- [x] T10.2 stdio トランスポート起動(`dennoh serve`) - 検証: stdin に initialize リクエストを流して initialize レスポンスを得る -- [ ] T10.3 stdout 汚染防止: ログ・診断は stderr に固定(T0.6 のロガーで保証) +- [x] T10.3 stdout 汚染防止: ログ・診断は stderr に固定(T0.6 のロガーで保証) - 検証: stdout を JSON 行のみに保つテスト -- [ ] T10.4 `save_memory(content, source?)` ツール登録 +- [x] T10.4 `save_memory(content, source?)` ツール登録 - 検証: ツール呼び出しでファイルが生成される統合テスト -- [ ] T10.5 `update_memory(id, content)` ツール登録 +- [x] T10.5 `update_memory(id, content)` ツール登録 - 検証: 既存ノートが更新される -- [ ] T10.6 `delete_memory(id)` ツール登録 +- [x] T10.6 `delete_memory(id)` ツール登録 - 検証: ノートが削除される -- [ ] T10.7 `search_memory(query, filters?, limit?)` ツール登録 +- [x] T10.7 `search_memory(query, filters?, limit?)` ツール登録 - 検証: クエリで検索結果が返る -- [ ] T10.8 `list_recent(limit?)` ツール登録 +- [x] T10.8 `list_recent(limit?)` ツール登録 - 検証: 最近のノート一覧が返る -- [ ] T10.9 `get_note(id)` ツール登録 +- [x] T10.9 `get_note(id)` ツール登録 - 検証: ID 指定でノートが返る -- [ ] T10.10 `status()` ツール登録(インデックス状態・キュー残数・最新エラー) +- [x] T10.10 `status()` ツール登録(インデックス状態・キュー残数・最新エラー) - 検証: 戻り値スキーマのテスト -- [ ] T10.11 各ツールの description を i18n 対応 - - 検証: `lang=ja` で日本語、`lang=en` で英語の description -- [ ] T10.12 Claude Desktop 実機接続テスト(設定例ドキュメント込み) +- [ ] T10.11 Claude Desktop 実機接続テスト(設定例ドキュメント込み) - 検証: Claude Desktop の MCP 設定に登録して `save_memory` が呼べる --- diff --git a/bun.lock b/bun.lock index 6af5f85..bf3424e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,9 +6,11 @@ "name": "dennoh", "dependencies": { "@huggingface/transformers": "^4.2.0", + "@modelcontextprotocol/sdk": "^1.29.0", "isomorphic-git": "^1.38.4", "uuidv7": "^1.2.1", "yaml": "^2.9.0", + "zod": "^4.4.3", }, "devDependencies": { "@biomejs/biome": "^1.9.4", @@ -43,6 +45,8 @@ "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], @@ -99,6 +103,8 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -123,20 +129,30 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -145,14 +161,30 @@ "clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], @@ -161,6 +193,10 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -169,16 +205,38 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -201,20 +259,40 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-git": ["isomorphic-git@1.38.4", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "pify": "^4.0.1", "readable-stream": "^4.0.0", "sha.js": "^2.4.12", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-Ud5vs6Ac+ET+iOZWZB1j2RruVeGQSQc7U7QUhPq6iGqzifaqOVHCgRpG/8c0LwIP39R+Mr+lzR4escmCuhjONQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -223,14 +301,32 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], @@ -241,8 +337,16 @@ "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -251,52 +355,102 @@ "protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uuidv7": ["uuidv7@1.2.1", "", { "bin": { "uuidv7": "cli.js" } }, "sha512-4kPkK3/XTQW9Hbm4CaqfICn+kY9LJtDVEOfgsRRra/+n2Ofg4NqzRFceAkxvQ/Ud/6BpHOPzj8cirqM7TzTN5Q=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], } } diff --git a/docs/claude-desktop-setup.md b/docs/claude-desktop-setup.md new file mode 100644 index 0000000..05ab109 --- /dev/null +++ b/docs/claude-desktop-setup.md @@ -0,0 +1,123 @@ +# Connecting dennoh to Claude Desktop + +`dennoh serve` runs an [MCP](https://modelcontextprotocol.io) server over stdio, +exposing seven tools (`save_memory`, `update_memory`, `delete_memory`, +`search_memory`, `list_recent`, `get_note`, `status`). This guide wires it into +Claude Desktop. + +## Prerequisites + +1. Install dependencies (from the repo root): + + ```sh + bun install + ``` + +2. Initialize a vault and write the dennoh config. This creates the vault + directory, a git repo, and `~/Library/Application Support/dennoh/config.json` + (which `serve` reads to find your vault): + + ```sh + bun run src/cli/main.ts init + ``` + + `dennoh serve` does **not** take the vault path as an argument — it always + reads it from that config file, so `init` must run first. + +## Configure Claude Desktop + +Claude Desktop reads MCP servers from: + +```text +~/Library/Application Support/Claude/claude_desktop_config.json +``` + +Add a `dennoh` entry under `mcpServers`. Use an **absolute path** to this +repository — Claude Desktop launches the command from its own working +directory, so relative paths will not resolve. + +### Option A — run from source (development) + +```json +{ + "mcpServers": { + "dennoh": { + "command": "bun", + "args": ["run", "/absolute/path/to/dennoh/src/cli/main.ts", "serve"] + } + } +} +``` + +### Option B — run a built bundle + +Build a single-file bundle first: + +```sh +bun run build # emits dist/cli.js +``` + +```json +{ + "mcpServers": { + "dennoh": { + "command": "bun", + "args": ["/absolute/path/to/dennoh/dist/cli.js", "serve"] + } + } +} +``` + +Notes: + +- `command` must be resolvable on Claude Desktop's `PATH` (use the absolute path + to `bun` — e.g. `/Users/you/.bun/bin/bun` — if `bun` is not found). +- No `env` block is required: `serve` locates the vault through the config file + under your home directory. To force a language, add + `"env": { "DENNOH_LANG": "en" }`. +- stdout carries only the JSON-RPC protocol stream; all logs go to stderr, so + they will not corrupt the MCP connection. + +Restart Claude Desktop after editing the file so it re-reads the config. + +## Verify it works + +1. Open Claude Desktop. The `dennoh` tools should appear in the tools menu + (the hammer/▶ icon). If they do not, check Claude Desktop's MCP logs at + `~/Library/Logs/Claude/`. + +2. Ask Claude to save something, e.g.: + + > Save a memory: "Trying out dennoh #demo @setup" + + Claude calls `save_memory`, which returns the new note's metadata. + +3. Confirm the markdown file was created on disk. Notes live under + `/YYYY/MM/DD/.md`: + + ```sh + # Replace with your configured vaultPath + ls "/$(date +%Y/%m/%d)/" + cat "/$(date +%Y/%m/%d)/"*.md + ``` + + You should see the note with YAML frontmatter (`createdAt`, `updatedAt`, + `source`, `projects`, `tags`) followed by your text. The `#demo` mention is + captured under `projects` and `@setup` under `tags`. + +4. (Optional) Ask Claude to `search_memory` for `demo` or call `status` to see + the indexed note count. + +## Troubleshooting + +- **Tools don't appear / server fails to start** — run the same command in a + terminal to see the error on stderr: + + ```sh + echo '' | bun run /absolute/path/to/dennoh/src/cli/main.ts serve + ``` + + A `dennoh config not found …` message means `init` has not been run. + +- **`bun: command not found` in Claude Desktop** — set `command` to the absolute + path of the `bun` binary (`which bun`). diff --git a/package.json b/package.json index d75cc16..852efb7 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,10 @@ ], "dependencies": { "@huggingface/transformers": "^4.2.0", + "@modelcontextprotocol/sdk": "^1.29.0", "isomorphic-git": "^1.38.4", "uuidv7": "^1.2.1", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "zod": "^4.4.3" } } diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts new file mode 100644 index 0000000..81b011c --- /dev/null +++ b/src/cli/commands/serve.ts @@ -0,0 +1,54 @@ +import type { Database } from "bun:sqlite"; + +import { type CliIO, readError } from "@/cli/types"; +import { readConfig } from "@/config"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; +import { log } from "@/log"; +import { createMcpServer, startStdioServer } from "@/mcp"; + +// `dennoh serve` — run the MCP server over stdio. stdout is reserved for the +// JSON-RPC protocol stream, so this handler emits diagnostics only via io.stderr +// and the log module (stderr-only). It blocks until the client disconnects. +export async function serveCommand(args: string[], io: CliIO): Promise { + if (args.length > 0) { + io.stderr(`Unexpected arguments for 'serve': ${args.join(" ")}\n`); + return 1; + } + + let vaultPath: string; + try { + vaultPath = readConfig().vaultPath; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return 1; + } + + // Open the index first, with its own error handling: openDatabase creates the + // .dennoh dir and opens SQLite (and closes itself on internal failure), so a + // failure here means there is no handle to clean up. Handling it before the + // try/finally below keeps closeDatabase from ever running on an unopened db. + let db: Database; + try { + db = openDatabase(vaultPath); + } catch (e) { + io.stderr(`${readError(e)}\n`); + return 1; + } + + // From here the handle is open, so runMigrations and the server run under a + // try/finally that always closes it — even if migration or startup throws. + // runMigrations is idempotent (no-ops when the schema is current), so this + // also covers a brand-new vault where `serve` first touches the database. + try { + runMigrations(db); + const server = createMcpServer({ db, vaultPath }); + log.info("mcp: serving over stdio", { vaultPath }); + await startStdioServer(server); + return 0; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return 1; + } finally { + closeDatabase(db); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 478698f..f2c2be0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ export type { CliIO } from "./types"; export { configCommand, configGet, configList, configSet } from "./commands/config"; export { historyCommand } from "./commands/history"; export { restoreCommand } from "./commands/restore"; +export { serveCommand } from "./commands/serve"; export { statusCommand } from "./commands/status"; export { type CloudMatch, diff --git a/src/cli/main.ts b/src/cli/main.ts index 7a71150..93fe94d 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -8,6 +8,7 @@ import { historyCommand, initCommand, restoreCommand, + serveCommand, statusCommand, } from "./index"; @@ -23,7 +24,7 @@ function usage(): string { " history Print a note's commit history (newest first)", " restore Restore a note to an earlier commit", " status Report vault health (e.g. cloud-sync conflicts)", - " serve Start the stdio MCP server (not implemented)", + " serve Start the stdio MCP server", "", "Flags:", " --help, -h Show this help", @@ -63,8 +64,7 @@ export async function main(argv: string[], io: CliIO): Promise { return await statusCommand(rest, io); } if (cmd === "serve") { - io.stderr("'dennoh serve' is not implemented yet.\n"); - return 1; + return await serveCommand(rest, io); } io.stderr(`Unknown command: ${cmd}\n\n`); diff --git a/src/db/index.ts b/src/db/index.ts index abe463d..f9d0081 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -9,6 +9,7 @@ export { closeDatabase, openDatabase } from "./connection"; export { runMigrations } from "./schema"; export { deleteNote, + getIndexStats, getNoteById, insertNote, searchNotes, diff --git a/src/db/repository.ts b/src/db/repository.ts index 6a4bad5..24e57f2 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -2,7 +2,7 @@ import type { Database } from "bun:sqlite"; import type { NoteSource } from "@/core/types"; -import type { NoteRow, NoteSearchResult, SearchFilters } from "./types"; +import type { IndexStats, NoteRow, NoteSearchResult, SearchFilters } from "./types"; const INSERT_SQL = ` INSERT INTO notes ( @@ -125,6 +125,28 @@ export function getAllNotes(db: Database): NoteRow[] { return db.query(SELECT_ALL_SQL).all(); } +const INDEX_STATS_SQL = ` + SELECT COUNT(*) AS noteCount, MAX(updated_at) AS lastUpdatedAt + FROM notes WHERE deleted_at IS NULL +`; + +// Aggregate counters for the `status` surface: live-row count and the max +// updated_at (null when the vault is empty). A single aggregate round-trip — +// no JS-side row scan — so it stays cheap on large vaults. +export function getIndexStats(db: Database): IndexStats { + const row = db + .query<{ noteCount: number; lastUpdatedAt: string | null }, []>(INDEX_STATS_SQL) + .get(); + // An aggregate SELECT returns exactly one row. A missing row means the query + // or database is in an unexpected state (e.g. corruption); fail loudly rather + // than fabricate a zero count. `lastUpdatedAt` is legitimately null for an + // empty vault, so it passes through untouched. + if (row === null) { + throw new Error("getIndexStats: aggregate query returned no row"); + } + return { noteCount: row.noteCount, lastUpdatedAt: row.lastUpdatedAt }; +} + const DEFAULT_SEARCH_LIMIT = 20; // `_` and `%` are LIKE wildcards; `\` is the documented escape. extractMentions diff --git a/src/mcp/index.ts b/src/mcp/index.ts index cb0ff5c..e3541c0 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -1 +1,2 @@ -export {}; +export { SERVER_NAME, createMcpServer, startStdioServer } from "./server"; +export type { McpContext, StatusResult } from "./types"; diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..ca8b874 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,36 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +import pkg from "../../package.json" with { type: "json" }; + +import { registerAllTools } from "./tools"; +import type { McpContext } from "./types"; + +// MCP server identity advertised to clients during `initialize`. The name is a +// stable constant; the version tracks package.json so a single bump propagates. +export const SERVER_NAME = "dennoh"; + +// Build the MCP server instance and register all dennoh tools against the given +// context (the open index + vault root). Tool handlers bridge into the core +// layer; see src/mcp/tools. +export function createMcpServer(context: McpContext): McpServer { + const server = new McpServer({ name: SERVER_NAME, version: pkg.version }); + registerAllTools(server, context); + return server; +} + +// Connect `server` to a transport (stdio by default) and resolve only once the +// connection closes — a client disconnect or stdin EOF. Resolving on close (not +// on connect) lets `dennoh serve` keep the process alive for the server's +// lifetime and run cleanup afterwards. The transport is injectable so tests can +// drive the lifecycle with an in-memory pair instead of real stdio. +export async function startStdioServer( + server: McpServer, + transport: Transport = new StdioServerTransport() +): Promise { + await server.connect(transport); + await new Promise((resolve) => { + server.server.onclose = () => resolve(); + }); +} diff --git a/src/mcp/tools/delete-memory.ts b/src/mcp/tools/delete-memory.ts new file mode 100644 index 0000000..f4510e2 --- /dev/null +++ b/src/mcp/tools/delete-memory.ts @@ -0,0 +1,30 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { deleteMemory } from "@/core/memory"; + +import type { McpContext } from "../types"; +import { toolError, toolOk } from "./result"; + +const DESCRIPTION = + "Delete a note by id: removes the markdown file, soft-deletes the index row, and records a git commit. Returns { id, deleted: true } on success, or an error result if the note does not exist."; + +export function registerDeleteMemory(server: McpServer, context: McpContext): void { + server.registerTool( + "delete_memory", + { + description: DESCRIPTION, + inputSchema: { + id: z.string().min(1), + }, + }, + async ({ id }) => { + try { + await deleteMemory(context.db, context.vaultPath, id); + return toolOk({ id, deleted: true }); + } catch (e) { + return toolError(e); + } + } + ); +} diff --git a/src/mcp/tools/get-note.ts b/src/mcp/tools/get-note.ts new file mode 100644 index 0000000..8bcfdc3 --- /dev/null +++ b/src/mcp/tools/get-note.ts @@ -0,0 +1,30 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { getNote } from "@/core/memory"; + +import type { McpContext } from "../types"; +import { toolError, toolOk } from "./result"; + +const DESCRIPTION = + "Fetch a single note by id, including its full body and frontmatter. Returns { note: { id, frontmatter, body } }, or { note: null } when the id is unknown or the note was deleted."; + +export function registerGetNote(server: McpServer, context: McpContext): void { + server.registerTool( + "get_note", + { + description: DESCRIPTION, + inputSchema: { + id: z.string().min(1), + }, + }, + async ({ id }) => { + try { + const note = await getNote(context.db, context.vaultPath, id); + return toolOk({ note }); + } catch (e) { + return toolError(e); + } + } + ); +} diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts new file mode 100644 index 0000000..6f9be80 --- /dev/null +++ b/src/mcp/tools/index.ts @@ -0,0 +1,21 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { McpContext } from "../types"; +import { registerDeleteMemory } from "./delete-memory"; +import { registerGetNote } from "./get-note"; +import { registerListRecent } from "./list-recent"; +import { registerSaveMemory } from "./save-memory"; +import { registerSearchMemory } from "./search-memory"; +import { registerStatus } from "./status"; +import { registerUpdateMemory } from "./update-memory"; + +// Register every dennoh MCP tool on `server`. Called once from createMcpServer. +export function registerAllTools(server: McpServer, context: McpContext): void { + registerSaveMemory(server, context); + registerUpdateMemory(server, context); + registerDeleteMemory(server, context); + registerSearchMemory(server, context); + registerListRecent(server, context); + registerGetNote(server, context); + registerStatus(server, context); +} diff --git a/src/mcp/tools/list-recent.ts b/src/mcp/tools/list-recent.ts new file mode 100644 index 0000000..57fd878 --- /dev/null +++ b/src/mcp/tools/list-recent.ts @@ -0,0 +1,31 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { listRecent } from "@/core/memory"; + +import type { McpContext } from "../types"; +import { noteMetadata } from "./metadata"; +import { toolError, toolOk } from "./result"; + +const DESCRIPTION = + "List the most recently updated notes (metadata only, newest first). `limit` bounds the count (default 10). Pair with get_note to read full bodies."; + +export function registerListRecent(server: McpServer, context: McpContext): void { + server.registerTool( + "list_recent", + { + description: DESCRIPTION, + inputSchema: { + limit: z.number().int().positive().optional(), + }, + }, + async ({ limit }) => { + try { + const rows = listRecent(context.db, limit); + return toolOk({ notes: rows.map(noteMetadata) }); + } catch (e) { + return toolError(e); + } + } + ); +} diff --git a/src/mcp/tools/metadata.ts b/src/mcp/tools/metadata.ts new file mode 100644 index 0000000..ae3b96b --- /dev/null +++ b/src/mcp/tools/metadata.ts @@ -0,0 +1,30 @@ +import type { NoteRow } from "@/db"; +import { fromNoteRow } from "@/db/mapper"; + +// JSON-friendly projection of a stored note: identity + path + frontmatter, +// without the indexed body / body_en columns. Shared by the tools that return +// note metadata (save / update / list_recent) so the shape stays consistent. +export type NoteMetadataDto = { + id: string; + path: string; + createdAt: string; + updatedAt: string; + source: string; + title: string | null; + projects: string[]; + tags: string[]; +}; + +export function noteMetadata(row: NoteRow): NoteMetadataDto { + const { metadata, path } = fromNoteRow(row); + return { + id: metadata.id, + path, + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + source: metadata.source, + title: metadata.title ?? null, + projects: metadata.projects, + tags: metadata.tags, + }; +} diff --git a/src/mcp/tools/result.ts b/src/mcp/tools/result.ts new file mode 100644 index 0000000..35c013d --- /dev/null +++ b/src/mcp/tools/result.ts @@ -0,0 +1,15 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// Tools report outcomes inside the result object, never by throwing: a thrown +// error surfaces as an MCP *protocol* error, whereas `isError: true` is a +// tool-level result the model can read and recover from. Payloads are returned +// as pretty-printed JSON text so any client (structured or text-only) can use +// them. +export function toolOk(payload: unknown): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] }; +} + +export function toolError(e: unknown): CallToolResult { + const message = e instanceof Error ? e.message : String(e); + return { content: [{ type: "text", text: message }], isError: true }; +} diff --git a/src/mcp/tools/save-memory.ts b/src/mcp/tools/save-memory.ts new file mode 100644 index 0000000..b47a12c --- /dev/null +++ b/src/mcp/tools/save-memory.ts @@ -0,0 +1,39 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { saveMemory } from "@/core/memory"; +import { getNoteById } from "@/db"; + +import type { McpContext } from "../types"; +import { noteMetadata } from "./metadata"; +import { toolError, toolOk } from "./result"; + +const DESCRIPTION = + "Save a new note to the dennoh memory vault. `content` is markdown; #project and @tag mentions are extracted automatically into the note's metadata. Returns the created note's metadata (id, path, timestamps, projects, tags)."; + +export function registerSaveMemory(server: McpServer, context: McpContext): void { + server.registerTool( + "save_memory", + { + description: DESCRIPTION, + inputSchema: { + content: z.string().min(1), + // The only NoteSource today is "note"; constrain the input to match + // core/saveMemory rather than accepting an arbitrary string. + source: z.enum(["note"]).optional(), + }, + }, + async ({ content, source }) => { + try { + const id = await saveMemory(context.db, context.vaultPath, content, source); + const row = getNoteById(context.db, id); + if (row === null) { + throw new Error(`saved note ${id} could not be read back`); + } + return toolOk(noteMetadata(row)); + } catch (e) { + return toolError(e); + } + } + ); +} diff --git a/src/mcp/tools/search-memory.ts b/src/mcp/tools/search-memory.ts new file mode 100644 index 0000000..382a812 --- /dev/null +++ b/src/mcp/tools/search-memory.ts @@ -0,0 +1,44 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { searchMemory } from "@/core/memory"; + +import type { McpContext } from "../types"; +import { toolError, toolOk } from "./result"; + +const DESCRIPTION = + "Full-text search across the memory vault (title, body, and English translation). Optional filters narrow by project, tag, updated-at date range, and source. Returns the matching notes with snippets, newest-relevance first."; + +// Mirror SearchFilters from the db layer; `source` is constrained to the single +// NoteSource value rather than a free string. +const filtersSchema = z + .object({ + project: z.string().optional(), + tag: z.string().optional(), + dateFrom: z.string().optional(), + dateTo: z.string().optional(), + source: z.enum(["note"]).optional(), + }) + .optional(); + +export function registerSearchMemory(server: McpServer, context: McpContext): void { + server.registerTool( + "search_memory", + { + description: DESCRIPTION, + inputSchema: { + query: z.string().min(1), + filters: filtersSchema, + limit: z.number().int().positive().optional(), + }, + }, + async ({ query, filters, limit }) => { + try { + const results = searchMemory(context.db, query, filters, limit); + return toolOk({ results }); + } catch (e) { + return toolError(e); + } + } + ); +} diff --git a/src/mcp/tools/status.ts b/src/mcp/tools/status.ts new file mode 100644 index 0000000..1f14f95 --- /dev/null +++ b/src/mcp/tools/status.ts @@ -0,0 +1,24 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { getIndexStats } from "@/db"; + +import type { McpContext, StatusResult } from "../types"; +import { toolError, toolOk } from "./result"; + +const DESCRIPTION = + "Report dennoh index health: indexedCount (live notes in the index) and latestError. queueDepth is included only when a file watcher is attached to the server."; + +export function registerStatus(server: McpServer, context: McpContext): void { + // No input schema: `status` takes no parameters. + server.registerTool("status", { description: DESCRIPTION }, async () => { + try { + const stats = getIndexStats(context.db); + // queueDepth is omitted: the serve foundation does not attach a watcher, + // and latestError stays null until an error-tracking subsystem feeds it. + const result: StatusResult = { indexedCount: stats.noteCount, latestError: null }; + return toolOk(result); + } catch (e) { + return toolError(e); + } + }); +} diff --git a/src/mcp/tools/update-memory.ts b/src/mcp/tools/update-memory.ts new file mode 100644 index 0000000..b7e0347 --- /dev/null +++ b/src/mcp/tools/update-memory.ts @@ -0,0 +1,37 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { updateMemory } from "@/core/memory"; +import { getNoteById } from "@/db"; + +import type { McpContext } from "../types"; +import { noteMetadata } from "./metadata"; +import { toolError, toolOk } from "./result"; + +const DESCRIPTION = + "Update an existing note's content by id. #project and @tag mentions are re-extracted from the new content; createdAt is preserved and updatedAt is bumped. Returns the updated note's metadata."; + +export function registerUpdateMemory(server: McpServer, context: McpContext): void { + server.registerTool( + "update_memory", + { + description: DESCRIPTION, + inputSchema: { + id: z.string().min(1), + content: z.string().min(1), + }, + }, + async ({ id, content }) => { + try { + await updateMemory(context.db, context.vaultPath, id, content); + const row = getNoteById(context.db, id); + if (row === null) { + throw new Error(`updated note ${id} could not be read back`); + } + return toolOk(noteMetadata(row)); + } catch (e) { + return toolError(e); + } + } + ); +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..31131c9 --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,17 @@ +import type { Database } from "bun:sqlite"; + +// Shared context every tool handler needs to bridge into the core layer: the +// open SQLite index and the vault root. Constructed once by `dennoh serve`. +export type McpContext = { + db: Database; + vaultPath: string; +}; + +// Result shape for the `status` MCP tool (T10.10). `queueDepth` is present only +// when a watcher is wired into the server (none yet in the serve foundation); +// `latestError` is null until an error-tracking subsystem feeds it. +export type StatusResult = { + indexedCount: number; + queueDepth?: number; + latestError: string | null; +}; diff --git a/tests/cli/serve.test.ts b/tests/cli/serve.test.ts new file mode 100644 index 0000000..52e90e9 --- /dev/null +++ b/tests/cli/serve.test.ts @@ -0,0 +1,63 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; + +import { type CliIO, serveCommand } from "@/cli"; + +function makeIO(): { io: CliIO; stdout: () => string; stderr: () => string } { + const stdoutBuf: string[] = []; + const stderrBuf: string[] = []; + return { + io: { + stdout: (s) => { + stdoutBuf.push(s); + }, + stderr: (s) => { + stderrBuf.push(s); + }, + }, + stdout: () => stdoutBuf.join(""), + stderr: () => stderrBuf.join(""), + }; +} + +// These tests exercise only the pre-connection paths (validation + config +// failure), which return before opening stdio. The live stdio loop blocks +// until disconnect and is covered by mcp/server.test.ts via an in-memory pair. +describe("cli serve", () => { + let homeDir: string; + let homedirSpy: ReturnType>; + + beforeEach(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-serve-home-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(homeDir); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it("rejects unexpected arguments without touching stdout", async () => { + const { io, stdout, stderr } = makeIO(); + const code = await serveCommand(["extra"], io); + + expect(code).toBe(1); + expect(stderr()).toContain("Unexpected"); + // stdout is reserved for the MCP protocol stream — nothing else may write it. + expect(stdout()).toBe(""); + }); + + it("fails with a config error when no config exists, writing only to stderr", async () => { + // homedir points at an empty dir, so readConfig throws "config not found" + // before the server ever connects. + const { io, stdout, stderr } = makeIO(); + const code = await serveCommand([], io); + + expect(code).toBe(1); + expect(stderr().length).toBeGreaterThan(0); + expect(stdout()).toBe(""); + }); +}); diff --git a/tests/mcp/delete-memory.test.ts b/tests/mcp/delete-memory.test.ts new file mode 100644 index 0000000..b573216 --- /dev/null +++ b/tests/mcp/delete-memory.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; + +import { setupMcpHarness, toolPayload, toolText } from "./harness"; + +describe("mcp/delete_memory", () => { + const h = setupMcpHarness(); + + it("deletes a note so get_note then returns null", async () => { + const saved = toolPayload(await h.call("save_memory", { content: "to delete" })) as { + id: string; + }; + + const del = toolPayload(await h.call("delete_memory", { id: saved.id })) as { + id: string; + deleted: boolean; + }; + expect(del).toEqual({ id: saved.id, deleted: true }); + + const after = toolPayload(await h.call("get_note", { id: saved.id })) as { note: null }; + expect(after.note).toBeNull(); + }); + + it("returns an isError result for an unknown id", async () => { + const res = await h.call("delete_memory", { id: "no-such-id" }); + expect(res.isError).toBe(true); + expect(toolText(res)).toMatch(/not found or already deleted/); + }); + + it("returns an isError result on a double delete", async () => { + const saved = toolPayload(await h.call("save_memory", { content: "once" })) as { id: string }; + await h.call("delete_memory", { id: saved.id }); + const res = await h.call("delete_memory", { id: saved.id }); + expect(res.isError).toBe(true); + }); +}); diff --git a/tests/mcp/get-note.test.ts b/tests/mcp/get-note.test.ts new file mode 100644 index 0000000..5a56737 --- /dev/null +++ b/tests/mcp/get-note.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; + +import { setupMcpHarness, toolPayload } from "./harness"; + +describe("mcp/get_note", () => { + const h = setupMcpHarness(); + + it("returns id, frontmatter, and body for a live note", async () => { + const saved = toolPayload(await h.call("save_memory", { content: "lookup body #proj" })) as { + id: string; + }; + + const found = toolPayload(await h.call("get_note", { id: saved.id })) as { + note: { id: string; body: string; frontmatter: { projects: string[] } } | null; + }; + expect(found.note?.id).toBe(saved.id); + expect(found.note?.body).toContain("lookup body #proj"); + expect(found.note?.frontmatter.projects).toEqual(["proj"]); + }); + + it("returns { note: null } for an unknown id (not an error)", async () => { + const res = await h.call("get_note", { id: "no-such-id" }); + expect(res.isError).toBeFalsy(); + expect((toolPayload(res) as { note: null }).note).toBeNull(); + }); + + it("returns an isError result for an empty id (schema validation)", async () => { + const res = await h.call("get_note", { id: "" }); + expect(res.isError).toBe(true); + }); +}); diff --git a/tests/mcp/harness.ts b/tests/mcp/harness.ts new file mode 100644 index 0000000..50b8040 --- /dev/null +++ b/tests/mcp/harness.ts @@ -0,0 +1,87 @@ +import type { Database } from "bun:sqlite"; +import { afterEach, beforeEach, spyOn } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +import git from "isomorphic-git"; + +import { writeConfig } from "@/config"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; +import { createMcpServer, startStdioServer } from "@/mcp"; + +// First text content block of a tool result, narrowed to its string. +export function toolText(res: CallToolResult): string { + const block = res.content[0]; + if (block?.type !== "text") { + throw new Error(`expected text content, got ${block?.type}`); + } + return block.text; +} + +// Parse the JSON payload a successful tool encodes in its text block. +export function toolPayload(res: CallToolResult): unknown { + return JSON.parse(toolText(res)); +} + +export type McpHarness = { + call: (name: string, args?: Record) => Promise; + vaultPath: () => string; +}; + +// Registers beforeEach/afterEach that stand up a real, isolated vault (git + +// SQLite under a temp dir) and a linked in-memory client/server pair, mirroring +// the temp-dir isolation in core/memory.test.ts. Call once inside a describe. +export function setupMcpHarness(): McpHarness { + let homeDir: string; + let vaultPath: string; + let db: Database; + let client: Client; + let serving: Promise; + let homedirSpy: ReturnType>; + let originalTranslateDisable: string | undefined; + + beforeEach(async () => { + originalTranslateDisable = process.env.DENNOH_TRANSLATE_DISABLE; + process.env.DENNOH_TRANSLATE_DISABLE = "1"; + + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-mcp-home-")); + vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-mcp-vault-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(homeDir); + writeConfig({ vaultPath, lang: "ja", maxFileSizeBytes: 1_048_576 }); + + db = openDatabase(vaultPath); + runMigrations(db); + await git.init({ fs, dir: vaultPath, defaultBranch: "main" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.name", value: "Test" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.email", value: "test@example.com" }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + serving = startStdioServer(createMcpServer({ db, vaultPath }), serverTransport); + client = new Client({ name: "test-client", version: "0.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + await serving; + closeDatabase(db); + homedirSpy.mockRestore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(vaultPath, { recursive: true, force: true }); + if (originalTranslateDisable === undefined) { + Reflect.deleteProperty(process.env, "DENNOH_TRANSLATE_DISABLE"); + } else { + process.env.DENNOH_TRANSLATE_DISABLE = originalTranslateDisable; + } + }); + + return { + call: (name, args = {}) => + client.callTool({ name, arguments: args }) as Promise, + vaultPath: () => vaultPath, + }; +} diff --git a/tests/mcp/integration.test.ts b/tests/mcp/integration.test.ts new file mode 100644 index 0000000..b32376c --- /dev/null +++ b/tests/mcp/integration.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +import git from "isomorphic-git"; + +import pkg from "../../package.json" with { type: "json" }; + +// True end-to-end: spawn the real `dennoh serve` process and talk to it over +// actual stdio pipes. This exercises the initialize handshake, proves stdout +// carries only JSON-RPC (a noisy stdout would break the client's parser, so a +// successful handshake is itself the assertion), and confirms logs land on +// stderr. It then runs a multi-tool scenario end to end. +const MAIN = path.resolve(import.meta.dir, "../../src/cli/main.ts"); + +function firstText(res: CallToolResult): string { + const block = res.content[0]; + if (block?.type !== "text") { + throw new Error(`expected text content, got ${block?.type}`); + } + return block.text; +} + +// Recursively collect .md note files under the vault (skipping dot-dirs). +function findMarkdown(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...findMarkdown(full)); + } else if (entry.name.endsWith(".md")) { + out.push(full); + } + } + return out; +} + +describe("mcp/stdio integration", () => { + let homeDir: string; + let vaultPath: string; + let client: Client; + let transport: StdioClientTransport; + let stderr = ""; + + beforeEach(async () => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-int-home-")); + vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-int-vault-")); + + // Write the config exactly where the spawned process will look for it: + // os.homedir()/Library/Application Support/dennoh/config.json. The child's + // os.homedir() resolves from the HOME we pass below. + const configDir = path.join(homeDir, "Library", "Application Support", "dennoh"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "config.json"), + JSON.stringify({ vaultPath, lang: "ja", maxFileSizeBytes: 1_048_576 }) + ); + + await git.init({ fs, dir: vaultPath, defaultBranch: "main" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.name", value: "Test" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.email", value: "test@example.com" }); + + // Inherit the full environment (PATH, etc.), then point HOME at our temp + // home and disable the translation model download for the child. + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (typeof v === "string") env[k] = v; + } + env.HOME = homeDir; + env.DENNOH_TRANSLATE_DISABLE = "1"; + + transport = new StdioClientTransport({ + command: "bun", + args: ["run", MAIN, "serve"], + env, + stderr: "pipe", + }); + stderr = ""; + transport.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + client = new Client({ name: "integration-client", version: "0.0.0" }); + // A successful connect IS the initialize-handshake assertion: it round-trips + // an initialize request/response over the real stdio pipes. + await client.connect(transport); + }); + + afterEach(async () => { + await client.close(); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(vaultPath, { recursive: true, force: true }); + }); + + it("completes the initialize handshake and reports the dennoh identity", () => { + expect(client.getServerVersion()).toEqual({ name: "dennoh", version: pkg.version }); + }); + + it("runs a multi-tool scenario over stdio and writes a markdown file", async () => { + // save_memory -> a real .md file appears in the vault. + const saved = JSON.parse( + firstText( + (await client.callTool({ + name: "save_memory", + arguments: { content: "integration body #demo" }, + })) as CallToolResult + ) + ) as { id: string }; + expect(saved.id).toMatch(/[0-9a-f-]{36}/); + + const files = findMarkdown(vaultPath); + expect(files).toHaveLength(1); + expect(fs.readFileSync(files[0] as string, "utf-8")).toContain("integration body #demo"); + + // get_note -> reads the same note back. + const got = JSON.parse( + firstText( + (await client.callTool({ + name: "get_note", + arguments: { id: saved.id }, + })) as CallToolResult + ) + ) as { note: { id: string } | null }; + expect(got.note?.id).toBe(saved.id); + + // status -> reflects the one indexed note. + const status = JSON.parse( + firstText((await client.callTool({ name: "status" })) as CallToolResult) + ) as { indexedCount: number }; + expect(status.indexedCount).toBe(1); + }); + + it("writes diagnostics to stderr (stdout stays JSON-RPC only)", async () => { + // Make at least one round-trip so the child has fully started and logged. + await client.callTool({ name: "status" }); + expect(stderr).toContain("mcp: serving over stdio"); + }); +}); diff --git a/tests/mcp/list-recent.test.ts b/tests/mcp/list-recent.test.ts new file mode 100644 index 0000000..c52c581 --- /dev/null +++ b/tests/mcp/list-recent.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; + +import { setupMcpHarness, toolPayload } from "./harness"; + +describe("mcp/list_recent", () => { + const h = setupMcpHarness(); + + it("returns the saved notes as metadata", async () => { + const a = toolPayload(await h.call("save_memory", { content: "first" })) as { id: string }; + const b = toolPayload(await h.call("save_memory", { content: "second" })) as { id: string }; + + const out = toolPayload(await h.call("list_recent", { limit: 10 })) as { + notes: { id: string }[]; + }; + expect(out.notes.map((n) => n.id).sort()).toEqual([a.id, b.id].sort()); + }); + + it("works with no arguments (default limit)", async () => { + await h.call("save_memory", { content: "only one" }); + const out = toolPayload(await h.call("list_recent")) as { notes: { id: string }[] }; + expect(out.notes).toHaveLength(1); + }); + + it("returns an isError result for a non-positive limit (schema validation)", async () => { + const res = await h.call("list_recent", { limit: 0 }); + expect(res.isError).toBe(true); + }); +}); diff --git a/tests/mcp/save-memory.test.ts b/tests/mcp/save-memory.test.ts new file mode 100644 index 0000000..ea61811 --- /dev/null +++ b/tests/mcp/save-memory.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; + +import { setupMcpHarness, toolPayload } from "./harness"; + +describe("mcp/save_memory", () => { + const h = setupMcpHarness(); + + it("saves a note, extracts mentions, and writes the file", async () => { + const res = await h.call("save_memory", { content: "hello world #proj @tag" }); + expect(res.isError).toBeFalsy(); + + const meta = toolPayload(res) as { + id: string; + path: string; + projects: string[]; + tags: string[]; + }; + expect(meta.id).toMatch(/[0-9a-f-]{36}/); + expect(meta.projects).toEqual(["proj"]); + expect(meta.tags).toEqual(["tag"]); + expect(meta.path.startsWith(h.vaultPath())).toBe(true); + expect(fs.existsSync(meta.path)).toBe(true); + }); + + it("defaults source to note", async () => { + const meta = toolPayload(await h.call("save_memory", { content: "no source given" })) as { + source: string; + }; + expect(meta.source).toBe("note"); + }); + + it("returns an isError result for empty content (schema validation)", async () => { + const res = await h.call("save_memory", { content: "" }); + expect(res.isError).toBe(true); + }); + + it("returns an isError result for content containing a NULL byte", async () => { + // A NUL byte trips core validateContent, surfaced as a tool-level error. + const content = ["good", "bad"].join(String.fromCharCode(0)); + const res = await h.call("save_memory", { content }); + expect(res.isError).toBe(true); + }); +}); diff --git a/tests/mcp/search-memory.test.ts b/tests/mcp/search-memory.test.ts new file mode 100644 index 0000000..4b2e674 --- /dev/null +++ b/tests/mcp/search-memory.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; + +import { setupMcpHarness, toolPayload } from "./harness"; + +describe("mcp/search_memory", () => { + const h = setupMcpHarness(); + + it("finds an indexed note by a body term", async () => { + await h.call("save_memory", { content: "searchablekeyword body" }); + const out = toolPayload(await h.call("search_memory", { query: "searchablekeyword" })) as { + results: { id: string }[]; + }; + expect(out.results).toHaveLength(1); + }); + + it("narrows results with a project filter", async () => { + await h.call("save_memory", { content: "common term #alpha" }); + await h.call("save_memory", { content: "common term #beta" }); + + const out = toolPayload( + await h.call("search_memory", { query: "common", filters: { project: "alpha" } }) + ) as { results: { projects: string[] }[] }; + expect(out.results).toHaveLength(1); + expect(out.results[0]?.projects).toEqual(["alpha"]); + }); + + it("returns an isError result for an empty query (schema validation)", async () => { + const res = await h.call("search_memory", { query: "" }); + expect(res.isError).toBe(true); + }); +}); diff --git a/tests/mcp/server.test.ts b/tests/mcp/server.test.ts new file mode 100644 index 0000000..8e78017 --- /dev/null +++ b/tests/mcp/server.test.ts @@ -0,0 +1,83 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, it } from "bun:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { runMigrations } from "@/db"; +import { type McpContext, SERVER_NAME, createMcpServer, startStdioServer } from "@/mcp"; +import pkg from "../../package.json" with { type: "json" }; + +describe("mcp/server", () => { + let db: Database | null = null; + + // The tools need a context; an in-memory migrated DB is enough for the + // foundation assertions (identity, lifecycle, tool registration). + function makeContext(): McpContext { + db = new Database(":memory:"); + runMigrations(db); + return { db, vaultPath: "/tmp/dennoh-server-test-vault" }; + } + + afterEach(() => { + db?.close(); + db = null; + }); + + it("createMcpServer advertises the dennoh name and package version", async () => { + // Drive a real client handshake over a linked in-memory transport so we + // read the identity the server actually reports during `initialize`. + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(makeContext()); + const serving = startStdioServer(server, serverTransport); + + const client = new Client({ name: "test-client", version: "0.0.0" }); + await client.connect(clientTransport); + + expect(client.getServerVersion()).toEqual({ name: SERVER_NAME, version: pkg.version }); + + await client.close(); + // startStdioServer resolves only once the connection closes; awaiting it + // here confirms the close path fires (and would hang the test otherwise). + await serving; + }); + + it("registers all seven tools", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(makeContext()); + const serving = startStdioServer(server, serverTransport); + + const client = new Client({ name: "test-client", version: "0.0.0" }); + await client.connect(clientTransport); + + const names = (await client.listTools()).tools.map((t) => t.name).sort(); + expect(names).toEqual([ + "delete_memory", + "get_note", + "list_recent", + "save_memory", + "search_memory", + "status", + "update_memory", + ]); + + await client.close(); + await serving; + }); + + it("startStdioServer stays pending until the transport closes", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(makeContext()); + const serving = startStdioServer(server, serverTransport); + + const client = new Client({ name: "test-client", version: "0.0.0" }); + await client.connect(clientTransport); + + // While connected, the serving promise must not have resolved yet. + const pending = Symbol("pending"); + const raced = await Promise.race([serving.then(() => "resolved"), Promise.resolve(pending)]); + expect(raced).toBe(pending); + + await client.close(); + await serving; + }); +}); diff --git a/tests/mcp/status.test.ts b/tests/mcp/status.test.ts new file mode 100644 index 0000000..d81d2d1 --- /dev/null +++ b/tests/mcp/status.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; + +import { setupMcpHarness, toolPayload } from "./harness"; + +describe("mcp/status", () => { + const h = setupMcpHarness(); + + it("reports zero indexed notes for a fresh vault", async () => { + const status = toolPayload(await h.call("status")) as { + indexedCount: number; + latestError: string | null; + }; + expect(status.indexedCount).toBe(0); + expect(status.latestError).toBeNull(); + }); + + it("reports the live indexed count and omits queueDepth without a watcher", async () => { + await h.call("save_memory", { content: "one" }); + await h.call("save_memory", { content: "two" }); + + const status = toolPayload(await h.call("status")) as Record; + expect(status.indexedCount).toBe(2); + expect(status.latestError).toBeNull(); + expect("queueDepth" in status).toBe(false); + }); +}); diff --git a/tests/mcp/update-memory.test.ts b/tests/mcp/update-memory.test.ts new file mode 100644 index 0000000..877fdc0 --- /dev/null +++ b/tests/mcp/update-memory.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; + +import { setupMcpHarness, toolPayload, toolText } from "./harness"; + +describe("mcp/update_memory", () => { + const h = setupMcpHarness(); + + it("re-extracts mentions and returns updated metadata", async () => { + const saved = toolPayload(await h.call("save_memory", { content: "v1 #old" })) as { + id: string; + }; + + const updated = toolPayload( + await h.call("update_memory", { id: saved.id, content: "v2 #new @reader" }) + ) as { id: string; projects: string[]; tags: string[] }; + expect(updated.id).toBe(saved.id); + expect(updated.projects).toEqual(["new"]); + expect(updated.tags).toEqual(["reader"]); + }); + + it("returns an isError result for an unknown id", async () => { + const res = await h.call("update_memory", { id: "no-such-id", content: "x" }); + expect(res.isError).toBe(true); + expect(toolText(res)).toMatch(/not found or already deleted/); + }); + + it("returns an isError result for empty content (schema validation)", async () => { + const saved = toolPayload(await h.call("save_memory", { content: "keep" })) as { id: string }; + const res = await h.call("update_memory", { id: saved.id, content: "" }); + expect(res.isError).toBe(true); + }); +});