334 lines
9.6 KiB
TypeScript
334 lines
9.6 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();
|
|
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(userId: 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: {
|
|
includeItems: z
|
|
.boolean()
|
|
.optional()
|
|
.describe('Whether to include list items in the response.'),
|
|
},
|
|
annotations: {
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
openWorldHint: false,
|
|
},
|
|
},
|
|
async ({ includeItems = false }) => {
|
|
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: {
|
|
kind: listKindSchema.describe('Optional template kind filter.'),
|
|
},
|
|
annotations: {
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
openWorldHint: false,
|
|
},
|
|
},
|
|
async ({ kind }) => {
|
|
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: {
|
|
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 ({ goal, kind, constraints }) => {
|
|
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: {
|
|
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 });
|
|
},
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
private toToolResult(data: object) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: JSON.stringify(data, null, 2),
|
|
},
|
|
],
|
|
structuredContent: data as Record<string, unknown>,
|
|
};
|
|
}
|
|
}
|