diff --git a/listify-api/README.md b/listify-api/README.md index 8f0f65f..dbb5a80 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -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 +``` + +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. diff --git a/listify-api/package-lock.json b/listify-api/package-lock.json index 07db17d..d0f7d38 100644 --- a/listify-api/package-lock.json +++ b/listify-api/package-lock.json @@ -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" + } } } } diff --git a/listify-api/package.json b/listify-api/package.json index 9191fbe..276c608 100644 --- a/listify-api/package.json +++ b/listify-api/package.json @@ -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", diff --git a/listify-api/src/app.module.ts b/listify-api/src/app.module.ts index 2fcf3d4..6688fc8 100644 --- a/listify-api/src/app.module.ts +++ b/listify-api/src/app.module.ts @@ -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], diff --git a/listify-api/src/mcp/list-suggestion-agent.service.spec.ts b/listify-api/src/mcp/list-suggestion-agent.service.spec.ts new file mode 100644 index 0000000..f783573 --- /dev/null +++ b/listify-api/src/mcp/list-suggestion-agent.service.spec.ts @@ -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; + let listTemplatesService: Pick; + 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(); +} diff --git a/listify-api/src/mcp/list-suggestion-agent.service.ts b/listify-api/src/mcp/list-suggestion-agent.service.ts new file mode 100644 index 0000000..e0477d9 --- /dev/null +++ b/listify-api/src/mcp/list-suggestion-agent.service.ts @@ -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 { + 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, + ): 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, + ): 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 { + 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'; + } +} diff --git a/listify-api/src/mcp/list-suggestion.types.ts b/listify-api/src/mcp/list-suggestion.types.ts new file mode 100644 index 0000000..8a64c3f --- /dev/null +++ b/listify-api/src/mcp/list-suggestion.types.ts @@ -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[]; +} diff --git a/listify-api/src/mcp/mcp-server.service.ts b/listify-api/src/mcp/mcp-server.service.ts new file mode 100644 index 0000000..c02cadf --- /dev/null +++ b/listify-api/src/mcp/mcp-server.service.ts @@ -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, + }; + } +} diff --git a/listify-api/src/mcp/mcp.controller.ts b/listify-api/src/mcp/mcp.controller.ts new file mode 100644 index 0000000..237275c --- /dev/null +++ b/listify-api/src/mcp/mcp.controller.ts @@ -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(); + + constructor(private readonly mcpServerService: McpServerService) {} + + @Post() + async post( + @Req() request: AuthenticatedRequest, + @Res() response: Response, + ): Promise { + 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 { + await this.handleSessionRequest(request, response); + } + + @Delete() + async delete( + @Req() request: AuthenticatedRequest, + @Res() response: Response, + ): Promise { + await this.handleSessionRequest(request, response); + } + + private async handleInitialize( + userId: string, + request: AuthenticatedRequest, + response: Response, + ): Promise { + 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 { + 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 { + 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 { + 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, + }); + } +} diff --git a/listify-api/src/mcp/mcp.module.ts b/listify-api/src/mcp/mcp.module.ts new file mode 100644 index 0000000..0847d4c --- /dev/null +++ b/listify-api/src/mcp/mcp.module.ts @@ -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 {} diff --git a/readme.md b/readme.md index cc0bca2..87f502d 100644 --- a/readme.md +++ b/readme.md @@ -1,2 +1,26 @@ docker build -t listify:local . - docker run --env-file .\listify-api\.env -p 8080:80 listify:local \ No newline at end of file + docker run --env-file .\listify-api\.env -p 8080:80 listify:local + +## MCP-Agent anbinden + +Das Backend stellt einen Remote-MCP-Endpunkt unter `/mcp` bereit. Der Endpunkt nutzt denselben Bearer-JWT wie die REST-API, d. h. ein Agent muss sich zuerst ueber die normale Auth-API anmelden und den `accessToken` bei allen MCP-Requests als `Authorization: Bearer ` mitsenden. + +Lokal laeuft der MCP-Endpunkt bei der API standardmaessig unter: + +```text +http://localhost:3000/mcp +``` + +Im Docker-Setup mit obigem Port-Mapping ist er erreichbar unter: + +```text +http://localhost:8080/mcp +``` + +Verfuegbare MCP-Tools: + +- `list_existing_lists`: liest die Listen des angemeldeten Users. +- `list_templates`: liest die Listenvorlagen des angemeldeten Users. +- `suggest_lists`: erzeugt strukturierte Vorschlaege fuer neue Listen, schreibt aber nichts in die Datenbank. + +Weitere Details und Beispiel-Requests stehen in `listify-api/README.md`.