mcp
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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() }
|
|
||||||
: {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user