mcp
This commit is contained in:
@@ -99,7 +99,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
|
||||
|
||||
- `list_existing_lists`
|
||||
- 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.
|
||||
|
||||
- `list_templates`
|
||||
|
||||
@@ -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 () => {
|
||||
const createdList = list({ id: 'list-1', name: 'Sommerurlaub' });
|
||||
const withFirstItem = list({
|
||||
@@ -293,7 +427,7 @@ function toolFrom(server: object, name: string) {
|
||||
function list(options: {
|
||||
id: string;
|
||||
name: string;
|
||||
items?: string[];
|
||||
items?: Array<string | { title: string; checked?: boolean }>;
|
||||
}): UserList {
|
||||
return {
|
||||
id: options.id,
|
||||
@@ -301,11 +435,11 @@ function list(options: {
|
||||
accessRole: 'owner',
|
||||
name: options.name,
|
||||
kind: 'custom',
|
||||
items: (options.items ?? []).map((title, position) => ({
|
||||
items: (options.items ?? []).map((item, position) => ({
|
||||
id: `item-${position}`,
|
||||
title,
|
||||
title: typeof item === 'string' ? item : item.title,
|
||||
required: true,
|
||||
checked: false,
|
||||
checked: typeof item === 'string' ? false : item.checked === true,
|
||||
position,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
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 { ListsService } from '../lists/lists.service';
|
||||
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
||||
@@ -9,6 +12,8 @@ import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
||||
const listKindSchema = z
|
||||
.enum(['packing', 'shopping', 'todo', 'custom'])
|
||||
.optional();
|
||||
const listStatusSchema = z.enum(['open', 'completed', 'all']).optional();
|
||||
type ListStatusFilter = 'open' | 'completed' | 'all';
|
||||
type ToolInputSchema = Record<string, z.ZodType>;
|
||||
const userIdInputSchema = {
|
||||
userId: z
|
||||
@@ -68,8 +73,11 @@ export class McpServerService {
|
||||
{
|
||||
title: 'List existing lists',
|
||||
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, {
|
||||
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
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -81,29 +89,37 @@ export class McpServerService {
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ userId: inputUserId, includeItems = false }) => {
|
||||
async ({
|
||||
userId: inputUserId,
|
||||
status = 'open',
|
||||
includeItems = false,
|
||||
}) => {
|
||||
const userId = this.resolveUserId(boundUserId, inputUserId);
|
||||
const lists = await this.listsService.listLists(userId);
|
||||
const result = {
|
||||
lists: lists.map((list) => ({
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
description: list.description,
|
||||
kind: list.kind,
|
||||
accessRole: list.accessRole,
|
||||
itemCount: list.items.length,
|
||||
items: includeItems
|
||||
? list.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
notes: item.notes,
|
||||
quantity: item.quantity,
|
||||
required: item.required,
|
||||
checked: item.checked,
|
||||
position: item.position,
|
||||
}))
|
||||
: undefined,
|
||||
})),
|
||||
lists: lists
|
||||
.filter((list) =>
|
||||
this.matchesListStatus(list, status as ListStatusFilter),
|
||||
)
|
||||
.map((list) => ({
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
description: list.description,
|
||||
kind: list.kind,
|
||||
accessRole: list.accessRole,
|
||||
itemCount: list.items.length,
|
||||
items: includeItems
|
||||
? list.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
notes: item.notes,
|
||||
quantity: item.quantity,
|
||||
required: item.required,
|
||||
checked: item.checked,
|
||||
position: item.position,
|
||||
}))
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
return this.toToolResult(result);
|
||||
@@ -377,6 +393,17 @@ export class McpServerService {
|
||||
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) {
|
||||
return {
|
||||
content: [
|
||||
|
||||
Reference in New Issue
Block a user