From 01f2aff0bee32ce41ccc5ef1aecf8842ffed6f55 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 24 Jun 2026 13:17:48 +0200 Subject: [PATCH] mcp --- listify-api/README.md | 5 +- .../src/assistant/assistant.service.spec.ts | 2 + .../src/assistant/assistant.service.ts | 2 + listify-api/src/auth/auth.controller.ts | 19 --- listify-api/src/auth/auth.service.spec.ts | 50 ------- listify-api/src/auth/auth.service.ts | 123 +----------------- listify-api/src/auth/auth.types.ts | 10 +- listify-api/src/auth/mcp-auth.guard.ts | 22 +++- .../src/mcp/mcp-server.service.spec.ts | 71 ++++++++-- listify-api/src/mcp/mcp-server.service.ts | 116 ++++++++++++----- listify-api/src/mcp/mcp.controller.ts | 52 +++++--- .../src/app/account/account.component.html | 62 --------- .../src/app/account/account.component.scss | 67 ---------- .../src/app/account/account.component.ts | 97 +------------- listify-client/src/app/auth/auth.models.ts | 8 -- listify-client/src/app/auth/auth.service.ts | 14 -- 16 files changed, 212 insertions(+), 508 deletions(-) diff --git a/listify-api/README.md b/listify-api/README.md index 5feac36..af2f997 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -51,9 +51,12 @@ The in-app assistant calls the Mistral Conversations API from the API server. Co ```bash MISTRAL_API_KEY=your-mistral-api-key MISTRAL_MODEL=mistral-large-latest +MCP_ACCESS_TOKEN=shared-secret-for-the-listify-mcp-connector ``` -The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/conversations` with the `listify` connector enabled and returns the assistant text. Listify inspects returned tool outputs and refreshes local list state when a list was created or changed. +The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/conversations` with the `listify` connector enabled and returns the assistant text. The MCP connector token authenticates only the connector. The assistant passes the authenticated Listify `userId` into every connector tool call, so each logged-in user sees and modifies only their own lists. + +Configure the external MCP connector with `Authorization: Bearer $MCP_ACCESS_TOKEN`. If your connector supports dynamic headers, it may also send `x-listify-user-id`; otherwise the MCP tools require a `userId` argument. Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata. diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index b841b4f..c923d92 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -3,6 +3,8 @@ import { AssistantService } from './assistant.service'; describe('AssistantService', () => { const connectorSystemMessage = [ + 'Die eingeloggte Listify userId ist "user-1".', + 'Der listify Connector ist nicht usergebunden. Sende bei jedem listify Tool-Call das Argument userId exakt mit dieser userId.', 'Benutze fuer Listify-Daten immer den listify Connector.', 'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.', 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.', diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 3fa92f0..0f3544a 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -504,6 +504,8 @@ export class AssistantService { const instructions = [ contextMessage?.content, [ + `Die eingeloggte Listify userId ist "${userId}".`, + 'Der listify Connector ist nicht usergebunden. Sende bei jedem listify Tool-Call das Argument userId exakt mit dieser userId.', 'Benutze fuer Listify-Daten immer den listify Connector.', 'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.', 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.', diff --git a/listify-api/src/auth/auth.controller.ts b/listify-api/src/auth/auth.controller.ts index 6a47f33..eca413b 100644 --- a/listify-api/src/auth/auth.controller.ts +++ b/listify-api/src/auth/auth.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - Delete, Get, HttpCode, HttpStatus, @@ -56,24 +55,6 @@ export class AuthController { return this.authService.getPublicUser(request.user!.sub); } - @Get('me/mcp-api-key') - @UseGuards(JwtAuthGuard) - getMcpApiKeyStatus(@Req() request: AuthenticatedRequest) { - return this.authService.getMcpApiKeyStatus(request.user!.sub); - } - - @Post('me/mcp-api-key') - @UseGuards(JwtAuthGuard) - createMcpApiKey(@Req() request: AuthenticatedRequest) { - return this.authService.createMcpApiKey(request.user!.sub); - } - - @Delete('me/mcp-api-key') - @UseGuards(JwtAuthGuard) - revokeMcpApiKey(@Req() request: AuthenticatedRequest) { - return this.authService.revokeMcpApiKey(request.user!.sub); - } - @Get('users/search') @UseGuards(JwtAuthGuard) searchUsers( diff --git a/listify-api/src/auth/auth.service.spec.ts b/listify-api/src/auth/auth.service.spec.ts index 9e60a65..b1dbac7 100644 --- a/listify-api/src/auth/auth.service.spec.ts +++ b/listify-api/src/auth/auth.service.spec.ts @@ -184,56 +184,6 @@ describe('AuthService', () => { ).rejects.toThrow('Access token is invalid.'); }); - it('creates and validates a persistent MCP API key', async () => { - const loginResponse = await registerVerifiedUserAndLogin(); - const apiKeyResponse = await authService.createMcpApiKey( - loginResponse.user.id, - ); - const status = await authService.getMcpApiKeyStatus(loginResponse.user.id); - const payload = await authService.verifyMcpCredential( - apiKeyResponse.apiKey, - ); - - expect(apiKeyResponse.apiKey).toMatch(/^lfy_mcp_/); - expect(apiKeyResponse.createdAt).toBeDefined(); - expect(status.createdAt).toBe(apiKeyResponse.createdAt); - expect(payload.type).toBe('mcp_api_key'); - expect(payload.sub).toBe(loginResponse.user.id); - expect(payload.email).toBe('user@example.com'); - }); - - it('rotates MCP API keys and rejects the old key', async () => { - const loginResponse = await registerVerifiedUserAndLogin(); - const firstKey = await authService.createMcpApiKey(loginResponse.user.id); - const secondKey = await authService.createMcpApiKey(loginResponse.user.id); - - expect(secondKey.apiKey).not.toBe(firstKey.apiKey); - await expect(authService.verifyMcpApiKey(firstKey.apiKey)).rejects.toThrow( - 'MCP API key is invalid.', - ); - await expect( - authService.verifyMcpApiKey(secondKey.apiKey), - ).resolves.toMatchObject({ - sub: loginResponse.user.id, - type: 'mcp_api_key', - }); - }); - - it('revokes MCP API keys', async () => { - const loginResponse = await registerVerifiedUserAndLogin(); - const apiKeyResponse = await authService.createMcpApiKey( - loginResponse.user.id, - ); - const revokedStatus = await authService.revokeMcpApiKey( - loginResponse.user.id, - ); - - expect(revokedStatus.createdAt).toBeUndefined(); - await expect( - authService.verifyMcpApiKey(apiKeyResponse.apiKey), - ).rejects.toThrow('MCP API key is invalid.'); - }); - it('rejects duplicate registrations', async () => { await authService.register({ email: 'user@example.com', diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index 84c829d..acc9669 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -8,13 +8,7 @@ import { import { EventEmitter2 } from '@nestjs/event-emitter'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; -import { - createHash, - randomBytes, - randomUUID, - scryptSync, - timingSafeEqual, -} from 'crypto'; +import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; import { Like, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; import { LoginDto } from './dto/login.dto'; @@ -25,8 +19,6 @@ import { AuthTokenResponse, AuthTokens, JwtTokenPayload, - McpApiKeyResponse, - McpApiKeyStatus, PublicUser, PublicUserSearchResult, } from './auth.types'; @@ -38,7 +30,6 @@ import { UserEntity } from './user.entity'; export class AuthService { private readonly accessTokenExpiresIn = '7d'; private readonly refreshTokenExpiresIn = '30d'; - private readonly mcpApiKeyPrefix = 'lfy_mcp_'; private readonly accessTokenSecret = process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret'; private readonly refreshTokenSecret = @@ -281,35 +272,6 @@ export class AuthService { } } - async verifyMcpCredential(token: string): Promise { - if (token.startsWith(this.mcpApiKeyPrefix)) { - return this.verifyMcpApiKey(token); - } - - return this.verifyAccessToken(token); - } - - async verifyMcpApiKey(apiKey: string): Promise { - const apiKeyHash = this.hashApiKey(apiKey); - const user = await this.usersRepository.findOne({ - where: { mcpApiKeyHash: apiKeyHash }, - }); - - if ( - !user || - !user.verified || - !this.apiKeyHashMatches(apiKey, apiKeyHash) - ) { - throw new UnauthorizedException('MCP API key is invalid.'); - } - - return { - sub: user.id, - email: user.email, - type: 'mcp_api_key', - }; - } - async getUserDisplayName(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, @@ -334,57 +296,6 @@ export class AuthService { return this.toPublicUser(user); } - async getMcpApiKeyStatus(userId: string): Promise { - const user = await this.requireUser(userId); - - return this.toMcpApiKeyStatus(user); - } - - async createMcpApiKey(userId: string): Promise { - const user = await this.requireUser(userId); - const hadExistingKey = Boolean(user.mcpApiKeyHash); - const apiKey = `${this.mcpApiKeyPrefix}${randomBytes(32).toString('base64url')}`; - const createdAt = new Date(); - - user.mcpApiKeyHash = this.hashApiKey(apiKey); - user.mcpApiKeyCreatedAt = createdAt; - - const savedUser = await this.usersRepository.save(user); - - await this.auditLogService?.record({ - actorUserId: savedUser.id, - actorEmail: savedUser.email, - action: 'user.mcp_api_key_created', - entityType: 'user', - entityId: savedUser.id, - metadata: { rotated: hadExistingKey }, - }); - - return { - apiKey, - ...this.toMcpApiKeyStatus(savedUser), - }; - } - - async revokeMcpApiKey(userId: string): Promise { - const user = await this.requireUser(userId); - - user.mcpApiKeyHash = null; - user.mcpApiKeyCreatedAt = null; - - const savedUser = await this.usersRepository.save(user); - - await this.auditLogService?.record({ - actorUserId: savedUser.id, - actorEmail: savedUser.email, - action: 'user.mcp_api_key_revoked', - entityType: 'user', - entityId: savedUser.id, - }); - - return this.toMcpApiKeyStatus(savedUser); - } - async searchUsers( actorUserId: string, query?: string, @@ -448,18 +359,6 @@ export class AuthService { return this.toPublicUser(savedUser); } - private async requireUser(userId: string): Promise { - const user = await this.usersRepository.findOne({ - where: { id: userId }, - }); - - if (!user) { - throw new UnauthorizedException('Authenticated user is required.'); - } - - return user; - } - private normalizeEmail(email?: string): string { const normalizedEmail = email?.trim().toLowerCase(); @@ -580,20 +479,6 @@ export class AuthService { return this.passwordMatches(token, tokenHash); } - private hashApiKey(apiKey: string): string { - return createHash('sha256').update(apiKey).digest('hex'); - } - - private apiKeyHashMatches(apiKey: string, apiKeyHash: string): boolean { - const expectedHashBuffer = Buffer.from(apiKeyHash, 'hex'); - const attemptedHashBuffer = Buffer.from(this.hashApiKey(apiKey), 'hex'); - - return ( - expectedHashBuffer.length === attemptedHashBuffer.length && - timingSafeEqual(expectedHashBuffer, attemptedHashBuffer) - ); - } - private createToken(): string { return randomBytes(32).toString('hex'); } @@ -612,10 +497,4 @@ export class AuthService { onboardingCompleted: user.onboardingCompleted === true, }; } - - private toMcpApiKeyStatus(user: UserEntity): McpApiKeyStatus { - return user.mcpApiKeyHash && user.mcpApiKeyCreatedAt - ? { createdAt: user.mcpApiKeyCreatedAt.toISOString() } - : {}; - } } diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index e443eb3..eb4110f 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -22,7 +22,7 @@ export interface AuthTokenResponse extends AuthTokens { export interface JwtTokenPayload { sub: string; email: string; - type: 'access' | 'refresh' | 'mcp_api_key'; + type: 'access' | 'refresh'; jti?: string; } @@ -43,11 +43,3 @@ export interface PublicUserSearchResult { email: string; name?: string; } - -export interface McpApiKeyStatus { - createdAt?: string; -} - -export interface McpApiKeyResponse extends McpApiKeyStatus { - apiKey: string; -} diff --git a/listify-api/src/auth/mcp-auth.guard.ts b/listify-api/src/auth/mcp-auth.guard.ts index 451361a..c3e44d0 100644 --- a/listify-api/src/auth/mcp-auth.guard.ts +++ b/listify-api/src/auth/mcp-auth.guard.ts @@ -4,18 +4,24 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; +import { timingSafeEqual } from 'crypto'; import { AuthenticatedRequest } from './auth.types'; -import { AuthService } from './auth.service'; @Injectable() export class McpAuthGuard implements CanActivate { - constructor(private readonly authService: AuthService) {} + private readonly sharedAccessToken = process.env.MCP_ACCESS_TOKEN; async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const token = this.extractBearerToken(request); - request.user = await this.authService.verifyMcpCredential(token); + if (!this.sharedAccessToken) { + throw new UnauthorizedException('MCP access token is not configured.'); + } + + if (!this.tokenMatches(token, this.sharedAccessToken)) { + throw new UnauthorizedException('MCP access token is invalid.'); + } return true; } @@ -39,4 +45,14 @@ export class McpAuthGuard implements CanActivate { return token; } + + private tokenMatches(token: string, expectedToken: string): boolean { + const tokenBuffer = Buffer.from(token); + const expectedTokenBuffer = Buffer.from(expectedToken); + + return ( + tokenBuffer.length === expectedTokenBuffer.length && + timingSafeEqual(tokenBuffer, expectedTokenBuffer) + ); + } } diff --git a/listify-api/src/mcp/mcp-server.service.spec.ts b/listify-api/src/mcp/mcp-server.service.spec.ts index 5df19ab..ab6cda2 100644 --- a/listify-api/src/mcp/mcp-server.service.spec.ts +++ b/listify-api/src/mcp/mcp-server.service.spec.ts @@ -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, extra: never) => Promise<{ + handler: ( + args: Record, + extra: never, + ) => Promise<{ structuredContent?: unknown; }>; }; diff --git a/listify-api/src/mcp/mcp-server.service.ts b/listify-api/src/mcp/mcp-server.service.ts index 22de5eb..afff5bc 100644 --- a/listify-api/src/mcp/mcp-server.service.ts +++ b/listify-api/src/mcp/mcp-server.service.ts @@ -9,10 +9,22 @@ import { ListSuggestionAgentService } from './list-suggestion-agent.service'; const listKindSchema = z .enum(['packing', 'shopping', 'todo', 'custom']) .optional(); +type ToolInputSchema = Record; +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( + 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: [ diff --git a/listify-api/src/mcp/mcp.controller.ts b/listify-api/src/mcp/mcp.controller.ts index 7d34fa5..9746242 100644 --- a/listify-api/src/mcp/mcp.controller.ts +++ b/listify-api/src/mcp/mcp.controller.ts @@ -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 { - 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 { @@ -120,7 +119,7 @@ export class McpController { request: AuthenticatedRequest, response: Response, ): Promise { - 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 { @@ -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( diff --git a/listify-client/src/app/account/account.component.html b/listify-client/src/app/account/account.component.html index 081a045..9c50540 100644 --- a/listify-client/src/app/account/account.component.html +++ b/listify-client/src/app/account/account.component.html @@ -17,68 +17,6 @@ {{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }} - -
-
-
-

MCP Connector

-

- @if (mcpApiKeyLoading()) { - Status wird geladen - } @else if (mcpApiKeyCreatedAt()) { - Key aktiv seit {{ formatDate(mcpApiKeyCreatedAt()!) }} - } @else { - Kein MCP-Key aktiv - } -

-
- -
- - @if (generatedMcpApiKey()) { -
- {{ generatedMcpApiKey() }} - -
-

Der Key wird nur jetzt angezeigt.

- } - - @if (mcpApiKeyMessage()) { -

{{ mcpApiKeyMessage() }}

- } - -
- - -
-
diff --git a/listify-client/src/app/account/account.component.scss b/listify-client/src/app/account/account.component.scss index f7da6f6..635383c 100644 --- a/listify-client/src/app/account/account.component.scss +++ b/listify-client/src/app/account/account.component.scss @@ -30,73 +30,6 @@ color: var(--mat-sys-primary); } -.mcp-key-panel { - display: grid; - gap: 1rem; - margin-top: 1rem; - padding: 1rem; - border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--mat-sys-surface-container-low) 44%, var(--mat-sys-surface)); -} - -.mcp-key-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; -} - -.mcp-key-header h2 { - margin: 0; - font-size: 1rem; - font-weight: 600; - line-height: 1.3; -} - -.mcp-key-header p, -.key-hint, -.mcp-key-message { - margin: 0.25rem 0 0; - color: var(--mat-sys-on-surface-variant); - font-size: 0.875rem; - line-height: 1.4; -} - -.mcp-key-header mat-icon { - color: var(--mat-sys-primary); -} - -.generated-key { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 0.5rem; - padding: 0.625rem; - border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); - border-radius: 8px; - background: var(--mat-sys-surface); -} - -.generated-key code { - min-width: 0; - overflow-wrap: anywhere; - color: var(--mat-sys-on-surface); - font-size: 0.8125rem; - line-height: 1.4; -} - -.mcp-key-actions { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.mcp-key-actions mat-progress-spinner { - display: inline-flex; - margin-right: 0.5rem; -} - mat-card-actions { flex-wrap: wrap; gap: 0.5rem; diff --git a/listify-client/src/app/account/account.component.ts b/listify-client/src/app/account/account.component.ts index dd87198..59ea2d2 100644 --- a/listify-client/src/app/account/account.component.ts +++ b/listify-client/src/app/account/account.component.ts @@ -1,11 +1,9 @@ -import { Component, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { finalize } from 'rxjs'; import { AuthService } from '../auth/auth.service'; import { OnboardingService } from '../onboarding/onboarding.service'; @@ -18,107 +16,14 @@ import { OnboardingService } from '../onboarding/onboarding.service'; export class AccountComponent { protected readonly auth = inject(AuthService); protected readonly onboarding = inject(OnboardingService); - protected readonly mcpApiKeyCreatedAt = signal(null); - protected readonly generatedMcpApiKey = signal(null); - protected readonly mcpApiKeyLoading = signal(false); - protected readonly mcpApiKeySaving = signal(false); - protected readonly mcpApiKeyMessage = signal(null); - private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); - constructor() { - this.loadMcpApiKeyStatus(); - } - resetOnboarding(): void { this.onboarding.resetForCurrentUser(); } - createMcpApiKey(): void { - this.mcpApiKeySaving.set(true); - this.mcpApiKeyMessage.set(null); - this.generatedMcpApiKey.set(null); - - this.auth - .createMcpApiKey() - .pipe( - takeUntilDestroyed(this.destroyRef), - finalize(() => this.mcpApiKeySaving.set(false)), - ) - .subscribe({ - next: (response) => { - this.mcpApiKeyCreatedAt.set(response.createdAt ?? null); - this.generatedMcpApiKey.set(response.apiKey); - this.mcpApiKeyMessage.set('MCP-Key wurde erzeugt.'); - }, - error: () => { - this.mcpApiKeyMessage.set('MCP-Key konnte nicht erzeugt werden.'); - }, - }); - } - - revokeMcpApiKey(): void { - this.mcpApiKeySaving.set(true); - this.mcpApiKeyMessage.set(null); - this.generatedMcpApiKey.set(null); - - this.auth - .revokeMcpApiKey() - .pipe( - takeUntilDestroyed(this.destroyRef), - finalize(() => this.mcpApiKeySaving.set(false)), - ) - .subscribe({ - next: () => { - this.mcpApiKeyCreatedAt.set(null); - this.mcpApiKeyMessage.set('MCP-Key wurde widerrufen.'); - }, - error: () => { - this.mcpApiKeyMessage.set('MCP-Key konnte nicht widerrufen werden.'); - }, - }); - } - - copyMcpApiKey(): void { - const apiKey = this.generatedMcpApiKey(); - - if (!apiKey || typeof navigator === 'undefined' || !navigator.clipboard) { - return; - } - - void navigator.clipboard.writeText(apiKey).then(() => { - this.mcpApiKeyMessage.set('MCP-Key wurde kopiert.'); - }); - } - logout(): void { this.auth.logout(); void this.router.navigateByUrl('/login'); } - - protected formatDate(value: string): string { - return new Date(value).toLocaleString('de-DE', { - dateStyle: 'medium', - timeStyle: 'short', - }); - } - - private loadMcpApiKeyStatus(): void { - this.mcpApiKeyLoading.set(true); - - this.auth - .getMcpApiKeyStatus() - .pipe( - takeUntilDestroyed(this.destroyRef), - finalize(() => this.mcpApiKeyLoading.set(false)), - ) - .subscribe({ - next: (status) => { - this.mcpApiKeyCreatedAt.set(status.createdAt ?? null); - }, - error: () => { - this.mcpApiKeyMessage.set('MCP-Key-Status konnte nicht geladen werden.'); - }, - }); - } } diff --git a/listify-client/src/app/auth/auth.models.ts b/listify-client/src/app/auth/auth.models.ts index 54c67f7..a41befe 100644 --- a/listify-client/src/app/auth/auth.models.ts +++ b/listify-client/src/app/auth/auth.models.ts @@ -32,14 +32,6 @@ export interface ResendVerificationResponse { message: string; } -export interface McpApiKeyStatus { - createdAt?: string; -} - -export interface McpApiKeyResponse extends McpApiKeyStatus { - apiKey: string; -} - export interface LoginRequest { email: string; password: string; diff --git a/listify-client/src/app/auth/auth.service.ts b/listify-client/src/app/auth/auth.service.ts index 8484561..a247613 100644 --- a/listify-client/src/app/auth/auth.service.ts +++ b/listify-client/src/app/auth/auth.service.ts @@ -4,8 +4,6 @@ import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs'; import { AuthTokenResponse, LoginRequest, - McpApiKeyResponse, - McpApiKeyStatus, PublicUser, PublicUserSearchResult, RegisterRequest, @@ -66,18 +64,6 @@ export class AuthService { .pipe(tap((user) => this.storeUser(user))); } - getMcpApiKeyStatus(): Observable { - return this.http.get(`${this.apiUrl}/me/mcp-api-key`); - } - - createMcpApiKey(): Observable { - return this.http.post(`${this.apiUrl}/me/mcp-api-key`, {}); - } - - revokeMcpApiKey(): Observable { - return this.http.delete(`${this.apiUrl}/me/mcp-api-key`); - } - accessToken(): string | null { return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null; }