Intelligence Artificielle angularforall.com

- MCP : connecter Claude à vos outils en TypeScript

Mcp Model-Context-Protocol Claude-Desktop Anthropic Typescript Sdk Claude-Code Agents-Ia Tool-Use Node-Js Stdio Sse Ai-Protocol Integration-Ia
MCP : connecter Claude à vos outils en TypeScript

Construisez un serveur Model Context Protocol en TypeScript : tools, resources, prompts, transports stdio/SSE et bonnes pratiques pour la production.

Pourquoi MCP existe

Avant MCP, chaque intégration entre un LLM et un service externe était ad-hoc : un script custom pour brancher Claude sur Postgres, un autre pour Notion, un autre pour Jira. Le code n'était pas partageable, chaque client (Claude Desktop, agent, IDE) devait réinventer la roue.

Model Context Protocol, publié par Anthropic en open source fin 2024, joue pour les LLM le rôle qu'a joué Language Server Protocol pour les éditeurs : un protocole standard sur lequel tous les acteurs peuvent s'aligner. Vous écrivez une fois un serveur MCP qui expose vos tools/resources/prompts, et il fonctionne avec Claude Desktop, Claude Code, Cursor, Continue.dev, et tous les clients à venir.

Les trois primitives MCP

Primitive Rôle Exemple concret Contrôlé par
tools Fonctions appelables create_issue, run_sql_query Modèle (Claude décide d'appeler)
resources Données lisibles (URI) file:///docs/api.md, db://users/42 Application/utilisateur (sélection)
prompts Templates de prompts code-review, summarize-pr Utilisateur (slash commands)
Métaphore utile : tools = endpoints REST, resources = fichiers / GET, prompts = snippets sauvegardés. MCP normalise leur exposition et leur découverte sur un transport simple (stdio ou HTTP).

Architecture client / serveur MCP

MCP suit une architecture client / serveur classique avec trois entités : un host (Claude Desktop, IDE, agent), un client intégré au host, et un ou plusieurs serveurs que vous écrivez. La communication passe par JSON-RPC 2.0 sur un transport — généralement stdio en local, ou HTTP/SSE en remote.

Flux d'un appel typique

# Schéma textuel d'un échange MCP
[Claude Desktop]                        [Serveur MCP]
       │                                       │
       │ ─── initialize (handshake) ─────────► │
       │ ◄── server capabilities ───────────── │
       │                                       │
       │ ─── tools/list ──────────────────────► │
       │ ◄── { tools: [create_issue, ...] } ── │
       │                                       │
       │ User: "crée un ticket bug login"      │
       │ Claude analyse → décide tool call     │
       │ ─── tools/call create_issue ────────► │
       │                                       │ → exécute logique
       │ ◄── { content: [...], isError: false }│
       │                                       │
       │ Claude formule la réponse finale      │
JSON-RPC 2.0 : protocole simple — chaque message porte jsonrpc:"2.0", id, method, params. Pas de types compliqués, pas de WebSocket obligatoire. Le SDK gère tout, vous écrivez juste vos fonctions métier.

SDK TypeScript : premier serveur

Le SDK officiel @modelcontextprotocol/sdk couvre client et serveur. On part d'un projet vide et on construit un serveur minimal qui expose un seul tool — un calculateur de TVA pour démarrer.

Prérequis : Node.js 18+ (ESM natif), TypeScript 5+, un client MCP installé (Claude Desktop ≥ 0.7 ou npx @modelcontextprotocol/inspector). Un projet npm vide suffit — pas besoin de framework.

Installation et structure projet

# Création du projet
mkdir mcp-tva && cd mcp-tva
npm init -y

# Dépendances
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node

# Config TypeScript stricte
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext

# Permettre l'exécution directe
echo '#!/usr/bin/env node' > src/server.ts

Squelette du serveur

// src/server.ts — serveur MCP minimal
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';

// 1. Création de l'instance serveur avec metadata
const server = new Server(
  { name: 'mcp-tva', version: '1.0.0' },
  { capabilities: { tools: {} } }, // on déclare ce qu'on supporte
);

// 2. Schéma Zod pour valider les arguments du tool
const TvaArgs = z.object({
  amount: z.number().positive().describe('Montant HT en euros'),
  rate:   z.number().min(0).max(100).default(20).describe('Taux TVA en %'),
});

// 3. Handler : Claude demande la liste des tools disponibles
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'compute_tva',
      description: 'Calcule la TVA et le montant TTC à partir d\'un HT.',
      inputSchema: {
        type: 'object',
        properties: {
          amount: { type: 'number', description: 'Montant HT en euros' },
          rate:   { type: 'number', description: 'Taux TVA % (défaut 20)' },
        },
        required: ['amount'],
      },
    },
  ],
}));

