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`
|
- `list_existing_lists`
|
||||||
- Liest die Listen des angemeldeten Users.
|
- 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.
|
- Schreibt keine Daten.
|
||||||
|
|
||||||
- `list_templates`
|
- `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 () => {
|
it('registers create_list as a write tool and creates initial items in order', async () => {
|
||||||
const createdList = list({ id: 'list-1', name: 'Sommerurlaub' });
|
const createdList = list({ id: 'list-1', name: 'Sommerurlaub' });
|
||||||
const withFirstItem = list({
|
const withFirstItem = list({
|
||||||
@@ -293,7 +427,7 @@ function toolFrom(server: object, name: string) {
|
|||||||
function list(options: {
|
function list(options: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
items?: string[];
|
items?: Array<string | { title: string; checked?: boolean }>;
|
||||||
}): UserList {
|
}): UserList {
|
||||||
return {
|
return {
|
||||||
id: options.id,
|
id: options.id,
|
||||||
@@ -301,11 +435,11 @@ function list(options: {
|
|||||||
accessRole: 'owner',
|
accessRole: 'owner',
|
||||||
name: options.name,
|
name: options.name,
|
||||||
kind: 'custom',
|
kind: 'custom',
|
||||||
items: (options.items ?? []).map((title, position) => ({
|
items: (options.items ?? []).map((item, position) => ({
|
||||||
id: `item-${position}`,
|
id: `item-${position}`,
|
||||||
title,
|
title: typeof item === 'string' ? item : item.title,
|
||||||
required: true,
|
required: true,
|
||||||
checked: false,
|
checked: typeof item === 'string' ? false : item.checked === true,
|
||||||
position,
|
position,
|
||||||
createdAt: now(),
|
createdAt: now(),
|
||||||
updatedAt: now(),
|
updatedAt: now(),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import * as z from 'zod/v4';
|
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 { ListTemplatesService } from '../list-templates/list-templates.service';
|
||||||
import { ListsService } from '../lists/lists.service';
|
import { ListsService } from '../lists/lists.service';
|
||||||
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
||||||
@@ -9,6 +12,8 @@ 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 listStatusSchema = z.enum(['open', 'completed', 'all']).optional();
|
||||||
|
type ListStatusFilter = 'open' | 'completed' | 'all';
|
||||||
type ToolInputSchema = Record<string, z.ZodType>;
|
type ToolInputSchema = Record<string, z.ZodType>;
|
||||||
const userIdInputSchema = {
|
const userIdInputSchema = {
|
||||||
userId: z
|
userId: z
|
||||||
@@ -68,8 +73,11 @@ export class McpServerService {
|
|||||||
{
|
{
|
||||||
title: 'List existing lists',
|
title: 'List existing lists',
|
||||||
description:
|
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, {
|
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
|
includeItems: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -81,29 +89,37 @@ export class McpServerService {
|
|||||||
openWorldHint: false,
|
openWorldHint: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ userId: inputUserId, includeItems = false }) => {
|
async ({
|
||||||
|
userId: inputUserId,
|
||||||
|
status = 'open',
|
||||||
|
includeItems = false,
|
||||||
|
}) => {
|
||||||
const userId = this.resolveUserId(boundUserId, inputUserId);
|
const userId = this.resolveUserId(boundUserId, inputUserId);
|
||||||
const lists = await this.listsService.listLists(userId);
|
const lists = await this.listsService.listLists(userId);
|
||||||
const result = {
|
const result = {
|
||||||
lists: lists.map((list) => ({
|
lists: lists
|
||||||
id: list.id,
|
.filter((list) =>
|
||||||
name: list.name,
|
this.matchesListStatus(list, status as ListStatusFilter),
|
||||||
description: list.description,
|
)
|
||||||
kind: list.kind,
|
.map((list) => ({
|
||||||
accessRole: list.accessRole,
|
id: list.id,
|
||||||
itemCount: list.items.length,
|
name: list.name,
|
||||||
items: includeItems
|
description: list.description,
|
||||||
? list.items.map((item) => ({
|
kind: list.kind,
|
||||||
id: item.id,
|
accessRole: list.accessRole,
|
||||||
title: item.title,
|
itemCount: list.items.length,
|
||||||
notes: item.notes,
|
items: includeItems
|
||||||
quantity: item.quantity,
|
? list.items.map((item) => ({
|
||||||
required: item.required,
|
id: item.id,
|
||||||
checked: item.checked,
|
title: item.title,
|
||||||
position: item.position,
|
notes: item.notes,
|
||||||
}))
|
quantity: item.quantity,
|
||||||
: undefined,
|
required: item.required,
|
||||||
})),
|
checked: item.checked,
|
||||||
|
position: item.position,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.toToolResult(result);
|
return this.toToolResult(result);
|
||||||
@@ -377,6 +393,17 @@ export class McpServerService {
|
|||||||
return userId;
|
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) {
|
private toToolResult(data: object) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
Reference in New Issue
Block a user