This commit is contained in:
Bastian Wagner
2026-06-24 13:17:48 +02:00
parent 17ac2953d6
commit 01f2aff0be
16 changed files with 212 additions and 508 deletions

View File

@@ -9,10 +9,22 @@ 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.'),
quantity: z
.number()
.positive()
.optional()
.describe('Optional item quantity.'),
required: z
.boolean()
.optional()
@@ -45,7 +57,7 @@ export class McpServerService {
private readonly listSuggestionAgentService: ListSuggestionAgentService,
) {}
createServer(userId: string): McpServer {
createServer(boundUserId?: string): McpServer {
const server = new McpServer({
name: 'listify',
version: '1.0.0',
@@ -57,19 +69,20 @@ export class McpServerService {
title: 'List existing lists',
description:
'Returns the authenticated user lists. This tool is read-only.',
inputSchema: {
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 ({ includeItems = 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) => ({
@@ -103,16 +116,17 @@ export class McpServerService {
title: 'List templates',
description:
'Returns the authenticated user list templates. This tool is read-only.',
inputSchema: {
inputSchema: this.withUserIdInput(boundUserId, {
kind: listKindSchema.describe('Optional template kind filter.'),
},
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ kind }) => {
async ({ userId: inputUserId, kind }) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
const templates = await this.listTemplatesService.listTemplates(userId);
const result = {
templates: templates
@@ -143,21 +157,22 @@ export class McpServerService {
title: 'Suggest lists',
description:
'Suggests new lists for the authenticated user without creating or modifying data.',
inputSchema: {
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 ({ goal, kind, constraints }) => {
async ({ userId: inputUserId, goal, kind, constraints }) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
const result = await this.listSuggestionAgentService.suggestLists(
userId,
{
@@ -177,7 +192,7 @@ export class McpServerService {
title: 'Create list',
description:
'Creates a new list for the authenticated user and optionally adds initial items.',
inputSchema: {
inputSchema: this.withUserIdInput(boundUserId, {
name: z.string().trim().min(1).describe('List name.'),
description: z
.string()
@@ -191,7 +206,7 @@ export class McpServerService {
.max(50)
.optional()
.describe('Optional initial list items.'),
},
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
@@ -199,7 +214,8 @@ export class McpServerService {
openWorldHint: false,
},
},
async ({ name, description, kind, items = [] }) => {
async ({ userId: inputUserId, name, description, kind, items = [] }) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
let list = await this.listsService.createList(userId, {
name,
description,
@@ -220,10 +236,10 @@ export class McpServerService {
title: 'Add list item',
description:
'Adds an item to an existing list the authenticated user can access.',
inputSchema: {
inputSchema: this.withUserIdInput(boundUserId, {
listId: z.string().trim().min(1).describe('Target list id.'),
...listItemInputSchema,
},
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
@@ -231,7 +247,15 @@ export class McpServerService {
openWorldHint: false,
},
},
async ({ listId, title, notes, quantity, required }) => {
async ({
userId: inputUserId,
listId,
title,
notes,
quantity,
required,
}) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
const list = await this.listsService.addItem(userId, listId, {
title,
notes,
@@ -249,7 +273,7 @@ export class McpServerService {
title: 'Create template',
description:
'Creates a new list template for the authenticated user and optionally adds template items.',
inputSchema: {
inputSchema: this.withUserIdInput(boundUserId, {
name: z.string().trim().min(1).describe('Template name.'),
description: z
.string()
@@ -263,7 +287,7 @@ export class McpServerService {
.max(50)
.optional()
.describe('Optional initial template items.'),
},
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
@@ -271,13 +295,17 @@ export class McpServerService {
openWorldHint: false,
},
},
async ({ name, description, kind, items = [] }) => {
const template = await this.listTemplatesService.createTemplate(userId, {
name,
description,
kind: kind as ListTemplateKind | undefined,
items,
});
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 });
},
@@ -289,10 +317,10 @@ export class McpServerService {
title: 'Add template item',
description:
'Adds an item to an existing list template owned by the authenticated user.',
inputSchema: {
inputSchema: this.withUserIdInput(boundUserId, {
templateId: z.string().trim().min(1).describe('Target template id.'),
...templateItemInputSchema,
},
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
@@ -300,7 +328,15 @@ export class McpServerService {
openWorldHint: false,
},
},
async ({ templateId, title, notes, quantity, required }) => {
async ({
userId: inputUserId,
templateId,
title,
notes,
quantity,
required,
}) => {
const userId = this.resolveUserId(boundUserId, inputUserId);
const template = await this.listTemplatesService.addItem(
userId,
templateId,
@@ -319,6 +355,28 @@ export class McpServerService {
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: [