// 4. Handler : Claude appelle le tool
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name !== 'compute_tva') {
    throw new Error(`Tool inconnu : ${request.params.name}`);
  }

  // Validation stricte (sinon Claude peut envoyer n'importe quoi)
  const { amount, rate } = TvaArgs.parse(request.params.arguments);
  const tva = +(amount * rate / 100).toFixed(2);
  const ttc = +(amount + tva).toFixed(2);

  // Format de retour MCP : array de "content blocks"
  return {
    content: [{
      type: 'text',
      text: `HT: ${amount}€ | TVA (${rate}%): ${tva}€ | TTC: ${ttc}€`,
    }],
  };
});

// 5. Démarrage : on parle stdio (process.stdin / process.stdout)
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[mcp-tva] serveur démarré'); // stderr — pas stdout (réservé JSON-RPC)
Piège classique : en transport stdio, n'écrivez jamais sur stdout autre chose que des messages JSON-RPC. Tout console.log casse le protocole. Utilisez console.error pour vos logs (qui va sur stderr).

Tools : exposer des fonctions

Les tools sont la primitive la plus puissante : Claude décide tout seul de les appeler en fonction du contexte. Quelques règles font la différence entre un tool ignoré et un tool utilisé naturellement par Claude.

Tool métier : recherche dans une base de tickets

// src/tools/search-tickets.ts — exemple production
import { z } from 'zod';
import { db } from '../db.js';

// Schéma d'entrée — chaque description est une instruction pour Claude
export const searchTicketsSchema = {
  name: 'search_tickets',
  description: [
    'Recherche des tickets de support par mots-clés et statut.',
    'Utilise ce tool quand l\'utilisateur demande des tickets,',
    'des bugs en cours, ou veut filtrer par projet.',
  ].join(' '),
  inputSchema: {
    type: 'object',
    properties: {
      query: { type: 'string', description: 'Mots-clés de recherche full-text' },
      status: {
        type: 'string',
        enum: ['open', 'in_progress', 'closed'],
        description: 'Filtre statut (par défaut tous statuts ouverts)',
      },
      limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
    },
    required: ['query'],
  },
};

const ArgsSchema = z.object({
  query: z.string().min(1),
  status: z.enum(['open', 'in_progress', 'closed']).optional(),
  limit: z.number().int().min(1).max(50).default(10),
});

export async function searchTickets(rawArgs: unknown) {
  // Validation stricte avant tout accès DB
  const { query, status, limit } = ArgsSchema.parse(rawArgs);

  // Requête paramétrée (jamais de concat de strings)
  const rows = await db.query(
    `SELECT id, title, status, created_at
       FROM tickets
      WHERE search_vector @@ plainto_tsquery('french', $1)
        ${status ? 'AND status = $2' : ''}
      ORDER BY created_at DESC
      LIMIT ${status ? '$3' : '$2'}`,
    status ? [query, status, limit] : [query, limit],
  );

  // Réponse MCP : on renvoie du texte structuré que Claude peut citer
  return {
    content: [{
      type: 'text',
      text: rows.length === 0
        ? `Aucun ticket trouvé pour "${query}".`
        : rows.map(r => `#${r.id} [${r.status}] ${r.title}`).join('\n'),
    }],
  };
}

