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
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.

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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(

View File

@@ -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',

View File

@@ -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<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> {
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<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(
actorUserId: string,
query?: string,
@@ -448,18 +359,6 @@ export class AuthService {
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 {
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() }
: {};
}
}

View File

@@ -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;
}

View File

@@ -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<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
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)
);
}
}

View File

@@ -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;
}>;
};

View File

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

View File

@@ -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(