From 9078da9f663a5f99db5ae0d5492d889d8afc1c10 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 25 Jun 2026 11:15:52 +0200 Subject: [PATCH] mcp --- listify-api/README.md | 3 +- .../src/mcp/mcp-server.service.spec.ts | 142 +++++++++++++++++- listify-api/src/mcp/mcp-server.service.ts | 71 ++++++--- 3 files changed, 189 insertions(+), 27 deletions(-) diff --git a/listify-api/README.md b/listify-api/README.md index af2f997..dc52fa5 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -99,7 +99,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den - `list_existing_lists` - Liest die Listen des angemeldeten Users. - - Input: `{ "includeItems": true | false }` + - Input: optional `{ "status": "open" | "completed" | "all", "includeItems": true | false }` + - Ohne `status` werden nur offene Listen geliefert. Erledigte/geschlossene Listen werden nur mit `"status": "completed"` oder explizit alle Listen mit `"status": "all"` geliefert. - Schreibt keine Daten. - `list_templates` diff --git a/listify-api/src/mcp/mcp-server.service.spec.ts b/listify-api/src/mcp/mcp-server.service.spec.ts index ab6cda2..13b800f 100644 --- a/listify-api/src/mcp/mcp-server.service.spec.ts +++ b/listify-api/src/mcp/mcp-server.service.spec.ts @@ -37,6 +37,140 @@ describe('McpServerService', () => { ); }); + it('returns only open lists by default', async () => { + const openList = list({ + id: 'list-open', + name: 'Offen', + items: [{ title: 'Milch', checked: false }], + }); + const emptyList = list({ id: 'list-empty', name: 'Leer' }); + const completedList = list({ + id: 'list-completed', + name: 'Erledigt', + items: [{ title: 'Pass', checked: true }], + }); + jest + .mocked(listsService.listLists) + .mockResolvedValue([completedList, emptyList, openList]); + + const tool = toolFrom( + service.createServer('user-1'), + 'list_existing_lists', + ); + const result = await tool.handler({}, {} as never); + + expect(listsService.listLists).toHaveBeenCalledWith('user-1'); + expect(result.structuredContent).toEqual({ + lists: [ + { + id: 'list-empty', + name: 'Leer', + description: undefined, + kind: 'custom', + accessRole: 'owner', + itemCount: 0, + items: undefined, + }, + { + id: 'list-open', + name: 'Offen', + description: undefined, + kind: 'custom', + accessRole: 'owner', + itemCount: 1, + items: undefined, + }, + ], + }); + }); + + it('returns completed lists when explicitly requested', async () => { + const openList = list({ + id: 'list-open', + name: 'Offen', + items: [{ title: 'Milch', checked: false }], + }); + const completedList = list({ + id: 'list-completed', + name: 'Erledigt', + items: [ + { title: 'Pass', checked: true }, + { title: 'Tickets', checked: true }, + ], + }); + jest + .mocked(listsService.listLists) + .mockResolvedValue([completedList, openList]); + + const tool = toolFrom( + service.createServer('user-1'), + 'list_existing_lists', + ); + const result = await tool.handler( + { status: 'completed', includeItems: true }, + {} as never, + ); + + expect(result.structuredContent).toEqual({ + lists: [ + { + id: 'list-completed', + name: 'Erledigt', + description: undefined, + kind: 'custom', + accessRole: 'owner', + itemCount: 2, + items: [ + { + id: 'item-0', + title: 'Pass', + notes: undefined, + quantity: undefined, + required: true, + checked: true, + position: 0, + }, + { + id: 'item-1', + title: 'Tickets', + notes: undefined, + quantity: undefined, + required: true, + checked: true, + position: 1, + }, + ], + }, + ], + }); + }); + + it('returns all lists only when all is explicitly requested', async () => { + const openList = list({ + id: 'list-open', + name: 'Offen', + items: [{ title: 'Milch', checked: false }], + }); + const completedList = list({ + id: 'list-completed', + name: 'Erledigt', + items: [{ title: 'Pass', checked: true }], + }); + jest + .mocked(listsService.listLists) + .mockResolvedValue([completedList, openList]); + + const tool = toolFrom( + service.createServer('user-1'), + 'list_existing_lists', + ); + const result = await tool.handler({ status: 'all' }, {} as never); + + expect( + (result.structuredContent as { lists: unknown[] }).lists, + ).toHaveLength(2); + }); + 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({ @@ -293,7 +427,7 @@ function toolFrom(server: object, name: string) { function list(options: { id: string; name: string; - items?: string[]; + items?: Array; }): UserList { return { id: options.id, @@ -301,11 +435,11 @@ function list(options: { accessRole: 'owner', name: options.name, kind: 'custom', - items: (options.items ?? []).map((title, position) => ({ + items: (options.items ?? []).map((item, position) => ({ id: `item-${position}`, - title, + title: typeof item === 'string' ? item : item.title, required: true, - checked: false, + checked: typeof item === 'string' ? false : item.checked === true, position, createdAt: now(), updatedAt: now(), diff --git a/listify-api/src/mcp/mcp-server.service.ts b/listify-api/src/mcp/mcp-server.service.ts index afff5bc..b0d69b9 100644 --- a/listify-api/src/mcp/mcp-server.service.ts +++ b/listify-api/src/mcp/mcp-server.service.ts @@ -1,7 +1,10 @@ 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 { + ListTemplateKind, + 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'; @@ -9,6 +12,8 @@ import { ListSuggestionAgentService } from './list-suggestion-agent.service'; const listKindSchema = z .enum(['packing', 'shopping', 'todo', 'custom']) .optional(); +const listStatusSchema = z.enum(['open', 'completed', 'all']).optional(); +type ListStatusFilter = 'open' | 'completed' | 'all'; type ToolInputSchema = Record; const userIdInputSchema = { userId: z @@ -68,8 +73,11 @@ export class McpServerService { { title: 'List existing lists', description: - 'Returns the authenticated user lists. This tool is read-only.', + 'Returns authenticated user lists. Defaults to open lists only. Use completed only when the user explicitly asks for completed or closed lists. This tool is read-only.', inputSchema: this.withUserIdInput(boundUserId, { + status: listStatusSchema.describe( + 'Optional list status filter. Defaults to open. Use completed for explicitly requested completed/closed lists, all only when all lists including completed lists are explicitly requested.', + ), includeItems: z .boolean() .optional() @@ -81,29 +89,37 @@ export class McpServerService { openWorldHint: false, }, }, - async ({ userId: inputUserId, includeItems = false }) => { + async ({ + userId: inputUserId, + status = 'open', + includeItems = false, + }) => { const userId = this.resolveUserId(boundUserId, inputUserId); 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, - })), + lists: lists + .filter((list) => + this.matchesListStatus(list, status as ListStatusFilter), + ) + .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); @@ -377,6 +393,17 @@ export class McpServerService { return userId; } + private matchesListStatus(list: UserList, status: ListStatusFilter): boolean { + if (status === 'all') { + return true; + } + + const completed = + list.items.length > 0 && list.items.every((item) => item.checked); + + return status === 'completed' ? completed : !completed; + } + private toToolResult(data: object) { return { content: [