From 57ad297932efb464aa2b05bcd90f167170bed854 Mon Sep 17 00:00:00 2001 From: Daniel Espendiller Date: Tue, 10 Feb 2026 18:05:42 +0100 Subject: [PATCH] add MCP JSON-RPC endpoint for session tools and update README --- README.md | 9 +- package-lock.json | 257 +++++++++++++++++++++- package.json | 4 +- src/command/serve.ts | 2 + src/controller/mcpController.ts | 367 ++++++++++++++++++++++++++++++++ 5 files changed, 629 insertions(+), 10 deletions(-) create mode 100644 src/controller/mcpController.ts diff --git a/README.md b/README.md index 2103a0a..7fedb29 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ AI Chat Session Importer and Viewer -Import your AI chat sessions into a local database, then browse and search them through a web interface. +Import your AI chat sessions into a local database, then browse and search them through a web interface or via MCP tools. ## Supported Providers @@ -80,8 +80,13 @@ This scans all providers locally and sends each session to the server's `POST /a | Method | Endpoint | Description | |--------|----------|-------------| | `POST` | `/api/import/session` | Import a single session. Expects a JSON body with `session`, `provider`, `projectPath`, `projectName`, `created`, and `updated` fields. | +| `POST` | `/api/mcp` | MCP endpoint (Streamable HTTP) with tools: `search_sessions`, `get_session`. | +| `GET` | `/api/mcp` | Streamable HTTP session channel (used by MCP clients). | +| `DELETE` | `/api/mcp` | Close a Streamable HTTP MCP session. | + +The MCP endpoint uses Streamable HTTP over JSON-RPC. ## Screenshots ![My Mega Memory screenshot](docs/my-mega-memory.webp) -![My Mega Memory screenshot](docs/my-mega-memory_1.webp) \ No newline at end of file +![My Mega Memory screenshot](docs/my-mega-memory_1.webp) diff --git a/package-lock.json b/package-lock.json index 263b20d..74a5c95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", "@types/markdown-it": "^14.1.2", "better-sqlite3": "^12.1.0", "commander": "^14.0.3", @@ -17,7 +18,8 @@ "express": "^5.1.0", "express-ejs-layouts": "^2.5.1", "markdown-it": "^14.1.0", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "zod": "^3.25.0" }, "bin": { "my-mega-memory": "dist/cli.js" @@ -607,6 +609,18 @@ "tslib": "^2.4.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1042,6 +1056,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1715,6 +1769,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2413,6 +2500,23 @@ "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2423,7 +2527,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2725,6 +2828,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -2841,6 +2965,30 @@ "resolved": "https://registry.npmjs.org/express-ejs-layouts/-/express-ejs-layouts-2.5.1.tgz", "integrity": "sha512-IXROv9n3xKga7FowT06n1Qn927JR8ZWDn5Dc9CJQoiiaaDqbhW5PDmWShzbpAa2wjWT1vJqaIM1S6vJwwX11gA==" }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2848,6 +2996,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3192,6 +3356,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3319,6 +3492,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3388,7 +3570,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4101,6 +4282,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4142,6 +4332,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4507,6 +4709,15 @@ "node": ">=8" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4670,7 +4881,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4739,6 +4949,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -4940,6 +5159,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -5072,7 +5300,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5085,7 +5312,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5946,7 +6172,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6192,6 +6417,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index 89ed81d..7c0a28a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", "@types/markdown-it": "^14.1.2", "better-sqlite3": "^12.1.0", "commander": "^14.0.3", @@ -26,7 +27,8 @@ "express": "^5.1.0", "express-ejs-layouts": "^2.5.1", "markdown-it": "^14.1.0", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "zod": "^3.25.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/src/command/serve.ts b/src/command/serve.ts index cbb52ea..e4823a2 100644 --- a/src/command/serve.ts +++ b/src/command/serve.ts @@ -8,6 +8,7 @@ import {projectController} from '../controller/projectController'; import {sessionController} from '../controller/sessionController'; import {searchController} from '../controller/searchController'; import {apiController} from '../controller/apiController'; +import {mcpController} from '../controller/mcpController'; export const serveCommand = new Command('serve') .description('Start the web server to view sessions') @@ -40,6 +41,7 @@ export const serveCommand = new Command('serve') app.use('/sessions', sessionController); app.use('/search', searchController); app.use('/api', apiController); + app.use('/api/mcp', mcpController); // Error handler app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => { diff --git a/src/controller/mcpController.ts b/src/controller/mcpController.ts new file mode 100644 index 0000000..7e1a60b --- /dev/null +++ b/src/controller/mcpController.ts @@ -0,0 +1,367 @@ +import { Router, Request, Response } from 'express'; +import { randomUUID } from 'crypto'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest, CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { DatabaseManager } from '../database'; +import { SearchDatabase } from '../searchDatabase'; +import { MessageContent, RenderableMessage } from '../types'; + +const MAX_SEARCH_LIMIT = 100; +const DEFAULT_SEARCH_LIMIT = 50; +const MAX_SNIPPET_CHARS = 600; + +const router = Router(); +const transports = new Map(); + +router.post('/', async (req: Request, res: Response) => { + const db: DatabaseManager = req.app.locals.db; + const searchDb: SearchDatabase = req.app.locals.searchDb; + + try { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId && transports.has(sessionId)) { + const transport = transports.get(sessionId)!; + await transport.handleRequest(req, res, req.body); + return; + } + + if (!sessionId) { + if (!isInitializeRequest(req.body)) { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: req?.body?.id + }); + return; + } + + const server = createMcpServer(db, searchDb); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id: string) => { + transports.set(id, transport); + }, + // Local-only server; disable for MCP Inspector compatibility. + enableDnsRebindingProtection: false + }); + + transport.onclose = () => { + if (transport.sessionId) { + transports.delete(transport.sessionId); + } + }; + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: req?.body?.id + }); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: req?.body?.id + }); + } + } +}); + +router.get('/', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports.has(sessionId)) { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: (req as any)?.body?.id + }); + return; + } + + try { + const transport = transports.get(sessionId)!; + await transport.handleRequest(req, res); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: (req as any)?.body?.id + }); + } + } +}); + +router.delete('/', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports.has(sessionId)) { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: (req as any)?.body?.id + }); + return; + } + + try { + const transport = transports.get(sessionId)!; + await transport.handleRequest(req, res); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: (req as any)?.body?.id + }); + } + } +}); + +function createMcpServer(db: DatabaseManager, searchDb: SearchDatabase): McpServer { + const server = new McpServer({ + name: 'mega-memory', + version: '1.0.0' + }); + const registerTool = server.registerTool.bind(server) as any; + + registerTool( + 'search_sessions', + { + title: 'Search Sessions', + description: 'Full-text search across sessions with snippets and relevance scores.', + inputSchema: { + query: z.string().describe('Search query'), + project: z.string().optional().describe('Optional project UUID or project name'), + limit: z.number().int().min(1).max(MAX_SEARCH_LIMIT).optional().describe('Max results to return'), + offset: z.number().int().min(0).optional().describe('Result offset for pagination') + } + }, + async (args: any): Promise => { + const query = String(args.query || '').trim(); + const projectArg = args.project ? String(args.project).trim() : ''; + const limit = clampInt(args.limit, 1, MAX_SEARCH_LIMIT, DEFAULT_SEARCH_LIMIT); + const offset = clampInt(args.offset, 0, MAX_SEARCH_LIMIT, 0); + const safeLimit = Math.min(limit + offset, MAX_SEARCH_LIMIT); + + try { + const projects = db.projects.listAll(); + const projectMatch = resolveProject(projectArg, projects); + + const rawResults = projectMatch + ? searchDb.search.searchByProject(projectMatch.name, query, safeLimit) + : searchDb.search.search(query, safeLimit); + + const grouped = new Map< + string, + { + sessionId: string; + sessionTitle: string; + projectId: string; + projectName: string; + score: number; + snippets: string[]; + } + >(); + + rawResults.forEach((result) => { + let entry = grouped.get(result.sessionId); + if (!entry) { + entry = { + sessionId: result.sessionId, + sessionTitle: result.sessionTitle, + projectId: result.projectId, + projectName: result.projectName, + score: result.score, + snippets: [] + }; + grouped.set(result.sessionId, entry); + } + if (result.score > entry.score) { + entry.score = result.score; + } + if (entry.snippets.length < 5) { + entry.snippets.push(formatSnippet(result.content)); + } + }); + + const groupedResults = Array.from(grouped.values()); + const results = groupedResults.slice(offset, offset + limit); + + const payload = { + query, + project: projectMatch + ? { projectUuid: projectMatch.projectUuid, name: projectMatch.name } + : projectArg || null, + limit, + offset, + total: groupedResults.length, + results + }; + + return toJsonResult(payload); + } catch (error: any) { + const message = + error?.message && String(error.message).includes('fts5') + ? 'Invalid search query. Try simpler terms or use quotes for exact phrases.' + : error?.message || 'Search error'; + return toTextResult(message, true); + } + } + ); + + registerTool( + 'get_session', + { + title: 'Get Session', + description: 'Fetch a session with formatted message content.', + inputSchema: { + sessionId: z.string().describe('Session UUID') + } + }, + async (args: any): Promise => { + const sessionId = String(args.sessionId || '').trim(); + if (!sessionId) { + return toTextResult('Missing sessionId', true); + } + + const session = db.sessions.getBySessionId(sessionId); + if (!session || !session.id) { + return toTextResult(`Session not found: ${sessionId}`, true); + } + + const messages = db.messages.getBySessionId(session.id); + const projects = db.projects.listAll(); + const project = projects.find((p) => p.id === Number(session.projectId)); + + const formattedMessages = messages.map((msg) => ({ + sequence: msg.sequence, + cardType: msg.cardType, + title: msg.title, + subtitle: msg.subtitle, + timestamp: msg.timestamp, + markdown: formatRenderableMessage(msg) + })); + + const payload = { + session: { + sessionId: session.sessionId, + title: session.title, + provider: session.provider, + version: session.version, + gitBranch: session.gitBranch, + cwd: session.cwd, + models: session.modelsJson ? JSON.parse(session.modelsJson) : [], + created: session.created, + modified: session.modified, + messageCount: session.messageCount + }, + project: project + ? { + projectUuid: project.projectUuid, + name: project.name, + path: project.path + } + : null, + messages: formattedMessages + }; + + return toJsonResult(payload); + } + ); + + return server; +} + +function formatRenderableMessage(message: RenderableMessage): string { + const lines: string[] = []; + const header = `[${message.sequence}] ${message.cardType}${message.title ? `: ${message.title}` : ''}`; + lines.push(header); + if (message.subtitle) { + lines.push(message.subtitle); + } + + message.content.forEach((block) => { + lines.push(formatContentBlock(block)); + }); + + return lines.filter(Boolean).join('\n\n'); +} + +function formatContentBlock(block: MessageContent): string { + switch (block.type) { + case 'text': + return block.text; + case 'markdown': + return block.markdown; + case 'code': { + const lang = block.language ? block.language : ''; + return `\`\`\`${lang}\n${block.code}\n\`\`\``; + } + case 'json': + return `\`\`\`json\n${block.json}\n\`\`\``; + case 'diff': { + const header = block.filePath ? `# ${block.filePath}\n` : ''; + return `\`\`\`diff\n${header}${block.oldText}\n---\n${block.newText}\n\`\`\``; + } + case 'html': + return `\`\`\`html\n${block.html}\n\`\`\``; + default: + return ''; + } +} + +function formatSnippet(content: string): string { + const withoutMarks = content.replace(//g, '**').replace(/<\/mark>/g, '**'); + if (withoutMarks.length <= MAX_SNIPPET_CHARS) return withoutMarks; + return `${withoutMarks.slice(0, MAX_SNIPPET_CHARS)}...`; +} + +function resolveProject( + projectArg: string, + projects: Array<{ id: number; projectUuid: string; name: string }> +): { projectUuid: string; name: string } | null { + if (!projectArg) return null; + const byUuid = projects.find((project) => project.projectUuid === projectArg); + if (byUuid) return { projectUuid: byUuid.projectUuid, name: byUuid.name }; + const byName = projects.find((project) => project.name === projectArg); + if (byName) return { projectUuid: byName.projectUuid, name: byName.name }; + return null; +} + +function clampInt(value: any, min: number, max: number, fallback: number): number { + const num = Number(value); + if (!Number.isFinite(num)) return fallback; + return Math.min(max, Math.max(min, Math.floor(num))); +} + +function toJsonResult(payload: any): CallToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(payload, null, 2) + } + ] + }; +} + +function toTextResult(text: string, isError: boolean): CallToolResult { + return { + isError, + content: [ + { + type: 'text', + text + } + ] + }; +} + +export const mcpController = router;