mcp support
This commit is contained in:
@@ -57,6 +57,77 @@ $ npm run test:e2e
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## MCP-Agent anbinden
|
||||
|
||||
Die API enthaelt einen Remote-MCP-Server unter `/mcp`. Er nutzt den bestehenden JWT-Login der REST-API. Ein Agent oder MCP-Client darf deshalb nur mit einem gueltigen Access Token eines verifizierten Users auf die Tools zugreifen.
|
||||
|
||||
### Ablauf
|
||||
|
||||
1. User ueber die normale Auth-API anmelden, z. B. `POST /auth/login`.
|
||||
2. `accessToken` aus der Login-Antwort speichern.
|
||||
3. MCP-Client auf den Streamable-HTTP-Endpunkt konfigurieren:
|
||||
|
||||
```text
|
||||
http://localhost:3000/mcp
|
||||
```
|
||||
|
||||
4. Bei jedem MCP-Request diesen Header senden:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <accessToken>
|
||||
```
|
||||
|
||||
Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den vom Server gelieferten `mcp-session-id` Header mitsenden. Die Session ist an den User aus dem JWT gebunden; ein Token eines anderen Users kann dieselbe MCP-Session nicht weiterverwenden.
|
||||
|
||||
### Verfuegbare Tools
|
||||
|
||||
- `list_existing_lists`
|
||||
- Liest die Listen des angemeldeten Users.
|
||||
- Input: `{ "includeItems": true | false }`
|
||||
- Schreibt keine Daten.
|
||||
|
||||
- `list_templates`
|
||||
- Liest die Listenvorlagen des angemeldeten Users.
|
||||
- Input: optional `{ "kind": "packing" | "shopping" | "todo" | "custom" }`
|
||||
- Schreibt keine Daten.
|
||||
|
||||
- `suggest_lists`
|
||||
- Erzeugt strukturierte Vorschlaege fuer neue Listen.
|
||||
- Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"goal": "Sommerurlaub mit Handgepaeck",
|
||||
"kind": "packing",
|
||||
"constraints": ["Nur Handgepaeck", "Reisedokumente nicht vergessen"]
|
||||
}
|
||||
```
|
||||
|
||||
- Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`.
|
||||
- Schreibt keine Daten und legt keine Liste an.
|
||||
|
||||
### Minimaler MCP-Request
|
||||
|
||||
Ein MCP-Client uebernimmt normalerweise Initialize, Session-Header und Tool-Calls selbst. Fuer eigene Tests sieht ein Tool-Call nach erfolgreicher Initialisierung sinngemaess so aus:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "suggest_lists",
|
||||
"arguments": {
|
||||
"goal": "Sommerurlaub mit Handgepaeck",
|
||||
"kind": "packing",
|
||||
"constraints": ["Nur Handgepaeck"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wichtig: Der aktuelle Ausbau ist absichtlich read-only. Wenn ein Agent spaeter Listen direkt erstellen darf, sollte dafuer ein separates MCP-Tool mit expliziter Bestaetigung und Audit-Logging ergaenzt werden.
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
183
listify-api/package-lock.json
generated
183
listify-api/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@nestjs-modules/mailer": "^2.3.6",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
@@ -24,7 +25,8 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.30"
|
||||
"typeorm": "^0.3.30",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -1176,6 +1178,18 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -2288,6 +2302,68 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"hono": "^4.11.4",
|
||||
"jose": "^6.1.3",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/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/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -4303,7 +4379,6 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
@@ -4321,7 +4396,6 @@
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -4338,7 +4412,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv-keywords": {
|
||||
@@ -6547,6 +6620,27 @@
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
@@ -6649,6 +6743,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-object": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/extend-object/-/extend-object-1.0.0.tgz",
|
||||
@@ -6660,7 +6772,6 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
@@ -6694,7 +6805,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7376,6 +7486,15 @@
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.25",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||
"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",
|
||||
@@ -7693,6 +7812,15 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -8783,6 +8911,15 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||
@@ -8955,6 +9092,12 @@
|
||||
"dev": true,
|
||||
"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/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
@@ -10890,6 +11033,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",
|
||||
@@ -12050,7 +12202,6 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -14407,6 +14558,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.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@nestjs-modules/mailer": "^2.3.6",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
@@ -39,7 +40,8 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.30"
|
||||
"typeorm": "^0.3.30",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AuthModule } from './auth/auth.module';
|
||||
import { ListTemplatesModule } from './list-templates/list-templates.module';
|
||||
import { ListsModule } from './lists/lists.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { McpModule } from './mcp/mcp.module';
|
||||
import {
|
||||
databaseLoggerOptionsFromEnv,
|
||||
parseDatabaseLogging,
|
||||
@@ -56,6 +57,7 @@ import { DatabaseLogger } from './database/database.logger';
|
||||
MailModule,
|
||||
ListsModule,
|
||||
ListTemplatesModule,
|
||||
McpModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
143
listify-api/src/mcp/list-suggestion-agent.service.spec.ts
Normal file
143
listify-api/src/mcp/list-suggestion-agent.service.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
ListTemplate,
|
||||
UserList,
|
||||
} from '../list-templates/list-template.types';
|
||||
import { ListTemplatesService } from '../list-templates/list-templates.service';
|
||||
import { ListsService } from '../lists/lists.service';
|
||||
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
||||
|
||||
describe('ListSuggestionAgentService', () => {
|
||||
let listsService: Pick<ListsService, 'listLists' | 'createList'>;
|
||||
let listTemplatesService: Pick<ListTemplatesService, 'listTemplates'>;
|
||||
let service: ListSuggestionAgentService;
|
||||
|
||||
beforeEach(() => {
|
||||
listsService = {
|
||||
listLists: jest.fn(),
|
||||
createList: jest.fn(),
|
||||
};
|
||||
listTemplatesService = {
|
||||
listTemplates: jest.fn(),
|
||||
};
|
||||
service = new ListSuggestionAgentService(
|
||||
listsService as ListsService,
|
||||
listTemplatesService as ListTemplatesService,
|
||||
);
|
||||
});
|
||||
|
||||
it('suggests read-only list ideas from matching templates', async () => {
|
||||
jest.mocked(listsService.listLists).mockResolvedValue([
|
||||
list({ name: 'Urlaub: Sommerurlaub' }),
|
||||
]);
|
||||
jest.mocked(listTemplatesService.listTemplates).mockResolvedValue([
|
||||
template({
|
||||
id: 'template-1',
|
||||
name: 'Urlaub',
|
||||
kind: 'packing',
|
||||
items: ['Pass', 'Tickets', 'Ladegeraete'],
|
||||
}),
|
||||
template({
|
||||
id: 'template-2',
|
||||
name: 'Wocheneinkauf',
|
||||
kind: 'shopping',
|
||||
items: ['Milch'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await service.suggestLists('user-1', {
|
||||
goal: 'Sommerurlaub',
|
||||
constraints: ['Handgepaeck beachten'],
|
||||
});
|
||||
|
||||
expect(result.suggestions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'Urlaub: Sommerurlaub 2',
|
||||
kind: 'packing',
|
||||
sourceTemplateId: 'template-1',
|
||||
}),
|
||||
);
|
||||
expect(result.suggestions[0].items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'Handgepaeck beachten',
|
||||
required: true,
|
||||
}),
|
||||
);
|
||||
expect(listsService.createList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to inferred kind when no template matches', async () => {
|
||||
jest.mocked(listsService.listLists).mockResolvedValue([]);
|
||||
jest.mocked(listTemplatesService.listTemplates).mockResolvedValue([]);
|
||||
|
||||
const result = await service.suggestLists('user-1', {
|
||||
goal: 'Projektplanung fuer Release',
|
||||
});
|
||||
|
||||
expect(result.suggestions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'Projektplanung fuer Release',
|
||||
kind: 'todo',
|
||||
}),
|
||||
);
|
||||
expect(result.suggestions[0].items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: 'Ziel klaeren' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty goals and invalid constraints', async () => {
|
||||
await expect(service.suggestLists('user-1', { goal: ' ' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
service.suggestLists('user-1', {
|
||||
goal: 'Liste',
|
||||
constraints: 'bad' as never,
|
||||
}),
|
||||
).rejects.toThrow('Constraints must be an array.');
|
||||
});
|
||||
});
|
||||
|
||||
function template(options: {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ListTemplate['kind'];
|
||||
items: string[];
|
||||
}): ListTemplate {
|
||||
return {
|
||||
id: options.id,
|
||||
ownerId: 'user-1',
|
||||
name: options.name,
|
||||
kind: options.kind,
|
||||
items: options.items.map((title, position) => ({
|
||||
id: `${options.id}-item-${position}`,
|
||||
title,
|
||||
required: true,
|
||||
position,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})),
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function list(options: { name: string }): UserList {
|
||||
return {
|
||||
id: 'list-1',
|
||||
ownerId: 'user-1',
|
||||
accessRole: 'owner',
|
||||
name: options.name,
|
||||
kind: 'packing',
|
||||
items: [],
|
||||
collaborators: [],
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function now(): string {
|
||||
return new Date(0).toISOString();
|
||||
}
|
||||
280
listify-api/src/mcp/list-suggestion-agent.service.ts
Normal file
280
listify-api/src/mcp/list-suggestion-agent.service.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ListTemplate,
|
||||
ListTemplateKind,
|
||||
UserList,
|
||||
} from '../list-templates/list-template.types';
|
||||
import { ListTemplatesService } from '../list-templates/list-templates.service';
|
||||
import { ListsService } from '../lists/lists.service';
|
||||
import {
|
||||
ListSuggestionsResult,
|
||||
SuggestListsInput,
|
||||
SuggestedList,
|
||||
SuggestedListItem,
|
||||
} from './list-suggestion.types';
|
||||
|
||||
@Injectable()
|
||||
export class ListSuggestionAgentService {
|
||||
constructor(
|
||||
private readonly listsService: ListsService,
|
||||
private readonly listTemplatesService: ListTemplatesService,
|
||||
) {}
|
||||
|
||||
async suggestLists(
|
||||
userId: string,
|
||||
input: SuggestListsInput,
|
||||
): Promise<ListSuggestionsResult> {
|
||||
const goal = this.requireGoal(input.goal);
|
||||
const kind = this.normalizeKind(input.kind) ?? this.inferKind(goal);
|
||||
const constraints = this.normalizeConstraints(input.constraints);
|
||||
const [lists, templates] = await Promise.all([
|
||||
this.listsService.listLists(userId),
|
||||
this.listTemplatesService.listTemplates(userId),
|
||||
]);
|
||||
const existingNames = new Set(lists.map((list) => this.nameKey(list.name)));
|
||||
const matchingTemplates = this.rankTemplates(templates, goal, kind).slice(0, 2);
|
||||
const suggestions = matchingTemplates.map((template) =>
|
||||
this.suggestFromTemplate(template, goal, constraints, existingNames),
|
||||
);
|
||||
|
||||
if (suggestions.length < 3) {
|
||||
suggestions.push(
|
||||
this.suggestFallbackList(goal, kind, constraints, existingNames),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: suggestions.slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
private suggestFromTemplate(
|
||||
template: ListTemplate,
|
||||
goal: string,
|
||||
constraints: string[],
|
||||
existingNames: Set<string>,
|
||||
): SuggestedList {
|
||||
const name = this.uniqueName(
|
||||
`${template.name}: ${this.toTitleFragment(goal)}`,
|
||||
existingNames,
|
||||
);
|
||||
const items = [...template.items]
|
||||
.sort((left, right) => left.position - right.position)
|
||||
.slice(0, 12)
|
||||
.map((item) => ({
|
||||
title: item.title,
|
||||
notes: item.notes,
|
||||
quantity: item.quantity,
|
||||
required: item.required,
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
template.description ??
|
||||
`Vorschlag auf Basis der Vorlage "${template.name}".`,
|
||||
kind: template.kind,
|
||||
items: this.withConstraintItems(items, constraints),
|
||||
sourceTemplateId: template.id,
|
||||
sourceTemplateName: template.name,
|
||||
rationale: `Nutzt die bestehende Vorlage "${template.name}", weil sie zum Ziel passt.`,
|
||||
};
|
||||
}
|
||||
|
||||
private suggestFallbackList(
|
||||
goal: string,
|
||||
kind: ListTemplateKind,
|
||||
constraints: string[],
|
||||
existingNames: Set<string>,
|
||||
): SuggestedList {
|
||||
return {
|
||||
name: this.uniqueName(this.toTitleFragment(goal), existingNames),
|
||||
description: `Neue ${this.kindLabel(kind)} fuer ${goal}.`,
|
||||
kind,
|
||||
items: this.withConstraintItems(this.defaultItems(kind), constraints),
|
||||
rationale:
|
||||
'Erzeugt eine neue Liste, weil keine passendere Vorlage priorisiert wurde.',
|
||||
};
|
||||
}
|
||||
|
||||
private rankTemplates(
|
||||
templates: ListTemplate[],
|
||||
goal: string,
|
||||
kind: ListTemplateKind,
|
||||
): ListTemplate[] {
|
||||
const goalTokens = this.tokenize(goal);
|
||||
|
||||
return [...templates]
|
||||
.map((template) => ({
|
||||
template,
|
||||
score:
|
||||
(template.kind === kind ? 5 : 0) +
|
||||
this.tokenScore(goalTokens, template.name) +
|
||||
this.tokenScore(goalTokens, template.description ?? '') +
|
||||
template.items.reduce(
|
||||
(score, item) => score + this.tokenScore(goalTokens, item.title),
|
||||
0,
|
||||
),
|
||||
}))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.map((entry) => entry.template);
|
||||
}
|
||||
|
||||
private withConstraintItems(
|
||||
items: SuggestedListItem[],
|
||||
constraints: string[],
|
||||
): SuggestedListItem[] {
|
||||
const constraintItems = constraints.map((constraint) => ({
|
||||
title: constraint,
|
||||
notes: 'Vom Nutzer genannte Randbedingung.',
|
||||
required: true,
|
||||
}));
|
||||
|
||||
return [...constraintItems, ...items].slice(0, 15);
|
||||
}
|
||||
|
||||
private defaultItems(kind: ListTemplateKind): SuggestedListItem[] {
|
||||
if (kind === 'packing') {
|
||||
return [
|
||||
{ title: 'Reisedokumente pruefen', required: true },
|
||||
{ title: 'Tickets und Buchungen sichern', required: true },
|
||||
{ title: 'Ladegeraete einpacken', required: true },
|
||||
{ title: 'Kleidung nach Wetter planen', required: true },
|
||||
{ title: 'Reiseapotheke vorbereiten', required: false },
|
||||
];
|
||||
}
|
||||
|
||||
if (kind === 'shopping') {
|
||||
return [
|
||||
{ title: 'Grundnahrungsmittel', required: true },
|
||||
{ title: 'Frisches Obst und Gemuese', required: true },
|
||||
{ title: 'Getraenke', required: false },
|
||||
{ title: 'Haushaltsartikel', required: false },
|
||||
{ title: 'Vorratsschrank pruefen', required: true },
|
||||
];
|
||||
}
|
||||
|
||||
if (kind === 'todo') {
|
||||
return [
|
||||
{ title: 'Ziel klaeren', required: true },
|
||||
{ title: 'Naechste konkrete Aufgabe festlegen', required: true },
|
||||
{ title: 'Abhaengigkeiten pruefen', required: true },
|
||||
{ title: 'Zeitfenster einplanen', required: false },
|
||||
{ title: 'Offene Punkte nachfassen', required: false },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ title: 'Ziel pruefen', required: true },
|
||||
{ title: 'Wichtige Punkte sammeln', required: true },
|
||||
{ title: 'Prioritaeten setzen', required: true },
|
||||
{ title: 'Naechsten Schritt festlegen', required: false },
|
||||
];
|
||||
}
|
||||
|
||||
private inferKind(goal: string): ListTemplateKind {
|
||||
const normalizedGoal = goal.toLowerCase();
|
||||
|
||||
if (/(urlaub|reise|pack|koffer|trip|flug|hotel)/.test(normalizedGoal)) {
|
||||
return 'packing';
|
||||
}
|
||||
|
||||
if (/(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal)) {
|
||||
return 'shopping';
|
||||
}
|
||||
|
||||
if (/(todo|aufgabe|projekt|planung|woche|erledigen)/.test(normalizedGoal)) {
|
||||
return 'todo';
|
||||
}
|
||||
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
private normalizeKind(kind?: ListTemplateKind): ListTemplateKind | undefined {
|
||||
if (kind === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
kind !== 'packing' &&
|
||||
kind !== 'shopping' &&
|
||||
kind !== 'todo' &&
|
||||
kind !== 'custom'
|
||||
) {
|
||||
throw new BadRequestException('List kind is invalid.');
|
||||
}
|
||||
|
||||
return kind;
|
||||
}
|
||||
|
||||
private normalizeConstraints(constraints?: string[]): string[] {
|
||||
if (constraints === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(constraints)) {
|
||||
throw new BadRequestException('Constraints must be an array.');
|
||||
}
|
||||
|
||||
return constraints
|
||||
.map((constraint) => constraint.trim())
|
||||
.filter((constraint) => constraint.length > 0)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
private requireGoal(goal?: string): string {
|
||||
const normalizedGoal = goal?.trim();
|
||||
|
||||
if (!normalizedGoal) {
|
||||
throw new BadRequestException('Suggestion goal is required.');
|
||||
}
|
||||
|
||||
return normalizedGoal;
|
||||
}
|
||||
|
||||
private uniqueName(name: string, existingNames: Set<string>): string {
|
||||
let candidate = name;
|
||||
let suffix = 2;
|
||||
|
||||
while (existingNames.has(this.nameKey(candidate))) {
|
||||
candidate = `${name} ${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
existingNames.add(this.nameKey(candidate));
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private tokenScore(tokens: string[], value: string): number {
|
||||
const normalizedValue = value.toLowerCase();
|
||||
return tokens.filter((token) => normalizedValue.includes(token)).length;
|
||||
}
|
||||
|
||||
private tokenize(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/i)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 3);
|
||||
}
|
||||
|
||||
private toTitleFragment(value: string): string {
|
||||
const compactValue = value.replace(/\s+/g, ' ').trim();
|
||||
return compactValue.charAt(0).toUpperCase() + compactValue.slice(1);
|
||||
}
|
||||
|
||||
private nameKey(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private kindLabel(kind: ListTemplateKind): string {
|
||||
return kind === 'packing'
|
||||
? 'Packliste'
|
||||
: kind === 'shopping'
|
||||
? 'Einkaufsliste'
|
||||
: kind === 'todo'
|
||||
? 'Todo-Liste'
|
||||
: 'Liste';
|
||||
}
|
||||
}
|
||||
28
listify-api/src/mcp/list-suggestion.types.ts
Normal file
28
listify-api/src/mcp/list-suggestion.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ListTemplateKind } from '../list-templates/list-template.types';
|
||||
|
||||
export interface SuggestListsInput {
|
||||
goal?: string;
|
||||
kind?: ListTemplateKind;
|
||||
constraints?: string[];
|
||||
}
|
||||
|
||||
export interface SuggestedListItem {
|
||||
title: string;
|
||||
notes?: string;
|
||||
quantity?: number;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface SuggestedList {
|
||||
name: string;
|
||||
description?: string;
|
||||
kind: ListTemplateKind;
|
||||
items: SuggestedListItem[];
|
||||
sourceTemplateId?: string;
|
||||
sourceTemplateName?: string;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
export interface ListSuggestionsResult {
|
||||
suggestions: SuggestedList[];
|
||||
}
|
||||
161
listify-api/src/mcp/mcp-server.service.ts
Normal file
161
listify-api/src/mcp/mcp-server.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import * as z from 'zod/v4';
|
||||
import { ListTemplateKind } from '../list-templates/list-template.types';
|
||||
import { ListTemplatesService } from '../list-templates/list-templates.service';
|
||||
import { ListsService } from '../lists/lists.service';
|
||||
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
||||
|
||||
const listKindSchema = z
|
||||
.enum(['packing', 'shopping', 'todo', 'custom'])
|
||||
.optional();
|
||||
|
||||
@Injectable()
|
||||
export class McpServerService {
|
||||
constructor(
|
||||
private readonly listsService: ListsService,
|
||||
private readonly listTemplatesService: ListTemplatesService,
|
||||
private readonly listSuggestionAgentService: ListSuggestionAgentService,
|
||||
) {}
|
||||
|
||||
createServer(userId: string): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'listify',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
'list_existing_lists',
|
||||
{
|
||||
title: 'List existing lists',
|
||||
description:
|
||||
'Returns the authenticated user lists. This tool is read-only.',
|
||||
inputSchema: {
|
||||
includeItems: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether to include list items in the response.'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ includeItems = false }) => {
|
||||
const lists = await this.listsService.listLists(userId);
|
||||
const result = {
|
||||
lists: lists.map((list) => ({
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
description: list.description,
|
||||
kind: list.kind,
|
||||
accessRole: list.accessRole,
|
||||
itemCount: list.items.length,
|
||||
items: includeItems
|
||||
? list.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
notes: item.notes,
|
||||
quantity: item.quantity,
|
||||
required: item.required,
|
||||
checked: item.checked,
|
||||
position: item.position,
|
||||
}))
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
return this.toToolResult(result);
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_templates',
|
||||
{
|
||||
title: 'List templates',
|
||||
description:
|
||||
'Returns the authenticated user list templates. This tool is read-only.',
|
||||
inputSchema: {
|
||||
kind: listKindSchema.describe('Optional template kind filter.'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ kind }) => {
|
||||
const templates = await this.listTemplatesService.listTemplates(userId);
|
||||
const result = {
|
||||
templates: templates
|
||||
.filter((template) => !kind || template.kind === kind)
|
||||
.map((template) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
kind: template.kind,
|
||||
items: template.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
notes: item.notes,
|
||||
quantity: item.quantity,
|
||||
required: item.required,
|
||||
position: item.position,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
return this.toToolResult(result);
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'suggest_lists',
|
||||
{
|
||||
title: 'Suggest lists',
|
||||
description:
|
||||
'Suggests new lists for the authenticated user without creating or modifying data.',
|
||||
inputSchema: {
|
||||
goal: z.string().min(1).describe('What the user wants a list for.'),
|
||||
kind: listKindSchema.describe('Optional desired list kind.'),
|
||||
constraints: z
|
||||
.array(z.string().min(1))
|
||||
.optional()
|
||||
.describe('Optional constraints or must-have list items.'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ goal, kind, constraints }) => {
|
||||
const result = await this.listSuggestionAgentService.suggestLists(
|
||||
userId,
|
||||
{
|
||||
goal,
|
||||
kind: kind as ListTemplateKind | undefined,
|
||||
constraints,
|
||||
},
|
||||
);
|
||||
|
||||
return this.toToolResult(result);
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
private toToolResult(data: object) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
structuredContent: data as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
}
|
||||
214
listify-api/src/mcp/mcp.controller.ts
Normal file
214
listify-api/src/mcp/mcp.controller.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { McpServerService } from './mcp-server.service';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
||||
interface McpSession {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
@Controller('mcp')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class McpController {
|
||||
private readonly sessions = new Map<string, McpSession>();
|
||||
|
||||
constructor(private readonly mcpServerService: McpServerService) {}
|
||||
|
||||
@Post()
|
||||
async post(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const userId = this.requireUserId(request);
|
||||
const sessionId = this.sessionIdFrom(request);
|
||||
|
||||
if (sessionId) {
|
||||
await this.handleExistingSession(sessionId, userId, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInitializeRequest(request.body)) {
|
||||
this.writeJsonRpcError(
|
||||
response,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
-32000,
|
||||
'Bad Request: No valid session ID provided.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleInitialize(userId, request, response);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async get(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.handleSessionRequest(request, response);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
async delete(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.handleSessionRequest(request, response);
|
||||
}
|
||||
|
||||
private async handleInitialize(
|
||||
userId: string,
|
||||
request: AuthenticatedRequest,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
const server = this.mcpServerService.createServer(userId);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
this.sessions.set(sessionId, {
|
||||
server,
|
||||
transport,
|
||||
userId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
const sessionId = transport.sessionId;
|
||||
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
void server.close();
|
||||
};
|
||||
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(request, response, request.body);
|
||||
} catch (error) {
|
||||
await this.closeSession(server, transport);
|
||||
|
||||
if (!response.headersSent) {
|
||||
this.writeJsonRpcError(
|
||||
response,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
-32603,
|
||||
'Internal server error.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSessionRequest(
|
||||
request: AuthenticatedRequest,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
const userId = this.requireUserId(request);
|
||||
const sessionId = this.sessionIdFrom(request);
|
||||
|
||||
if (!sessionId) {
|
||||
response.status(HttpStatus.BAD_REQUEST).send('Missing MCP session ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleExistingSession(sessionId, userId, request, response);
|
||||
}
|
||||
|
||||
private async handleExistingSession(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
request: AuthenticatedRequest,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
response.status(HttpStatus.BAD_REQUEST).send('Invalid MCP session ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.userId !== userId) {
|
||||
throw new ForbiddenException('MCP session belongs to another user.');
|
||||
}
|
||||
|
||||
try {
|
||||
await session.transport.handleRequest(request, response, request.body);
|
||||
} catch (error) {
|
||||
if (!response.headersSent) {
|
||||
this.writeJsonRpcError(
|
||||
response,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
-32603,
|
||||
'Internal server error.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async closeSession(
|
||||
server: McpServer,
|
||||
transport: StreamableHTTPServerTransport,
|
||||
): Promise<void> {
|
||||
const sessionId = transport.sessionId;
|
||||
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
await transport.close();
|
||||
await server.close();
|
||||
}
|
||||
|
||||
private requireUserId(request: AuthenticatedRequest): string {
|
||||
if (!request.user?.sub) {
|
||||
throw new UnauthorizedException('Authenticated user is required.');
|
||||
}
|
||||
|
||||
return request.user.sub;
|
||||
}
|
||||
|
||||
private sessionIdFrom(request: AuthenticatedRequest): string | undefined {
|
||||
const sessionId = request.headers['mcp-session-id'];
|
||||
|
||||
if (Array.isArray(sessionId)) {
|
||||
return sessionId[0];
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private writeJsonRpcError(
|
||||
response: Response,
|
||||
status: number,
|
||||
code: number,
|
||||
message: string,
|
||||
): void {
|
||||
response.status(status).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
13
listify-api/src/mcp/mcp.module.ts
Normal file
13
listify-api/src/mcp/mcp.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ListTemplatesModule } from '../list-templates/list-templates.module';
|
||||
import { ListsModule } from '../lists/lists.module';
|
||||
import { McpController } from './mcp.controller';
|
||||
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
||||
import { McpServerService } from './mcp-server.service';
|
||||
|
||||
@Module({
|
||||
imports: [ListsModule, ListTemplatesModule],
|
||||
controllers: [McpController],
|
||||
providers: [ListSuggestionAgentService, McpServerService],
|
||||
})
|
||||
export class McpModule {}
|
||||
Reference in New Issue
Block a user