import { Body, Controller, HttpCode, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CapabilityActor } from '@domain/services/capability-access'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { SubscriptionService } from '../services/subscription.service'; import { CapabilityInputError } from './capability'; import { CapabilityRegistry } from './capability.registry'; /** * Serveur MCP d'Xpeditis. * * Expose les capacites du produit au protocole Model Context Protocol, sur une * unique route HTTP. Le transport est volontairement minimal : un POST * JSON-RPC, sans session ni flux SSE. Un serveur qui n'expose que des outils * n'a rien a diffuser au client entre deux appels, et l'absence d'etat rend * chaque requete authentifiable independamment — ce qui compte ici, puisque * deux appels consecutifs peuvent venir de deux comptes differents. * * L'authentification n'est pas reimplementee : la route passe par le garde * global `ApiKeyOrJwtGuard`, donc une cle API `X-API-Key` (offres Gold et * Platinium) ou un jeton JWT. L'identite obtenue porte le role et l'offre, qui * decident ensuite de ce que le catalogue laisse voir. * * Non couvert a ce stade : les ressources et les invites MCP, la negociation * SSE, et les notifications serveur → client. */ const PROTOCOL_VERSION = '2025-06-18'; const SERVER_INFO = { name: 'xpeditis', version: '1.0.0' }; /** Codes d'erreur JSON-RPC 2.0. */ const enum RpcError { InvalidRequest = -32600, MethodNotFound = -32601, InvalidParams = -32602, InternalError = -32603, } interface RpcRequest { jsonrpc?: string; id?: string | number | null; method?: string; params?: Record; } @ApiTags('MCP') @ApiBearerAuth() @Controller('mcp') export class McpController { constructor( private readonly registry: CapabilityRegistry, private readonly subscriptions: SubscriptionService ) {} @Post() @HttpCode(200) @ApiOperation({ summary: 'Model Context Protocol endpoint', description: 'JSON-RPC 2.0 endpoint exposing Xpeditis capabilities as MCP tools. Authenticate with an X-API-Key header (Gold and Platinium plans) or a JWT bearer token. Supported methods: initialize, tools/list, tools/call, ping.', }) @ApiResponse({ status: 200, description: 'JSON-RPC response' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async rpc(@CurrentUser() user: UserPayload, @Body() body: RpcRequest | RpcRequest[]) { // Un lot JSON-RPC est traite element par element, dans l'ordre reçu. if (Array.isArray(body)) { const responses = await Promise.all(body.map(entry => this.handle(user, entry))); return responses.filter(response => response !== null); } return this.handle(user, body); } private async handle(user: UserPayload, request: RpcRequest) { const id = request?.id ?? null; // Une notification (sans `id`) n'attend pas de reponse : `notifications/initialized` // arrive juste apres la poignee de main de tout client MCP. if (id === null && request?.method?.startsWith('notifications/')) return null; if (request?.jsonrpc !== '2.0' || typeof request.method !== 'string') { return fail(id, RpcError.InvalidRequest, 'Invalid JSON-RPC 2.0 request.'); } try { switch (request.method) { case 'initialize': return ok(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, serverInfo: SERVER_INFO, instructions: "Xpeditis est une plateforme de réservation de fret maritime LCL. Les outils disponibles dépendent du rôle et de l'offre du compte authentifié : appelez `whoami` pour connaître les droits en cours. Pour une question de connaissance métier, préférez `search_documentation`, qui répond à partir du wiki Xpeditis.", }); case 'ping': return ok(id, {}); case 'tools/list': { const actor = await this.actorOf(user); return ok(id, { tools: this.registry.listFor(actor).map(capability => ({ name: capability.policy.name, description: capability.description, inputSchema: capability.inputSchema, annotations: { readOnlyHint: capability.policy.scope === 'read' }, })), }); } case 'tools/call': { const name = request.params?.name; if (typeof name !== 'string') { return fail(id, RpcError.InvalidParams, 'Missing tool name.'); } const actor = await this.actorOf(user); const result = await this.registry.invoke( name, request.params?.arguments as Record | undefined, actor ); return ok(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], isError: false, }); } default: return fail(id, RpcError.MethodNotFound, `Unknown method "${request.method}".`); } } catch (error) { return this.toRpcError(id, request.method, error); } } /** * Une erreur d'outil se rend au modele, pas au transport : MCP demande de * repondre `isError` dans le resultat pour qu'un agent puisse corriger son * appel, la ou une erreur JSON-RPC interromprait l'echange. */ private toRpcError(id: string | number | null, method: string | undefined, error: unknown) { const message = error instanceof Error ? error.message : String(error); if (method === 'tools/call') { const invalid = error instanceof CapabilityInputError; return ok(id, { content: [{ type: 'text', text: message }], isError: true, ...(invalid ? {} : {}), }); } return fail(id, RpcError.InternalError, message); } /** * Identite de l'appelant, completee de son offre. * * L'offre est relue a chaque appel pour appliquer les suspensions et les * changements de droits, meme si un jeton porte encore une ancienne offre. */ private async actorOf(user: UserPayload & { plan?: string }): Promise { const subscription = await this.subscriptions.getOrCreateSubscription(user.organizationId); return { id: user.id, organizationId: user.organizationId, role: user.role, email: user.email, plan: subscription.accessPlan.value, }; } } const ok = (id: string | number | null, result: unknown) => ({ jsonrpc: '2.0', id, result }); const fail = (id: string | number | null, code: number, message: string) => ({ jsonrpc: '2.0', id, error: { code, message }, });