Bonnes pratiques pour les descriptions

  • Verbes d'action explicites : "Recherche", "Crée", "Calcule" — pas "Permet de…"
  • Cas d'usage en exemple : "Utilise quand l'utilisateur demande X" — guide Claude
  • Limites mentionnées : "Retourne max 10 résultats", "lecture seule"
  • Description sur chaque champ : Claude lit aussi les descriptions des paramètres

Resources : exposer des données

Là où les tools sont activés par le modèle, les resources sont sélectionnées par l'utilisateur ou l'application. Elles sont identifiées par une URI (custom scheme libre) et peuvent être listées ou lues. Cas typiques : fichiers d'un projet, documents Notion, fiches CRM.

Exposer des fiches produit comme resources

// src/resources.ts — fiches produit en lecture seule
import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { db } from './db.js';

// Liste : chaque resource a une URI unique
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  const products = await db.query('SELECT id, name FROM products WHERE published');

  return {
    resources: products.map(p => ({
      uri: `product://${p.id}`,                  // URI custom — libre
      name: p.name,                              // affiché dans Claude Desktop
      description: `Fiche complète de ${p.name}`,
      mimeType: 'application/json',              // Claude saura comment l'interpréter
    })),
  };
});

// Lecture : Claude Desktop demande le contenu d'une URI sélectionnée
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const uri = request.params.uri;
  const match = uri.match(/^product:\/\/(\d+)$/);
  if (!match) throw new Error(`URI non gérée : ${uri}`);

  const id = parseInt(match[1], 10);
  const product = await db.queryOne('SELECT * FROM products WHERE id=$1', [id]);
  if (!product) throw new Error(`Produit ${id} introuvable`);

  return {
    contents: [{
      uri,
      mimeType: 'application/json',
      text: JSON.stringify(product, null, 2),
    }],
  };
});
Diff tools vs resources : un tool get_product(id) est appelé automatiquement par Claude. Une resource product://42 est ajoutée manuellement par l'utilisateur dans le contexte de la conversation (drag & drop dans Claude Desktop). Choisissez selon qui doit décider.

Prompts : templates réutilisables

Les prompts MCP sont des slash commands que l'utilisateur invoque depuis le client. Ils prennent éventuellement des arguments et retournent une séquence de messages à injecter dans la conversation. Idéal pour standardiser des workflows : code review, génération de tests, audit sécurité.

Prompt "code review" avec arguments

