From 4dec991c4a4e7d3f3dde932d3fb6f5538b54c86b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 11 Jun 2026 17:45:40 +0200 Subject: [PATCH] mcp --- listify-api/README.md | 36 +++- .../src/mcp/mcp-server.service.spec.ts | 186 ++++++++++++++++++ listify-api/src/mcp/mcp-server.service.ts | 81 ++++++++ readme.md | 2 + 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 listify-api/src/mcp/mcp-server.service.spec.ts diff --git a/listify-api/README.md b/listify-api/README.md index dbb5a80..ff3238b 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -106,6 +106,40 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den - Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`. - Schreibt keine Daten und legt keine Liste an. +- `create_list` + - Erstellt eine neue Liste fuer den angemeldeten User. + - Input: + +```json +{ + "name": "Sommerurlaub", + "description": "Packliste fuer die Reise", + "kind": "packing", + "items": [ + { "title": "Pass", "required": true }, + { "title": "Tickets", "notes": "Digital und offline sichern" } + ] +} +``` + + - `items` ist optional. Wenn Items angegeben sind, werden sie nach dem Erstellen der Liste in Reihenfolge hinzugefuegt. + - Output enthaelt `list` mit der erstellten Liste inklusive Items. + +- `add_list_item` + - Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der angemeldete User Zugriff hat. + - Input: + +```json +{ + "listId": "list-id", + "title": "Milch", + "quantity": 2, + "required": true +} +``` + + - Output enthaelt `list` mit der aktualisierten Liste. + ### 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: @@ -126,7 +160,7 @@ Ein MCP-Client uebernimmt normalerweise Initialize, Session-Header und Tool-Call } ``` -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. +Wichtig: Der aktuelle Ausbau erlaubt Erstellen von Listen und Hinzufuegen von Items. Aendern, Abhaken, Loeschen und Teilen von Listen ist ueber MCP noch nicht freigegeben. ## Deployment diff --git a/listify-api/src/mcp/mcp-server.service.spec.ts b/listify-api/src/mcp/mcp-server.service.spec.ts new file mode 100644 index 0000000..6e6a872 --- /dev/null +++ b/listify-api/src/mcp/mcp-server.service.spec.ts @@ -0,0 +1,186 @@ +import { ListTemplatesService } from '../list-templates/list-templates.service'; +import { UserList } from '../list-templates/list-template.types'; +import { ListsService } from '../lists/lists.service'; +import { ListSuggestionAgentService } from './list-suggestion-agent.service'; +import { McpServerService } from './mcp-server.service'; + +describe('McpServerService', () => { + let listsService: Pick; + let listTemplatesService: Pick; + let listSuggestionAgentService: Pick< + ListSuggestionAgentService, + 'suggestLists' + >; + let service: McpServerService; + + beforeEach(() => { + listsService = { + listLists: jest.fn(), + createList: jest.fn(), + addItem: jest.fn(), + }; + listTemplatesService = { + listTemplates: jest.fn(), + }; + listSuggestionAgentService = { + suggestLists: jest.fn(), + }; + service = new McpServerService( + listsService as ListsService, + listTemplatesService as ListTemplatesService, + listSuggestionAgentService as ListSuggestionAgentService, + ); + }); + + it('registers create_list as a write tool and creates initial items in order', async () => { + const createdList = list({ id: 'list-1', name: 'Sommerurlaub' }); + const withFirstItem = list({ + id: 'list-1', + name: 'Sommerurlaub', + items: ['Pass'], + }); + const withSecondItem = list({ + id: 'list-1', + name: 'Sommerurlaub', + items: ['Pass', 'Tickets'], + }); + jest.mocked(listsService.createList).mockResolvedValue(createdList); + jest + .mocked(listsService.addItem) + .mockResolvedValueOnce(withFirstItem) + .mockResolvedValueOnce(withSecondItem); + + const tool = toolFrom(service.createServer('user-1'), 'create_list'); + const result = await tool.handler( + { + name: 'Sommerurlaub', + description: 'Packliste', + kind: 'packing', + items: [ + { title: 'Pass', required: true }, + { title: 'Tickets', notes: 'Ausdrucken', required: false }, + ], + }, + {} as never, + ); + + expect(tool.annotations).toEqual( + expect.objectContaining({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }), + ); + expect(listsService.createList).toHaveBeenCalledWith('user-1', { + name: 'Sommerurlaub', + description: 'Packliste', + kind: 'packing', + }); + expect(listsService.addItem).toHaveBeenNthCalledWith(1, 'user-1', 'list-1', { + title: 'Pass', + required: true, + }); + expect(listsService.addItem).toHaveBeenNthCalledWith(2, 'user-1', 'list-1', { + title: 'Tickets', + notes: 'Ausdrucken', + required: false, + }); + expect(result.structuredContent).toEqual({ list: withSecondItem }); + }); + + it('creates a list without items', async () => { + const createdList = list({ id: 'list-1', name: 'Ideen' }); + jest.mocked(listsService.createList).mockResolvedValue(createdList); + + const tool = toolFrom(service.createServer('user-1'), 'create_list'); + const result = await tool.handler( + { + name: 'Ideen', + }, + {} as never, + ); + + expect(listsService.createList).toHaveBeenCalledWith('user-1', { + name: 'Ideen', + description: undefined, + kind: undefined, + }); + expect(listsService.addItem).not.toHaveBeenCalled(); + expect(result.structuredContent).toEqual({ list: createdList }); + }); + + it('registers add_list_item as a write tool and adds an item', async () => { + const updatedList = list({ + id: 'list-1', + name: 'Einkauf', + items: ['Milch'], + }); + jest.mocked(listsService.addItem).mockResolvedValue(updatedList); + + const tool = toolFrom(service.createServer('user-1'), 'add_list_item'); + const result = await tool.handler( + { + listId: 'list-1', + title: 'Milch', + quantity: 2, + }, + {} as never, + ); + + expect(tool.annotations).toEqual( + expect.objectContaining({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }), + ); + expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', { + title: 'Milch', + notes: undefined, + quantity: 2, + required: undefined, + }); + expect(result.structuredContent).toEqual({ list: updatedList }); + }); +}); + +function toolFrom(server: object, name: string) { + const tools = (server as { _registeredTools: Record }) + ._registeredTools; + return tools[name] as { + annotations?: unknown; + handler: (args: Record, extra: never) => Promise<{ + structuredContent?: unknown; + }>; + }; +} + +function list(options: { + id: string; + name: string; + items?: string[]; +}): UserList { + return { + id: options.id, + ownerId: 'user-1', + accessRole: 'owner', + name: options.name, + kind: 'custom', + items: (options.items ?? []).map((title, position) => ({ + id: `item-${position}`, + title, + required: true, + checked: false, + position, + createdAt: now(), + updatedAt: now(), + })), + collaborators: [], + createdAt: now(), + updatedAt: now(), + }; +} + +function now(): string { + return new Date(0).toISOString(); +} diff --git a/listify-api/src/mcp/mcp-server.service.ts b/listify-api/src/mcp/mcp-server.service.ts index c02cadf..947066a 100644 --- a/listify-api/src/mcp/mcp-server.service.ts +++ b/listify-api/src/mcp/mcp-server.service.ts @@ -9,6 +9,15 @@ import { ListSuggestionAgentService } from './list-suggestion-agent.service'; const listKindSchema = z .enum(['packing', 'shopping', 'todo', 'custom']) .optional(); +const listItemInputSchema = { + title: z.string().trim().min(1).describe('List item title.'), + notes: z.string().trim().min(1).optional().describe('Optional item notes.'), + quantity: z.number().positive().optional().describe('Optional item quantity.'), + required: z + .boolean() + .optional() + .describe('Whether the item is required. Defaults to true.'), +}; @Injectable() export class McpServerService { @@ -144,6 +153,78 @@ export class McpServerService { }, ); + server.registerTool( + 'create_list', + { + title: 'Create list', + description: + 'Creates a new list for the authenticated user and optionally adds initial items.', + inputSchema: { + name: z.string().trim().min(1).describe('List name.'), + description: z + .string() + .trim() + .min(1) + .optional() + .describe('Optional list description.'), + kind: listKindSchema.describe('Optional list kind.'), + items: z + .array(z.object(listItemInputSchema)) + .max(50) + .optional() + .describe('Optional initial list items.'), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ name, description, kind, items = [] }) => { + let list = await this.listsService.createList(userId, { + name, + description, + kind: kind as ListTemplateKind | undefined, + }); + + for (const item of items) { + list = await this.listsService.addItem(userId, list.id, item); + } + + return this.toToolResult({ list }); + }, + ); + + server.registerTool( + 'add_list_item', + { + title: 'Add list item', + description: + 'Adds an item to an existing list the authenticated user can access.', + inputSchema: { + listId: z.string().trim().min(1).describe('Target list id.'), + ...listItemInputSchema, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ listId, title, notes, quantity, required }) => { + const list = await this.listsService.addItem(userId, listId, { + title, + notes, + quantity, + required, + }); + + return this.toToolResult({ list }); + }, + ); + return server; } diff --git a/readme.md b/readme.md index 87f502d..74fce44 100644 --- a/readme.md +++ b/readme.md @@ -22,5 +22,7 @@ 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. +- `create_list`: erstellt eine neue Liste mit optionalen Start-Items. +- `add_list_item`: fuegt ein Item zu einer bestehenden Liste hinzu. Weitere Details und Beispiel-Requests stehen in `listify-api/README.md`.