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

@@ -51,9 +51,12 @@ The in-app assistant calls the Mistral Conversations API from the API server. Co
```bash ```bash
MISTRAL_API_KEY=your-mistral-api-key MISTRAL_API_KEY=your-mistral-api-key
MISTRAL_MODEL=mistral-large-latest 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. 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.

View File

@@ -3,6 +3,8 @@ import { AssistantService } from './assistant.service';
describe('AssistantService', () => { describe('AssistantService', () => {
const connectorSystemMessage = [ 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.', '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 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.', 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.',

View File

@@ -504,6 +504,8 @@ export class AssistantService {
const instructions = [ const instructions = [
contextMessage?.content, 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.', '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 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.', 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.',

View File

@@ -1,7 +1,6 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
@@ -56,24 +55,6 @@ export class AuthController {
return this.authService.getPublicUser(request.user!.sub); 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') @Get('users/search')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
searchUsers( searchUsers(

View File

@@ -184,56 +184,6 @@ describe('AuthService', () => {
).rejects.toThrow('Access token is invalid.'); ).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 () => { it('rejects duplicate registrations', async () => {
await authService.register({ await authService.register({
email: 'user@example.com', email: 'user@example.com',

View File

@@ -8,13 +8,7 @@ import {
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
createHash,
randomBytes,
randomUUID,
scryptSync,
timingSafeEqual,
} from 'crypto';
import { Like, Repository } from 'typeorm'; import { Like, Repository } from 'typeorm';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
@@ -25,8 +19,6 @@ import {
AuthTokenResponse, AuthTokenResponse,
AuthTokens, AuthTokens,
JwtTokenPayload, JwtTokenPayload,
McpApiKeyResponse,
McpApiKeyStatus,
PublicUser, PublicUser,
PublicUserSearchResult, PublicUserSearchResult,
} from './auth.types'; } from './auth.types';
@@ -38,7 +30,6 @@ import { UserEntity } from './user.entity';
export class AuthService { export class AuthService {
private readonly accessTokenExpiresIn = '7d'; private readonly accessTokenExpiresIn = '7d';
private readonly refreshTokenExpiresIn = '30d'; private readonly refreshTokenExpiresIn = '30d';
private readonly mcpApiKeyPrefix = 'lfy_mcp_';
private readonly accessTokenSecret = private readonly accessTokenSecret =
process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret'; process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret';
private readonly refreshTokenSecret = private readonly refreshTokenSecret =
@@ -281,35 +272,6 @@ export class AuthService {
} }
} }
async verifyMcpCredential(token: string): Promise<JwtTokenPayload> {
if (token.startsWith(this.mcpApiKeyPrefix)) {
return this.verifyMcpApiKey(token);
}
return this.verifyAccessToken(token);
}
async verifyMcpApiKey(apiKey: string): Promise<JwtTokenPayload> {
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<string> { async getUserDisplayName(userId: string): Promise<string> {
const user = await this.usersRepository.findOne({ const user = await this.usersRepository.findOne({
where: { id: userId }, where: { id: userId },
@@ -334,57 +296,6 @@ export class AuthService {
return this.toPublicUser(user); return this.toPublicUser(user);
} }
async getMcpApiKeyStatus(userId: string): Promise<McpApiKeyStatus> {
const user = await this.requireUser(userId);
return this.toMcpApiKeyStatus(user);
}
async createMcpApiKey(userId: string): Promise<McpApiKeyResponse> {
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<McpApiKeyStatus> {
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( async searchUsers(
actorUserId: string, actorUserId: string,
query?: string, query?: string,
@@ -448,18 +359,6 @@ export class AuthService {
return this.toPublicUser(savedUser); return this.toPublicUser(savedUser);
} }
private async requireUser(userId: string): Promise<UserEntity> {
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 { private normalizeEmail(email?: string): string {
const normalizedEmail = email?.trim().toLowerCase(); const normalizedEmail = email?.trim().toLowerCase();
@@ -580,20 +479,6 @@ export class AuthService {
return this.passwordMatches(token, tokenHash); 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 { private createToken(): string {
return randomBytes(32).toString('hex'); return randomBytes(32).toString('hex');
} }
@@ -612,10 +497,4 @@ export class AuthService {
onboardingCompleted: user.onboardingCompleted === true, onboardingCompleted: user.onboardingCompleted === true,
}; };
} }
private toMcpApiKeyStatus(user: UserEntity): McpApiKeyStatus {
return user.mcpApiKeyHash && user.mcpApiKeyCreatedAt
? { createdAt: user.mcpApiKeyCreatedAt.toISOString() }
: {};
}
} }

View File

@@ -22,7 +22,7 @@ export interface AuthTokenResponse extends AuthTokens {
export interface JwtTokenPayload { export interface JwtTokenPayload {
sub: string; sub: string;
email: string; email: string;
type: 'access' | 'refresh' | 'mcp_api_key'; type: 'access' | 'refresh';
jti?: string; jti?: string;
} }
@@ -43,11 +43,3 @@ export interface PublicUserSearchResult {
email: string; email: string;
name?: string; name?: string;
} }
export interface McpApiKeyStatus {
createdAt?: string;
}
export interface McpApiKeyResponse extends McpApiKeyStatus {
apiKey: string;
}

View File

@@ -4,18 +4,24 @@ import {
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { timingSafeEqual } from 'crypto';
import { AuthenticatedRequest } from './auth.types'; import { AuthenticatedRequest } from './auth.types';
import { AuthService } from './auth.service';
@Injectable() @Injectable()
export class McpAuthGuard implements CanActivate { export class McpAuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {} private readonly sharedAccessToken = process.env.MCP_ACCESS_TOKEN;
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.extractBearerToken(request); 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; return true;
} }
@@ -39,4 +45,14 @@ export class McpAuthGuard implements CanActivate {
return token; 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)
);
}
} }

View File

@@ -81,15 +81,25 @@ describe('McpServerService', () => {
description: 'Packliste', description: 'Packliste',
kind: 'packing', kind: 'packing',
}); });
expect(listsService.addItem).toHaveBeenNthCalledWith(1, 'user-1', 'list-1', { expect(listsService.addItem).toHaveBeenNthCalledWith(
title: 'Pass', 1,
required: true, 'user-1',
}); 'list-1',
expect(listsService.addItem).toHaveBeenNthCalledWith(2, 'user-1', 'list-1', { {
title: 'Tickets', title: 'Pass',
notes: 'Ausdrucken', required: true,
required: false, },
}); );
expect(listsService.addItem).toHaveBeenNthCalledWith(
2,
'user-1',
'list-1',
{
title: 'Tickets',
notes: 'Ausdrucken',
required: false,
},
);
expect(result.structuredContent).toEqual({ list: withSecondItem }); expect(result.structuredContent).toEqual({ list: withSecondItem });
}); });
@@ -114,6 +124,40 @@ describe('McpServerService', () => {
expect(result.structuredContent).toEqual({ list: createdList }); 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 () => { it('registers add_list_item as a write tool and adds an item', async () => {
const updatedList = list({ const updatedList = list({
id: 'list-1', id: 'list-1',
@@ -197,7 +241,9 @@ describe('McpServerService', () => {
name: 'Urlaub', name: 'Urlaub',
items: ['Pass'], 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 tool = toolFrom(service.createServer('user-1'), 'add_template_item');
const result = await tool.handler( const result = await tool.handler(
@@ -235,7 +281,10 @@ function toolFrom(server: object, name: string) {
._registeredTools; ._registeredTools;
return tools[name] as { return tools[name] as {
annotations?: unknown; annotations?: unknown;
handler: (args: Record<string, unknown>, extra: never) => Promise<{ handler: (
args: Record<string, unknown>,
extra: never,
) => Promise<{
structuredContent?: unknown; structuredContent?: unknown;
}>; }>;
}; };

View File

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

View File

@@ -7,7 +7,6 @@ import {
Post, Post,
Req, Req,
Res, Res,
UnauthorizedException,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -22,7 +21,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
interface McpSession { interface McpSession {
server: McpServer; server: McpServer;
transport: StreamableHTTPServerTransport; transport: StreamableHTTPServerTransport;
userId: string; userId?: string;
} }
@Controller('mcp') @Controller('mcp')
@@ -37,7 +36,7 @@ export class McpController {
@Req() request: AuthenticatedRequest, @Req() request: AuthenticatedRequest,
@Res() response: Response, @Res() response: Response,
): Promise<void> { ): Promise<void> {
const userId = this.requireUserId(request); const userId = this.userIdFrom(request);
const sessionId = this.sessionIdFrom(request); const sessionId = this.sessionIdFrom(request);
if (sessionId) { if (sessionId) {
@@ -75,7 +74,7 @@ export class McpController {
} }
private async handleInitialize( private async handleInitialize(
userId: string, userId: string | undefined,
request: AuthenticatedRequest, request: AuthenticatedRequest,
response: Response, response: Response,
): Promise<void> { ): Promise<void> {
@@ -120,7 +119,7 @@ export class McpController {
request: AuthenticatedRequest, request: AuthenticatedRequest,
response: Response, response: Response,
): Promise<void> { ): Promise<void> {
const userId = this.requireUserId(request); const userId = this.userIdFrom(request);
const sessionId = this.sessionIdFrom(request); const sessionId = this.sessionIdFrom(request);
if (!sessionId) { if (!sessionId) {
@@ -133,7 +132,7 @@ export class McpController {
private async handleExistingSession( private async handleExistingSession(
sessionId: string, sessionId: string,
userId: string, userId: string | undefined,
request: AuthenticatedRequest, request: AuthenticatedRequest,
response: Response, response: Response,
): Promise<void> { ): Promise<void> {
@@ -144,10 +143,15 @@ export class McpController {
return; return;
} }
if (session.userId !== userId) { if (session.userId && userId && session.userId !== userId) {
throw new ForbiddenException('MCP session belongs to another user.'); 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 { try {
await session.transport.handleRequest(request, response, request.body); await session.transport.handleRequest(request, response, request.body);
} catch (error) { } catch (error) {
@@ -175,22 +179,36 @@ export class McpController {
await server.close(); await server.close();
} }
private requireUserId(request: AuthenticatedRequest): string { private userIdFrom(request: AuthenticatedRequest): string | undefined {
if (!request.user?.sub) { return (
throw new UnauthorizedException('Authenticated user is required.'); request.user?.sub ??
} this.singleHeaderValue(request.headers['x-listify-user-id']) ??
this.singleHeaderValue(request.headers['x-user-id']) ??
return request.user.sub; this.singleQueryValue(request.query?.userId) ??
this.singleQueryValue(request.query?.user_id)
);
} }
private sessionIdFrom(request: AuthenticatedRequest): string | undefined { 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)) { private singleHeaderValue(
return sessionId[0]; 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( private writeJsonRpcError(

View File

@@ -17,68 +17,6 @@
{{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }} {{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }}
</span> </span>
</div> </div>
<div class="mcp-key-panel">
<div class="mcp-key-header">
<div>
<h2>MCP Connector</h2>
<p>
@if (mcpApiKeyLoading()) {
Status wird geladen
} @else if (mcpApiKeyCreatedAt()) {
Key aktiv seit {{ formatDate(mcpApiKeyCreatedAt()!) }}
} @else {
Kein MCP-Key aktiv
}
</p>
</div>
<mat-icon aria-hidden="true">key</mat-icon>
</div>
@if (generatedMcpApiKey()) {
<div class="generated-key">
<code>{{ generatedMcpApiKey() }}</code>
<button
mat-icon-button
type="button"
aria-label="MCP-Key kopieren"
(click)="copyMcpApiKey()"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
</button>
</div>
<p class="key-hint">Der Key wird nur jetzt angezeigt.</p>
}
@if (mcpApiKeyMessage()) {
<p class="mcp-key-message">{{ mcpApiKeyMessage() }}</p>
}
<div class="mcp-key-actions">
<button
mat-flat-button
type="button"
[disabled]="mcpApiKeyLoading() || mcpApiKeySaving()"
(click)="createMcpApiKey()"
>
@if (mcpApiKeySaving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">vpn_key</mat-icon>
}
{{ mcpApiKeyCreatedAt() ? 'Key rotieren' : 'Key erzeugen' }}
</button>
<button
mat-stroked-button
type="button"
[disabled]="!mcpApiKeyCreatedAt() || mcpApiKeyLoading() || mcpApiKeySaving()"
(click)="revokeMcpApiKey()"
>
<mat-icon aria-hidden="true">block</mat-icon>
Widerrufen
</button>
</div>
</div>
</mat-card-content> </mat-card-content>
<mat-card-actions align="end"> <mat-card-actions align="end">

View File

@@ -30,73 +30,6 @@
color: var(--mat-sys-primary); 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 { mat-card-actions {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;

View File

@@ -1,11 +1,9 @@
import { Component, DestroyRef, inject, signal } from '@angular/core'; import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { finalize } from 'rxjs';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { OnboardingService } from '../onboarding/onboarding.service'; import { OnboardingService } from '../onboarding/onboarding.service';
@@ -18,107 +16,14 @@ import { OnboardingService } from '../onboarding/onboarding.service';
export class AccountComponent { export class AccountComponent {
protected readonly auth = inject(AuthService); protected readonly auth = inject(AuthService);
protected readonly onboarding = inject(OnboardingService); protected readonly onboarding = inject(OnboardingService);
protected readonly mcpApiKeyCreatedAt = signal<string | null>(null);
protected readonly generatedMcpApiKey = signal<string | null>(null);
protected readonly mcpApiKeyLoading = signal(false);
protected readonly mcpApiKeySaving = signal(false);
protected readonly mcpApiKeyMessage = signal<string | null>(null);
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router); private readonly router = inject(Router);
constructor() {
this.loadMcpApiKeyStatus();
}
resetOnboarding(): void { resetOnboarding(): void {
this.onboarding.resetForCurrentUser(); 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 { logout(): void {
this.auth.logout(); this.auth.logout();
void this.router.navigateByUrl('/login'); 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.');
},
});
}
} }

View File

@@ -32,14 +32,6 @@ export interface ResendVerificationResponse {
message: string; message: string;
} }
export interface McpApiKeyStatus {
createdAt?: string;
}
export interface McpApiKeyResponse extends McpApiKeyStatus {
apiKey: string;
}
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
password: string; password: string;

View File

@@ -4,8 +4,6 @@ import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
import { import {
AuthTokenResponse, AuthTokenResponse,
LoginRequest, LoginRequest,
McpApiKeyResponse,
McpApiKeyStatus,
PublicUser, PublicUser,
PublicUserSearchResult, PublicUserSearchResult,
RegisterRequest, RegisterRequest,
@@ -66,18 +64,6 @@ export class AuthService {
.pipe(tap((user) => this.storeUser(user))); .pipe(tap((user) => this.storeUser(user)));
} }
getMcpApiKeyStatus(): Observable<McpApiKeyStatus> {
return this.http.get<McpApiKeyStatus>(`${this.apiUrl}/me/mcp-api-key`);
}
createMcpApiKey(): Observable<McpApiKeyResponse> {
return this.http.post<McpApiKeyResponse>(`${this.apiUrl}/me/mcp-api-key`, {});
}
revokeMcpApiKey(): Observable<McpApiKeyStatus> {
return this.http.delete<McpApiKeyStatus>(`${this.apiUrl}/me/mcp-api-key`);
}
accessToken(): string | null { accessToken(): string | null {
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null; return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
} }