mcp
This commit is contained in:
@@ -81,15 +81,25 @@ describe('McpServerService', () => {
|
||||
description: 'Packliste',
|
||||
kind: 'packing',
|
||||
});
|
||||
expect(listsService.addItem).toHaveBeenNthCalledWith(1, 'user-1', 'list-1', {
|
||||
title: 'Pass',
|
||||
required: true,
|
||||
});
|
||||
expect(listsService.addItem).toHaveBeenNthCalledWith(2, 'user-1', 'list-1', {
|
||||
title: 'Tickets',
|
||||
notes: 'Ausdrucken',
|
||||
required: false,
|
||||
});
|
||||
expect(listsService.addItem).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'user-1',
|
||||
'list-1',
|
||||
{
|
||||
title: 'Pass',
|
||||
required: true,
|
||||
},
|
||||
);
|
||||
expect(listsService.addItem).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'user-1',
|
||||
'list-1',
|
||||
{
|
||||
title: 'Tickets',
|
||||
notes: 'Ausdrucken',
|
||||
required: false,
|
||||
},
|
||||
);
|
||||
expect(result.structuredContent).toEqual({ list: withSecondItem });
|
||||
});
|
||||
|
||||
@@ -114,6 +124,40 @@ describe('McpServerService', () => {
|
||||
expect(result.structuredContent).toEqual({ list: createdList });
|
||||
});
|
||||
|
||||
it('uses the tool userId when the MCP session is not bound to a user', async () => {
|
||||
const createdList = list({ id: 'list-1', name: 'Dynamisch' });
|
||||
jest.mocked(listsService.createList).mockResolvedValue(createdList);
|
||||
|
||||
const tool = toolFrom(service.createServer(), 'create_list');
|
||||
const result = await tool.handler(
|
||||
{
|
||||
userId: 'user-2',
|
||||
name: 'Dynamisch',
|
||||
},
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(listsService.createList).toHaveBeenCalledWith('user-2', {
|
||||
name: 'Dynamisch',
|
||||
description: undefined,
|
||||
kind: undefined,
|
||||
});
|
||||
expect(result.structuredContent).toEqual({ list: createdList });
|
||||
});
|
||||
|
||||
it('requires a tool userId when the MCP session is not bound to a user', async () => {
|
||||
const tool = toolFrom(service.createServer(), 'create_list');
|
||||
|
||||
await expect(
|
||||
tool.handler(
|
||||
{
|
||||
name: 'Dynamisch',
|
||||
},
|
||||
{} as never,
|
||||
),
|
||||
).rejects.toThrow('Listify userId is required.');
|
||||
});
|
||||
|
||||
it('registers add_list_item as a write tool and adds an item', async () => {
|
||||
const updatedList = list({
|
||||
id: 'list-1',
|
||||
@@ -197,7 +241,9 @@ describe('McpServerService', () => {
|
||||
name: 'Urlaub',
|
||||
items: ['Pass'],
|
||||
});
|
||||
jest.mocked(listTemplatesService.addItem).mockResolvedValue(updatedTemplate);
|
||||
jest
|
||||
.mocked(listTemplatesService.addItem)
|
||||
.mockResolvedValue(updatedTemplate);
|
||||
|
||||
const tool = toolFrom(service.createServer('user-1'), 'add_template_item');
|
||||
const result = await tool.handler(
|
||||
@@ -235,7 +281,10 @@ function toolFrom(server: object, name: string) {
|
||||
._registeredTools;
|
||||
return tools[name] as {
|
||||
annotations?: unknown;
|
||||
handler: (args: Record<string, unknown>, extra: never) => Promise<{
|
||||
handler: (
|
||||
args: Record<string, unknown>,
|
||||
extra: never,
|
||||
) => Promise<{
|
||||
structuredContent?: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
@@ -22,7 +21,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
interface McpSession {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
userId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
@Controller('mcp')
|
||||
@@ -37,7 +36,7 @@ export class McpController {
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const userId = this.requireUserId(request);
|
||||
const userId = this.userIdFrom(request);
|
||||
const sessionId = this.sessionIdFrom(request);
|
||||
|
||||
if (sessionId) {
|
||||
@@ -75,7 +74,7 @@ export class McpController {
|
||||
}
|
||||
|
||||
private async handleInitialize(
|
||||
userId: string,
|
||||
userId: string | undefined,
|
||||
request: AuthenticatedRequest,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
@@ -120,7 +119,7 @@ export class McpController {
|
||||
request: AuthenticatedRequest,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
const userId = this.requireUserId(request);
|
||||
const userId = this.userIdFrom(request);
|
||||
const sessionId = this.sessionIdFrom(request);
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -133,7 +132,7 @@ export class McpController {
|
||||
|
||||
private async handleExistingSession(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
userId: string | undefined,
|
||||
request: AuthenticatedRequest,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
@@ -144,10 +143,15 @@ export class McpController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.userId !== userId) {
|
||||
if (session.userId && userId && session.userId !== userId) {
|
||||
throw new ForbiddenException('MCP session belongs to another user.');
|
||||
}
|
||||
|
||||
if (session.userId && !userId) {
|
||||
response.status(HttpStatus.BAD_REQUEST).send('Missing Listify user ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await session.transport.handleRequest(request, response, request.body);
|
||||
} catch (error) {
|
||||
@@ -175,22 +179,36 @@ export class McpController {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
private requireUserId(request: AuthenticatedRequest): string {
|
||||
if (!request.user?.sub) {
|
||||
throw new UnauthorizedException('Authenticated user is required.');
|
||||
}
|
||||
|
||||
return request.user.sub;
|
||||
private userIdFrom(request: AuthenticatedRequest): string | undefined {
|
||||
return (
|
||||
request.user?.sub ??
|
||||
this.singleHeaderValue(request.headers['x-listify-user-id']) ??
|
||||
this.singleHeaderValue(request.headers['x-user-id']) ??
|
||||
this.singleQueryValue(request.query?.userId) ??
|
||||
this.singleQueryValue(request.query?.user_id)
|
||||
);
|
||||
}
|
||||
|
||||
private sessionIdFrom(request: AuthenticatedRequest): string | undefined {
|
||||
const sessionId = request.headers['mcp-session-id'];
|
||||
return this.singleHeaderValue(request.headers['mcp-session-id']);
|
||||
}
|
||||
|
||||
if (Array.isArray(sessionId)) {
|
||||
return sessionId[0];
|
||||
private singleHeaderValue(
|
||||
value: string | string[] | undefined,
|
||||
): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
return value;
|
||||
}
|
||||
|
||||
private singleQueryValue(value: unknown): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return typeof value[0] === 'string' ? value[0] : undefined;
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
private writeJsonRpcError(
|
||||
|
||||
Reference in New Issue
Block a user