Files
listify/listify-api/src/mcp/mcp-server.service.ts
Bastian Wagner 01f2aff0be mcp
2026-06-24 13:17:48 +02:00

392 lines
11 KiB
TypeScript

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 { ListTemplatesService } from '../list-templates/list-templates.service';
import { ListsService } from '../lists/lists.service';
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
const listKindSchema = z
.enum(['packing', 'shopping', 'todo', 'custom'])
.optional();
type ToolInputSchema = Record<string, z.ZodType>;
const userIdInputSchema = {
userId: z
.string()
.trim()
.min(1)
.describe('Authenticated Listify user id for this tool call.'),
};
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.'),
};
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 {
constructor(
private readonly listsService: ListsService,
private readonly listTemplatesService: ListTemplatesService,
private readonly listSuggestionAgentService: ListSuggestionAgentService,
) {}
createServer(boundUserId?: string): McpServer {
const server = new McpServer({
name: 'listify',
version: '1.0.0',
});
server.registerTool(
'list_existing_lists',
{
title: 'List existing lists',
description:
'Returns the authenticated user lists. This tool is read-only.',
inputSchema: this.withUserIdInput(boundUserId, {
includeItems: z
.boolean()
.optional()
.describe('Whether to include list items in the response.'),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ userId: inputUserId, 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,
})),
};
return this.toToolResult(result);
},
);
server.registerTool(
'list_templates',
{
title: 'List templates',
description:
'Returns the authenticated user list templates. This tool is read-only.',
inputSchema: this.withUserIdInput(boundUserId, {
kind: listKindSchema.describe('Optional template kind filter.'),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ userId: inputUserId, kind }) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
const templates = await this.listTemplatesService.listTemplates(userId);
const result = {
templates: templates
.filter((template) => !kind || template.kind === kind)
.map((template) => ({
id: template.id,
name: template.name,
description: template.description,
kind: template.kind,
items: template.items.map((item) => ({
id: item.id,
title: item.title,
notes: item.notes,
quantity: item.quantity,
required: item.required,
position: item.position,
})),
})),
};
return this.toToolResult(result);
},
);
server.registerTool(
'suggest_lists',
{
title: 'Suggest lists',
description:
'Suggests new lists for the authenticated user without creating or modifying data.',
inputSchema: this.withUserIdInput(boundUserId, {
goal: z.string().min(1).describe('What the user wants a list for.'),
kind: listKindSchema.describe('Optional desired list kind.'),
constraints: z
.array(z.string().min(1))
.optional()
.describe('Optional constraints or must-have list items.'),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ userId: inputUserId, goal, kind, constraints }) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
const result = await this.listSuggestionAgentService.suggestLists(
userId,
{
goal,
kind: kind as ListTemplateKind | undefined,
constraints,
},
);
return this.toToolResult(result);
},
);
server.registerTool(
'create_list',
{
title: 'Create list',
description:
'Creates a new list for the authenticated user and optionally adds initial items.',
inputSchema: this.withUserIdInput(boundUserId, {
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 ({ userId: inputUserId, name, description, kind, items = [] }) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
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: this.withUserIdInput(boundUserId, {
listId: z.string().trim().min(1).describe('Target list id.'),
...listItemInputSchema,
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async ({
userId: inputUserId,
listId,
title,
notes,
quantity,
required,
}) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
const list = await this.listsService.addItem(userId, listId, {
title,
notes,
quantity,
required,
});
return this.toToolResult({ list });
},
);
server.registerTool(
'create_template',
{
title: 'Create template',
description:
'Creates a new list template for the authenticated user and optionally adds template items.',
inputSchema: this.withUserIdInput(boundUserId, {
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 ({ userId: inputUserId, name, description, kind, items = [] }) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
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: this.withUserIdInput(boundUserId, {
templateId: z.string().trim().min(1).describe('Target template id.'),
...templateItemInputSchema,
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async ({
userId: inputUserId,
templateId,
title,
notes,
quantity,
required,
}) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
const template = await this.listTemplatesService.addItem(
userId,
templateId,
{
title,
notes,
quantity,
required,
},
);
return this.toToolResult({ template });
},
);
return server;
}
private withUserIdInput<T extends ToolInputSchema>(
boundUserId: string | undefined,
inputSchema: T,
): T & typeof userIdInputSchema {
return (
boundUserId ? inputSchema : { ...userIdInputSchema, ...inputSchema }
) as T & typeof userIdInputSchema;
}
private resolveUserId(
boundUserId: string | undefined,
inputUserId: string | undefined,
): string {
const userId = boundUserId ?? inputUserId?.trim();
if (!userId) {
throw new Error('Listify userId is required.');
}
return userId;
}
private toToolResult(data: object) {
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(data, null, 2),
},
],
structuredContent: data as Record<string, unknown>,
};
}
}