// src/prompts.ts — slash command /code-review
import {
  ListPromptsRequestSchema,
  GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { readFile } from 'node:fs/promises';

server.setRequestHandler(ListPromptsRequestSchema, async () => ({
  prompts: [
    {
      name: 'code-review',
      description: 'Lance une revue de code complète sur un fichier donné.',
      arguments: [
        { name: 'path', description: 'Chemin du fichier à reviewer', required: true },
        { name: 'focus', description: 'Aspect prioritaire (perf, secu, lisibilite)', required: false },
      ],
    },
  ],
}));

server.setRequestHandler(GetPromptRequestSchema, async (request) => {
  if (request.params.name !== 'code-review') throw new Error('Inconnu');

  const path  = request.params.arguments?.path;
  const focus = request.params.arguments?.focus ?? 'lisibilite';
  const code  = await readFile(path, 'utf-8');

  // On retourne une séquence de messages prêts à être envoyés à Claude
  return {
    description: `Code review de ${path}`,
    messages: [
      {
        role: 'user',
        content: {
          type: 'text',
          text: [
            `Tu es un reviewer senior. Focus principal : ${focus}.`,
            `Voici le fichier ${path} :`,
            '```',
            code,
            '```',
            '',
            'Donne :',
            '1. Un résumé en 3 points',
            '2. Les bugs potentiels (avec ligne)',
            '3. Les améliorations rangées par impact',
          ].join('\n'),
        },
      },
    ],
  };
});
UX dans Claude Desktop : les prompts apparaissent dans le menu Add from MCP. L'utilisateur tape /code-review path=./src/auth.ts et Claude reçoit le message construit. Aucun copier-coller, aucun template à mémoriser.

Connecter à Claude Desktop

Une fois votre serveur prêt, on l'enregistre dans la config Claude Desktop. Le client lance automatiquement votre process en stdio à chaque démarrage.

Fichier de configuration

// macOS : ~/Library/Application Support/Claude/claude_desktop_config.json
// Windows : %APPDATA%\Claude\claude_desktop_config.json
{
  "mcpServers": {
    "tva-calculator": {
      "command": "node",
      "args": ["/Users/votre-user/projects/mcp-tva/dist/server.js"],
      "env": {
        "DB_URL": "postgres://localhost/myapp",
        "LOG_LEVEL": "info"
      }
    },
    "support-tickets": {
      "command": "npx",
      "args": ["-y", "tsx", "/path/to/mcp-tickets/src/server.ts"]
    }
  }
}

Vérification rapide

# 1. Build
npm run build

# 2. Test du serveur en dehors de Claude (debug pratique)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/server.js

# 3. Inspecter avec l'outil officiel
npx @modelcontextprotocol/inspector node dist/server.js
# → ouvre une UI web pour tester chaque tool / resource / prompt

# 4. Restart Claude Desktop pour recharger la config
Inspector = votre meilleur ami : avant de connecter à Claude Desktop, validez tout via @modelcontextprotocol/inspector. UI web qui liste tools/resources/prompts et permet de les déclencher manuellement avec arguments arbitraires. Indispensable pour itérer.

Transport HTTP / SSE pour le cloud

Le transport stdio est parfait en local mais ne passe pas à l'échelle pour un service multi-utilisateur. MCP supporte aussi un transport HTTP avec SSE pour les events serveur, ce qui permet d'héberger un serveur MCP partagé (Cloudflare Workers, Lambda, conteneur).

Serveur HTTP avec SSE

// src/server-http.ts — serveur MCP exposé en HTTP/SSE
import express from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';

const app = express();
app.use(express.json());

const mcpServer = new Server(
  { name: 'mcp-cloud', version: '1.0.0' },
  { capabilities: { tools: {}, resources: {}, prompts: {} } },
);
// ... handlers tools/resources/prompts (cf. sections précédentes)

// Map des transports actifs : un par session/connexion SSE
// Indispensable pour router les POST entrants vers la bonne session
const transports = new Map<string, SSEServerTransport>();

// 1. Endpoint SSE : Claude Desktop / agents s'y connectent et restent ouverts
app.get('/mcp/sse', async (req, res) => {
  // Auth Bearer — refus immédiat si token absent ou invalide
  const auth = req.headers.authorization?.replace('Bearer ', '');
  if (!auth || !verifyToken(auth)) {
    return res.status(401).end();
  }

  // Le SDK génère un sessionId unique exposé via transport.sessionId
  const transport = new SSEServerTransport('/mcp/messages', res);
  transports.set(transport.sessionId, transport);

  // Nettoyage à la déconnexion (sinon fuite mémoire)
  res.on('close', () => transports.delete(transport.sessionId));

  await mcpServer.connect(transport);
});

// 2. Endpoint POST : messages JSON-RPC entrants, routés par sessionId
app.post('/mcp/messages', async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);
  if (!transport) {
    return res.status(404).json({ error: 'Session inconnue ou expirée' });
  }
  await transport.handlePostMessage(req, res);
});

app.listen(4000, () => console.error('MCP HTTP on :4000'));

