This commit is contained in:
Bastian Wagner
2026-06-11 17:45:40 +02:00
parent 3a1d4ba2e3
commit 4dec991c4a
4 changed files with 304 additions and 1 deletions

View File

@@ -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`. - Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`.
- Schreibt keine Daten und legt keine Liste an. - 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 ### 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: 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 ## Deployment

View File

@@ -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<ListsService, 'listLists' | 'createList' | 'addItem'>;
let listTemplatesService: Pick<ListTemplatesService, 'listTemplates'>;
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<string, unknown> })
._registeredTools;
return tools[name] as {
annotations?: unknown;
handler: (args: Record<string, unknown>, 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();
}

View File

@@ -9,6 +9,15 @@ 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 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() @Injectable()
export class McpServerService { 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; return server;
} }

View File

@@ -22,5 +22,7 @@ Verfuegbare MCP-Tools:
- `list_existing_lists`: liest die Listen des angemeldeten Users. - `list_existing_lists`: liest die Listen des angemeldeten Users.
- `list_templates`: liest die Listenvorlagen 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. - `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`. Weitere Details und Beispiel-Requests stehen in `listify-api/README.md`.