mcp support

This commit is contained in:
Bastian Wagner
2026-06-11 11:19:07 +02:00
parent e1cc78ca27
commit c8603be226
11 changed files with 1116 additions and 9 deletions

View File

@@ -57,6 +57,77 @@ $ npm run test:e2e
$ npm run test:cov $ 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 ## 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. 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.

View File

@@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs-modules/mailer": "^2.3.6", "@nestjs-modules/mailer": "^2.3.6",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
@@ -24,7 +25,8 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.30" "typeorm": "^0.3.30",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -1176,6 +1178,18 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2288,6 +2302,68 @@
"node": ">=8" "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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -4303,7 +4379,6 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ajv": "^8.0.0" "ajv": "^8.0.0"
@@ -4321,7 +4396,6 @@
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -4338,7 +4412,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ajv-keywords": { "node_modules/ajv-keywords": {
@@ -6547,6 +6620,27 @@
"node": ">=0.8.x" "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": { "node_modules/execa": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -6649,6 +6743,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/extend-object": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/extend-object/-/extend-object-1.0.0.tgz", "resolved": "https://registry.npmjs.org/extend-object/-/extend-object-1.0.0.tgz",
@@ -6660,7 +6772,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
@@ -6694,7 +6805,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -7376,6 +7486,15 @@
"url": "https://github.com/sponsors/EvanHahn" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -7693,6 +7812,15 @@
"license": "ISC", "license": "ISC",
"optional": true "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "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" "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": { "node_modules/js-beautify": {
"version": "1.15.4", "version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
@@ -8955,6 +9092,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "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": ">= 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": { "node_modules/pkg-dir": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@@ -12050,7 +12202,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "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==", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -14407,6 +14558,24 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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"
}
} }
} }
} }

View File

@@ -24,6 +24,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs-modules/mailer": "^2.3.6", "@nestjs-modules/mailer": "^2.3.6",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
@@ -39,7 +40,8 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.30" "typeorm": "^0.3.30",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

View File

@@ -9,6 +9,7 @@ import { AuthModule } from './auth/auth.module';
import { ListTemplatesModule } from './list-templates/list-templates.module'; import { ListTemplatesModule } from './list-templates/list-templates.module';
import { ListsModule } from './lists/lists.module'; import { ListsModule } from './lists/lists.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { McpModule } from './mcp/mcp.module';
import { import {
databaseLoggerOptionsFromEnv, databaseLoggerOptionsFromEnv,
parseDatabaseLogging, parseDatabaseLogging,
@@ -56,6 +57,7 @@ import { DatabaseLogger } from './database/database.logger';
MailModule, MailModule,
ListsModule, ListsModule,
ListTemplatesModule, ListTemplatesModule,
McpModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View 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();
}

View 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';
}
}

View 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[];
}

View 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>,
};
}
}

View 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,
});
}
}

View 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 {}

View File

@@ -1,2 +1,26 @@
docker build -t listify:local . docker build -t listify:local .
docker run --env-file .\listify-api\.env -p 8080:80 listify:local 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 <accessToken>` 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`.