This commit is contained in:
Bastian Wagner
2026-06-13 15:41:57 +02:00
parent 6642575ea9
commit 22c93f9ca1
10 changed files with 874 additions and 19 deletions

View File

@@ -1,12 +1,15 @@
import { ListTemplatesService } from '../list-templates/list-templates.service';
import { UserList } from '../list-templates/list-template.types';
import { ListTemplate, 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 listTemplatesService: Pick<
ListTemplatesService,
'listTemplates' | 'createTemplate' | 'addItem'
>;
let listSuggestionAgentService: Pick<
ListSuggestionAgentService,
'suggestLists'
@@ -21,6 +24,8 @@ describe('McpServerService', () => {
};
listTemplatesService = {
listTemplates: jest.fn(),
createTemplate: jest.fn(),
addItem: jest.fn(),
};
listSuggestionAgentService = {
suggestLists: jest.fn(),
@@ -142,6 +147,87 @@ describe('McpServerService', () => {
});
expect(result.structuredContent).toEqual({ list: updatedList });
});
it('registers create_template as a write tool and creates initial items', async () => {
const createdTemplate = template({
id: 'template-1',
name: 'Urlaub',
items: ['Pass', 'Tickets'],
});
jest
.mocked(listTemplatesService.createTemplate)
.mockResolvedValue(createdTemplate);
const tool = toolFrom(service.createServer('user-1'), 'create_template');
const result = await tool.handler(
{
name: 'Urlaub',
description: 'Packvorlage',
kind: 'packing',
items: [
{ title: 'Pass', required: true },
{ title: 'Tickets', notes: 'Digital sichern', required: false },
],
},
{} as never,
);
expect(tool.annotations).toEqual(
expect.objectContaining({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
}),
);
expect(listTemplatesService.createTemplate).toHaveBeenCalledWith('user-1', {
name: 'Urlaub',
description: 'Packvorlage',
kind: 'packing',
items: [
{ title: 'Pass', required: true },
{ title: 'Tickets', notes: 'Digital sichern', required: false },
],
});
expect(result.structuredContent).toEqual({ template: createdTemplate });
});
it('registers add_template_item as a write tool and adds an item', async () => {
const updatedTemplate = template({
id: 'template-1',
name: 'Urlaub',
items: ['Pass'],
});
jest.mocked(listTemplatesService.addItem).mockResolvedValue(updatedTemplate);
const tool = toolFrom(service.createServer('user-1'), 'add_template_item');
const result = await tool.handler(
{
templateId: 'template-1',
title: 'Pass',
quantity: 1,
},
{} as never,
);
expect(tool.annotations).toEqual(
expect.objectContaining({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
}),
);
expect(listTemplatesService.addItem).toHaveBeenCalledWith(
'user-1',
'template-1',
{
title: 'Pass',
notes: undefined,
quantity: 1,
required: undefined,
},
);
expect(result.structuredContent).toEqual({ template: updatedTemplate });
});
});
function toolFrom(server: object, name: string) {
@@ -181,6 +267,29 @@ function list(options: {
};
}
function template(options: {
id: string;
name: string;
items?: string[];
}): ListTemplate {
return {
id: options.id,
ownerId: 'user-1',
name: options.name,
kind: 'packing',
items: (options.items ?? []).map((title, position) => ({
id: `template-item-${position}`,
title,
required: true,
position,
createdAt: now(),
updatedAt: now(),
})),
createdAt: now(),
updatedAt: now(),
};
}
function now(): string {
return new Date(0).toISOString();
}

View File

@@ -18,6 +18,24 @@ const listItemInputSchema = {
.optional()
.describe('Whether the item is required. Defaults to true.'),
};
const templateItemInputSchema = {
title: z.string().trim().min(1).describe('Template item title.'),
notes: z
.string()
.trim()
.min(1)
.optional()
.describe('Optional template item notes.'),
quantity: z
.number()
.positive()
.optional()
.describe('Optional template item quantity.'),
required: z
.boolean()
.optional()
.describe('Whether the template item is required. Defaults to true.'),
};
@Injectable()
export class McpServerService {
@@ -225,6 +243,79 @@ export class McpServerService {
},
);
server.registerTool(
'create_template',
{
title: 'Create template',
description:
'Creates a new list template for the authenticated user and optionally adds template items.',
inputSchema: {
name: z.string().trim().min(1).describe('Template name.'),
description: z
.string()
.trim()
.min(1)
.optional()
.describe('Optional template description.'),
kind: listKindSchema.describe('Optional template kind.'),
items: z
.array(z.object(templateItemInputSchema))
.max(50)
.optional()
.describe('Optional initial template items.'),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async ({ name, description, kind, items = [] }) => {
const template = await this.listTemplatesService.createTemplate(userId, {
name,
description,
kind: kind as ListTemplateKind | undefined,
items,
});
return this.toToolResult({ template });
},
);
server.registerTool(
'add_template_item',
{
title: 'Add template item',
description:
'Adds an item to an existing list template owned by the authenticated user.',
inputSchema: {
templateId: z.string().trim().min(1).describe('Target template id.'),
...templateItemInputSchema,
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async ({ templateId, title, notes, quantity, required }) => {
const template = await this.listTemplatesService.addItem(
userId,
templateId,
{
title,
notes,
quantity,
required,
},
);
return this.toToolResult({ template });
},
);
return server;
}