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`.
|
||||
- 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
|
||||
|
||||
|
||||
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
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user