mcp
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
186
listify-api/src/mcp/mcp-server.service.spec.ts
Normal file
186
listify-api/src/mcp/mcp-server.service.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user