This commit is contained in:
Bastian Wagner
2026-06-25 11:15:52 +02:00
parent 0abd2eb45c
commit 9078da9f66
3 changed files with 189 additions and 27 deletions

View File

@@ -99,7 +99,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
- `list_existing_lists` - `list_existing_lists`
- Liest die Listen des angemeldeten Users. - 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. - Schreibt keine Daten.
- `list_templates` - `list_templates`

View File

@@ -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 () => { it('registers create_list as a write tool and creates initial items in order', async () => {
const createdList = list({ id: 'list-1', name: 'Sommerurlaub' }); const createdList = list({ id: 'list-1', name: 'Sommerurlaub' });
const withFirstItem = list({ const withFirstItem = list({
@@ -293,7 +427,7 @@ function toolFrom(server: object, name: string) {
function list(options: { function list(options: {
id: string; id: string;
name: string; name: string;
items?: string[]; items?: Array<string | { title: string; checked?: boolean }>;
}): UserList { }): UserList {
return { return {
id: options.id, id: options.id,
@@ -301,11 +435,11 @@ function list(options: {
accessRole: 'owner', accessRole: 'owner',
name: options.name, name: options.name,
kind: 'custom', kind: 'custom',
items: (options.items ?? []).map((title, position) => ({ items: (options.items ?? []).map((item, position) => ({
id: `item-${position}`, id: `item-${position}`,
title, title: typeof item === 'string' ? item : item.title,
required: true, required: true,
checked: false, checked: typeof item === 'string' ? false : item.checked === true,
position, position,
createdAt: now(), createdAt: now(),
updatedAt: now(), updatedAt: now(),

View File

@@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4'; 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 { ListTemplatesService } from '../list-templates/list-templates.service';
import { ListsService } from '../lists/lists.service'; import { ListsService } from '../lists/lists.service';
import { ListSuggestionAgentService } from './list-suggestion-agent.service'; import { ListSuggestionAgentService } from './list-suggestion-agent.service';
@@ -9,6 +12,8 @@ import { ListSuggestionAgentService } from './list-suggestion-agent.service';
const listKindSchema = z const listKindSchema = z
.enum(['packing', 'shopping', 'todo', 'custom']) .enum(['packing', 'shopping', 'todo', 'custom'])
.optional(); .optional();
const listStatusSchema = z.enum(['open', 'completed', 'all']).optional();
type ListStatusFilter = 'open' | 'completed' | 'all';
type ToolInputSchema = Record<string, z.ZodType>; type ToolInputSchema = Record<string, z.ZodType>;
const userIdInputSchema = { const userIdInputSchema = {
userId: z userId: z
@@ -68,8 +73,11 @@ export class McpServerService {
{ {
title: 'List existing lists', title: 'List existing lists',
description: 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, { 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 includeItems: z
.boolean() .boolean()
.optional() .optional()
@@ -81,29 +89,37 @@ export class McpServerService {
openWorldHint: false, openWorldHint: false,
}, },
}, },
async ({ userId: inputUserId, includeItems = false }) => { async ({
userId: inputUserId,
status = 'open',
includeItems = false,
}) => {
const userId = this.resolveUserId(boundUserId, inputUserId); const userId = this.resolveUserId(boundUserId, inputUserId);
const lists = await this.listsService.listLists(userId); const lists = await this.listsService.listLists(userId);
const result = { const result = {
lists: lists.map((list) => ({ lists: lists
id: list.id, .filter((list) =>
name: list.name, this.matchesListStatus(list, status as ListStatusFilter),
description: list.description, )
kind: list.kind, .map((list) => ({
accessRole: list.accessRole, id: list.id,
itemCount: list.items.length, name: list.name,
items: includeItems description: list.description,
? list.items.map((item) => ({ kind: list.kind,
id: item.id, accessRole: list.accessRole,
title: item.title, itemCount: list.items.length,
notes: item.notes, items: includeItems
quantity: item.quantity, ? list.items.map((item) => ({
required: item.required, id: item.id,
checked: item.checked, title: item.title,
position: item.position, notes: item.notes,
})) quantity: item.quantity,
: undefined, required: item.required,
})), checked: item.checked,
position: item.position,
}))
: undefined,
})),
}; };
return this.toToolResult(result); return this.toToolResult(result);
@@ -377,6 +393,17 @@ export class McpServerService {
return userId; 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) { private toToolResult(data: object) {
return { return {
content: [ content: [