xpeditis2.0/apps/backend/src/application/mcp/mcp.controller.ts

180 lines
6.7 KiB
TypeScript

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<string, unknown>;
}
@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<string, unknown> | 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<CapabilityActor> {
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 },
});