Construire un serveur MCP de A à Z : tools, resources, prompts, validation, transports stdio et HTTP — pour connecter Claude à n'importe quel système externe.
Le squelette du chapitre précédent ne valide rien et explose sur n'importe quelle entrée inattendue. En production, les tools ont besoin de validation stricte, d'erreurs informatives, et d'une architecture qui reste lisible à mesure que le nombre de tools grandit.
Sans validation, args?.city as string est une bombe à retardement. Si Claude envoie undefined, un objet, ou un nombre — le cast TypeScript ne protège pas à l'exécution.
Zod valide et type simultanément :
import { z } from 'zod'
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'
// Schéma Zod
const GetWeatherSchema = z.object({
city: z.string().min(1).max(100),
unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
})
// Dans le handler CallToolRequestSchema :
if (name === 'get_weather') {
const parsed = GetWeatherSchema.safeParse(args)
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Paramètres invalides : ${parsed.error.message}`
)
}
const { city, unit } = parsed.data // typé correctement
// ...
}safeParse retourne { success: true, data } ou { success: false, error } — jamais d'exception. On choisit quoi faire avec l'erreur.
Les erreurs MCP ont un code standardisé que les clients comprennent.
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'
// Codes disponibles
ErrorCode.ParseError // JSON invalide
ErrorCode.InvalidRequest // requête malformée
ErrorCode.MethodNotFound // tool inexistant
ErrorCode.InvalidParams // paramètres invalides
ErrorCode.InternalError // erreur serveur// Paramètre manquant ou invalide
throw new McpError(ErrorCode.InvalidParams, 'city est requis et doit être une string')
// Ressource introuvable
throw new McpError(ErrorCode.InvalidParams, `Utilisateur ${id} introuvable`)
// Erreur interne
throw new McpError(ErrorCode.InternalError, `Erreur DB : ${err.message}`)Claude reçoit le message d'erreur et peut l'expliquer à l'utilisateur ou adapter son comportement.
Différence importante : throw McpError = erreur de protocole (tool n'a pas pu s'exécuter). Retourner isError: true = tool s'est exécuté mais a rencontré un cas d'erreur métier.
// Erreur métier — le tool a fonctionné, mais le résultat est une erreur
return {
content: [
{
type: 'text',
text: `Utilisateur ${id} introuvable dans la base de données.`,
},
],
isError: true,
}Claude sait que c'est une erreur et adapte sa réponse — sans que ça brise le flux de la conversation.
Règle :
throw McpError → problème de protocole ou de paramètres invalidesreturn { isError: true } → résultat négatif normal (404, validation métier)Avec 10+ tools, un if/else géant dans le handler devient illisible. Pattern recommandé : un registre de tools.
// src/tools/types.ts
import { z } from 'zod'
export interface ToolDefinition {
name: string
description: string
inputSchema: object
schema: z.ZodType<any>
handler: (args: any) => Promise<ToolResult>
}
export interface ToolResult {
content: Array<{ type: string; text: string }>
isError?: boolean
}// src/tools/weather.ts
import { z } from 'zod'
import type { ToolDefinition } from './types.js'
const schema = z.object({
city: z.string().min(1),
unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
})
export const weatherTool: ToolDefinition = {
name: 'get_weather',
description: 'Météo actuelle pour une ville',
inputSchema: {
type: 'object',
properties: {
city: { type: 'string', description: 'Nom de la ville' },
unit: { type: 'string', enum: ['celsius', 'fahrenheit'], default: 'celsius' },
},
required: ['city'],
},
schema,
handler: async (rawArgs) => {
const { city, unit } = schema.parse(rawArgs)
const res = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=j1`)
if (!res.ok) {
return {
content: [{ type: 'text', text: `Ville "${city}" introuvable.` }],
isError: true,
}
}
const data = await res.json() as any
const current = data.current_condition[0]
const temp = unit === 'celsius' ? current.temp_C : current.temp_F
return {
content: [
{
type: 'text',
text: `${city} : ${temp}°${unit === 'celsius' ? 'C' : 'F'}, ${current.weatherDesc[0].value}`,
},
],
}
},
}// src/tools/index.ts
import { weatherTool } from './weather.js'
import { searchUsersTool } from './users.js'
import { createTicketTool } from './tickets.js'
import type { ToolDefinition } from './types.js'
export const tools: Map<string, ToolDefinition> = new Map([
[weatherTool.name, weatherTool],
[searchUsersTool.name, searchUsersTool],
[createTicketTool.name, createTicketTool],
])// src/index.ts — les handlers deviennent triviaux
import { tools } from './tools/index.js'
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: Array.from(tools.values()).map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
}))
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
const tool = tools.get(name)
if (!tool) {
throw new McpError(ErrorCode.MethodNotFound, `Tool inconnu : ${name}`)
}
try {
return await tool.handler(args)
} catch (err) {
if (err instanceof McpError) throw err
throw new McpError(
ErrorCode.InternalError,
err instanceof Error ? err.message : 'Erreur interne'
)
}
})Ajouter un tool = créer un fichier, l'importer dans tools/index.ts. Zéro modification des handlers.
import { readFile } from 'fs/promises'
return {
content: [
{
type: 'image',
data: (await readFile('/tmp/chart.png')).toString('base64'),
mimeType: 'image/png',
},
{
type: 'text',
text: 'Graphique des ventes Q1 2026.',
},
],
}Claude peut analyser l'image si le modèle utilisé est multimodal.
Référencer une resource exposée par le même serveur :
return {
content: [
{
type: 'resource',
resource: {
uri: 'db://users/42',
mimeType: 'application/json',
text: JSON.stringify(user),
},
},
],
}Pour des opérations qui prennent du temps, envoyer des notifications de progression :
handler: async (args, extra) => {
// extra.signal — AbortSignal pour annulation
// Envoyer des logs de progression via le serveur
await server.sendLoggingMessage({ level: 'info', data: 'Démarrage du build...' })
const result = await longRunningBuild(args.branch, { signal: extra?.signal })
await server.sendLoggingMessage({ level: 'info', data: 'Build terminé.' })
return {
content: [{ type: 'text', text: `Build ${result.id} terminé en ${result.duration}s` }],
}
},Zod gère toute la complexité de schéma :
const DeploySchema = z.object({
service: z.string(),
environment: z.enum(['dev', 'staging', 'production']),
config: z.object({
replicas: z.number().int().min(1).max(10).default(2),
memory: z.string().regex(/^\d+[MG]$/, 'Format: 512M ou 2G').default('512M'),
envVars: z.record(z.string()).optional(),
}).default({}),
dryRun: z.boolean().default(false),
})
// inputSchema correspondant (pour la découverte MCP)
inputSchema: {
type: 'object',
properties: {
service: { type: 'string' },
environment: { type: 'string', enum: ['dev', 'staging', 'production'] },
config: {
type: 'object',
properties: {
replicas: { type: 'number', default: 2 },
memory: { type: 'string', default: '512M' },
envVars: { type: 'object', additionalProperties: { type: 'string' } },
},
},
dryRun: { type: 'boolean', default: false },
},
required: ['service', 'environment'],
}Avant de connecter Claude, tester les handlers directement :
// src/tools/weather.test.ts
import { weatherTool } from './weather.js'
// Test basique
const result = await weatherTool.handler({ city: 'Paris', unit: 'celsius' })
console.log(result)
// Test erreur
const errorResult = await weatherTool.handler({ city: '' })
console.log(errorResult)npx tsx src/tools/weather.test.tsPas besoin de lancer le serveur complet pour tester un handler.
Les tools sont robustes et bien structurés. Le chapitre suivant couvre les resources — le moyen d'exposer du contenu que Claude peut lire à sa demande, plutôt que d'attendre un appel de tool.