Serveur MCP NestJS
npm i @nestjs/mcp @modelcontextprotocol/server zod
npm i -D @modelcontextprotocol/client # client de test en mémoire
Zod 4.2+ et ArkType fonctionnent directement ; Valibot via toStandardJsonSchema() ; un schéma JSON existant via fromJsonSchema(). Les schémas Zod 3 sont refusés au démarrage.
McpModule.forRoot({
name: 'cat-store', version: '1.0.0',
instructions: 'Consultez les commandes avec find-order avant de les modifier.', // indique au modèle comment utiliser le serveur
}),
app.listen(3000) expose ensuite le endpoint à /mcp. Inspectez-le avec npx @modelcontextprotocol/inspector. Au démarrage, le module parcourt les providers (tous les modules, ou seulement include) pour trouver les méthodes décorées. Il échoue immédiatement sur les noms en double, les schémas invalides ou les scopes déclarés sans auth.
Primitives : méthodes sur les providers
| Primitive | Décorateur | Qui l'utilise | Appelée avec |
|---|---|---|---|
| Outil (une action) | @Tool({ name, description, inputSchema, outputSchema?, annotations?, scopes? }) | le modèle | entrée validée, ctx |
| Ressource (des données) | @Resource({ uri, mimeType }) / @ResourceTemplate({ uriTemplate: 'orders://{orderId}' }) | le client, souvent attaché par l'utilisateur | {} ou les variables du template, ctx (ctx.uri) |
| Prompt (un modèle) | @Prompt({ name, description, argsSchema }) | l'utilisateur en choisi un | arguments validés, ctx |
@Injectable() // un provider simple, listé dans les providers d'un module
export class OrdersTools {
constructor(private readonly orders: OrdersService) {}
@Tool({
name: 'find-order',
description: 'Trouve une commande par son identifiant, ou les dernières commandes d\'un client par email',
inputSchema: z.object({
orderId: z.string().optional().describe('L\'identifiant de la commande, par exemple ord_123'),
email: z.email().optional().describe('L\'adresse email du client'),
}),
annotations: { readOnlyHint: true }, // le client peut l'exécuter sans demander à l'utilisateur
})
async find({ orderId, email }: { orderId?: string; email?: string }) {
const found = orderId ? [await this.orders.findById(orderId)] : await this.orders.latestFor(email!);
if (!found[0]) throw new NotFoundException('Aucune commande trouvée'); // → résultat d'outil avec isError: true
return found;
}
@Tool({
name: 'change-address',
description: 'Change l\'adresse de livraison d\'une commande qui n\'a pas encore été expédiée',
inputSchema: z.object({ orderId: z.string(), address: z.string().min(10) }),
outputSchema: z.object({ orderId: z.string(), address: z.string(), status: z.string() }),
scopes: ['orders:write'],
})
async changeAddress({ orderId, address }: { orderId: string; address: string }, ctx: McpContext) {
const o = await this.orders.changeAddress(orderId, address, ctx.authInfo?.clientId);
return { orderId: o.id, address: o.address, status: o.status }; // → structuredContent + texte JSON
}
}
Valeurs de retour : la chaîne d'un outil devient un bloc de texte. Un objet devient structuredContent s'il y a un outputSchema, et du texte JSON autrement. Renvoyez vous-même un CallToolResult pour des images, de l'audio ou des ressources embarquées ; undefined donne un résultat vide. La chaîne d'une ressource est envoyée sous forme de texte, un Buffer/Uint8Array sous forme de blob, un objet sous forme de JSON. La chaîne d'un prompt devient un message utilisateur ; un tableau renvoie PromptMessage[]. Les noms d'outils font 1 à 128 caractères parmi A-Z a-z 0-9 _ - . et prennent par défaut le nom de la méthode.
Rédigez les noms, descriptions et textes .describe() pour le modèle. Ce sont les seules informations qu'il connaît : dites ce que fait l'outil, quand l'utiliser, s'il modifie quelque chose, et donnez un exemple de valeur pour chaque argument. Utilisez les ressources pour du contenu chargé tel quel ; utilisez les outils pour des recherches qui prennent des arguments.
Contexte d'appel
ctx: McpContext possède les propriétés suivantes :
kind,name,uriauthInfo(clientId,scopes,expiresAt,extra)request(la requête de la plateforme ;undefinedvia stdio)requestInfotransport('http' | 'stdio')era('modern'2026-07-28 /'legacy'2025)signal: passez-le àfetchet aux appels de base de données ; appelezthrowIfAborted()dans les bouclesreportProgress(progress, total?, message?)server(contexte SDK)
Pour demander à l'utilisateur (confirmations, valeurs manquantes), renvoyez inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message, requestedSchema }) } }). Lors de la nouvelle tentative, lisez la réponse avec acceptedContent(ctx.server.mcpReq.inputResponses, 'confirm', Schema). Via HTTP, cela ne fonctionne que pour les clients 2026-07-28, car le endpoint est sans état. Via stdio, cela fonctionne pour les deux ères.
Les providers restent des singletons. Lisez les données par appel depuis ctx, pas depuis des providers scopés par requête.
Enhancers et erreurs
- Les guards, interceptors, pipes et filtres s'exécutent comme sur les routes, avec
context.getType() === 'mcp'.McpExecutionContext.create(context)fournitgetInput(),getContext(),getKind(),getName(),getRequest()etgetAuthInfo(). - Les enhancers globaux s'exécutent sur les appels MCP et sur la requête HTTP porteuse. Les enhancers uniquement HTTP doivent vérifier
context.getType() === 'http'avant d'appelerswitchToHttp(). - Un
ValidationPipe({ transform: true })global valide l'entrée typée DTO. Les décorateurs de paramètres personnalisés viennent après(input, ctx, @CurrentUser() user). - Les erreurs levées par les outils deviennent des résultats
isError: truedont le modèle peut se remettre. UneHttpExceptionconserve son message ; toute autre erreur devientInternal server erroret est consignée. - Les ressources et les prompts répondent avec des erreurs JSON-RPC :
NotFoundException→ ressource non trouvée,BadRequestException→ paramètres invalides. - Filtres : vérifiez
host.getType<McpContextType>() === 'mcp'. Ce que le filtre renvoie devient le résultat ; pour envoyer une erreur JSON-RPC, levez uneProtocolError.
Autorisation (serveurs accessibles par réseau)
McpModule.forRootAsync({
imports: [ConfigModule, AuthModule],
inject: [ConfigService, IdentityProviderTokenVerifier],
useFactory: (config: ConfigService, verifier: IdentityProviderTokenVerifier) => ({
name: 'cat-store', version: config.get('VERSION'),
auth: {
verifier, // implémente McpTokenVerifier.verifyAccessToken(token) → AuthInfo | null
resource: config.get('MCP_RESOURCE'), // URL publique canonique = audience du token
authorizationServers: [config.get('IDENTITY_PROVIDER_URL')],
scopesSupported: ['orders:read', 'orders:write'],
},
}),
}),
- Le serveur est un serveur de ressources OAuth 2.1 : il vérifie les tokens et n'en émet jamais.
- Il sert les métadonnées RFC 9728 à
/.well-known/oauth-protected-resource/mcp. Les requêtes sans token reçoivent une 401 avec un en-têteWWW-Authenticate: Bearer, ce qui déclenche la connexion du client. - Vérificateurs :
- JWT : utilisez
JwtVerifierde@nestjs/authentication(vérifie la signature, l'émetteur, l'audience et l'expiration). - Tokens opaques : appelez le endpoint d'introspection RFC 7662.
- Le
AuthInforetourné doit contenirresource === auth.resourceet unexpiresAtfutur, sinon le token est refusé avec une 401. - Levez une erreur 401 pour refuser un token ; toute autre erreur devient une 500.
- JWT : utilisez
- L'absence de
scopessur une primitive → 403 avec défi d'élévation.auth.requiredScopess'applique à chaque requête. - Avec
@nestjs/authentication≥ 0.1.3 et@nestjs/authorization≥ 0.1.1 :- Leurs guards globaux authentifient chaque appel, et
@CurrentUser()et@Can(Policy, 'action')fonctionnent dans les outils. - Définissez
decorators: [Public()]sur le endpoint pour queauthgère le défi. - Un outil sans
@Can()est refusé.
- Leurs guards globaux authentifient chaque appel, et
- Les serveurs internes uniquement peuvent ignorer OAuth et utiliser
decorators: [UseGuards(ApiKeyGuard)]sur le endpoint.
Transports
- HTTP streamable est servi par défaut ;
path: falsele désactive. Le endpoint est un contrôleur normal, donc le préfixe global, le versionnement (endpointVersion), le middleware et CORS s'appliquent tous. Il est sans état : aucune session collante n'est nécessaire. Les deux ères de protocole sont servies (http.legacy: 'reject'sert uniquement 2026-07-28).http.responseModepeut être'auto','json'ou'sse'. - stdio, pour les assistants qui lancent un processus local : créez
stdio.tsavecNestFactory.createApplicationContext(AppModule, { logger: new McpStdioLogger() }), puisawait app.get(McpStdioServer).listen(); await app.close();. Tout autre chose écrite sur stdout casse le protocole. Les identifiants proviennent deprocess.env; les scopes ne sont pas vérifiés, mais les guards s'exécutent.
Consultez references/mcp-reference.md pour la table des options complètes, les paramètres de production, la configuration du client stdio, les schémas de test et la carte de migration.
Workflow
- 1. Installez ; vérifiez Zod 4.2+ ;
McpModule.forRootavecname,version,instructions. - 2. Ajoutez des outils, ressources et prompts sur les providers, avec des descriptions orientées modèle,
.describe()sur chaque argument,readOnlyHintsur les outils en lecture seule, etoutputSchemalà où le résultat est structuré. - 3. Assurez-vous que les enhancers globaux HTTP uniquement vérifient
getType(); ajoutez guards et filtres MCP si nécessaire. - 4. Serveurs accessibles par réseau : ajoutez
auth(vérificateur, ressource, serveurs d'autorisation, scopes),allowedHosts/allowedOrigins, en-têtes CORS et la limite de taille du corps. Serveurs locaux : ajoutezstdio.tsavecMcpStdioLogger. - 5. Tests : testez unitairement les méthodes directement ; effectuez des tests de bout en bout avec
connectMcpClient(moduleRef, { token })contre un vérificateur de test ; testez la route HTTP via un socket. Construisez, lint, exécutez les tests, démarrez l'application et listez les outils avec l'Inspector.
Fichiers de référence
references/mcp-reference.md: options du module, options des décorateurs, durcissement HTTP (Origin, Host, en-têtes CORS, taille du corps), configuration du client stdio, conventions de nommage NestJS Observe, liste de contrôle pour la production, tests (tokens et scopes, ères de protocole, vérifications de McpRegistry, tests HTTP et stdio), et migration depuis@rekog/mcp-nest1.x/2.x. À lire lors de la configuration de la production, de l'écriture des tests ou de la migration.
Sources : Vue d'ensemble · Primitives · Transports · Tests · Migration