Comparatif des transports

Transport Cas d'usage Auth Multi-utilisateur
stdio Outils locaux (FS, git, DB locale) Aucune (process local) Non — un process par client
SSE Service cloud, équipe Bearer token / OAuth Oui — sessions HTTP
WebSocket (expérimental) Latence faible, full-duplex Sub-protocol Oui

Sécurité, scopes et bonnes pratiques

Un serveur MCP est un point d'entrée vers vos systèmes. Le LLM n'est pas votre périmètre de sécurité — votre serveur l'est.

Pattern de validation Zod systématique

// src/middleware/validate.ts — wrap chaque handler
import { ZodSchema } from 'zod';

export function validated<T>(
  schema: ZodSchema<T>,
  handler: (args: T) => Promise<unknown>,
) {
  return async (rawArgs: unknown) => {
    const result = schema.safeParse(rawArgs);
    if (!result.success) {
      // Format MCP d'erreur : on retourne content + isError
      return {
        content: [{
          type: 'text',
          text: `Arguments invalides : ${result.error.message}`,
        }],
        isError: true,
      };
    }
    return handler(result.data);
  };
}

// Usage
const handleSqlQuery = validated(
  z.object({ sql: z.string().regex(/^\s*SELECT/i, 'Lecture seule uniquement') }),
  async ({ sql }) => {
    const rows = await db.query(sql);
    return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
  },
);
Checklist serveur MCP en production
  • Schéma Zod (ou JSON Schema strict) sur chaque tool/prompt
  • Lecture seule par défaut — opérations destructives marquées explicitement
  • Requêtes DB toujours paramétrées (jamais de concat string)
  • Auth Bearer / OAuth sur transport HTTP
  • Rate limiting par client (10-100 req/min selon contexte)
  • Timeout strict sur chaque tool (30s max)
  • Logs JSON structurés : tool, args, durée, success
  • Audit trail : qui a appelé quoi, quand
  • Tests : @modelcontextprotocol/inspector en CI sur les tools critiques
  • Versioning sémantique du serveur (capabilities négociées)
Confirmations côté client : Claude Desktop affiche par défaut un dialogue d'autorisation sur chaque tool call. Pour un tool destructif (suppression, paiement), assurez-vous que la description commence par "⚠ Destructif :" — l'utilisateur lit cette ligne avant d'autoriser.

Conclusion

MCP transforme l'intégration LLM ↔ outils d'un patchwork artisanal en un protocole standard, partagé et versionné. Avec quelques heures de TypeScript, vous donnez à Claude (Desktop ou Code) une porte d'entrée propre vers votre base de données, votre CRM, votre backend métier — sans réécrire la mécanique d'intégration à chaque nouveau client.

Trois leviers pour un serveur de qualité : tools auto-descriptifs (le modèle ne lit que vos descriptions), validation Zod systématique (le LLM est créatif avec les arguments), et transports adaptés au contexte (stdio en local, SSE pour le cloud). Le reste est de l'architecture standard : sécurité, observabilité, tests.

Pour aller plus loin : explorez les serveurs officiels (@modelcontextprotocol/server-filesystem, -postgres, -github) pour voir des patterns avancés, et publiez le vôtre sur npm pour que toute votre équipe en bénéficie en une commande.

Pour aller plus loin sur AngularForAll
  • Tool use natif : si vous n'avez besoin que de fonctions ad-hoc dans un seul appel API, le tool use de l'API Claude reste plus léger que MCP
  • Embeddings & RAG : pour donner du contexte documentaire à un tool MCP, couplez-le à pgvector + OpenAI (cf. nos articles IA)
  • Agents autonomes : un serveur MCP partagé permet à plusieurs agents (Claude Code, Cursor, n8n) de réutiliser les mêmes tools sans duplication
  • Observabilité : exposez les métriques (durée, erreurs par tool) via Prometheus pour piloter en production

Partager