Skip to content

MCP Server Patterns — Design, Auth, State, Transport, Deploy

TL;DR — Un serveur MCP n'est pas un "wrapper API". C'est un contrat sémantique entre un LLM et un domaine métier. Trois primitives : resources (lecture orientée contexte, indexable), tools (actions à effet de bord, signature stricte), prompts (templates parametrables côté serveur). Stateless par défaut, stateful seulement quand le coût de re-créer le contexte excède la complexité. Transport stdio pour desktop, Streamable HTTP pour prod. Auth OAuth 2.1 + PKCE pour user-facing, API keys pour service-to-service. Observabilité OTel + audit log signé pour les domaines régulés (banque, santé, legal FR). En 2026, le marketplace MCP (Anthropic registry, Smithery) commence à ressembler à npm — il faut savoir builder pour soi, et publier propre.


🧠 Mental model

            ┌────────────────────────────────────────────┐
            │           HOST (Claude Desktop,            │
            │         Claude Code, Cursor, n8n)          │
            └───────────────────┬────────────────────────┘

                  ┌─────────────┴───────────────┐
                  │      MCP CLIENT (1/server)  │
                  └─────────────┬───────────────┘

        stdio │ Streamable HTTP │ SSE (legacy) │ WS

        ┌───────────────────────┴────────────────────────┐
        │              MCP SERVER                        │
        │                                                │
        │  ┌─────────────┐ ┌────────────┐ ┌───────────┐ │
        │  │ resources   │ │   tools    │ │  prompts  │ │
        │  │ (read-only) │ │ (side-eff) │ │ (templates│ │
        │  │ URI-based   │ │ JSON Schema│ │  params)  │ │
        │  └─────────────┘ └────────────┘ └───────────┘ │
        │                                                │
        │  Auth │ State │ Audit │ Rate-limit │ OTel      │
        └───────────────────────┬────────────────────────┘

              ┌─────────────────┴──────────────────┐
              │       Backends métier              │
              │  Postgres, Pennylane API, Salesforce
              │  GitHub, S3, vector DB, ...        │
              └────────────────────────────────────┘

Analogie : MCP est l'USB-C des LLMs. Le host est l'ordinateur, le serveur MCP est le périphérique. Resources = un disque dur (lecture passive), tools = une imprimante (action qui modifie le monde), prompts = un mode "scanner OCR" préconfiguré. Le protocole gère le handshake, la découverte, le streaming. Toi tu écris le firmware du périphérique.

Autre analogie pour PHP/TS dev : MCP est à OpenAPI ce que GraphQL est à REST. OpenAPI décrit des endpoints, MCP décrit des intentions exposées à une IA. Tu ne mappes pas 1:1 ton CRUD ; tu sculptes ce qui est utile au LLM.


🛠️ Code minimal

Serveur MCP TypeScript en HTTP streamable, expose une resource + un tool. Le tout en 40 lignes.

typescript
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";

const server = new McpServer({ name: "demo", version: "1.0.0" });

// resource: lecture contextuelle, idempotente, indexable
server.resource(
  "invoice",
  "invoice://{id}",
  async (uri, { id }) => ({
    contents: [{ uri: uri.href, mimeType: "application/json",
                 text: JSON.stringify(await db.invoice(id)) }],
  })
);

// tool: action avec effet de bord
server.tool(
  "mark_invoice_paid",
  { id: z.string(), paidAt: z.string().datetime() },
  async ({ id, paidAt }, ctx) => {
    await audit.log(ctx.user, "mark_paid", { id, paidAt });
    await db.markPaid(id, paidAt);
    return { content: [{ type: "text", text: `OK invoice ${id} paid` }] };
  }
);

const app = express();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
await server.connect(transport);
app.all("/mcp", (req, res) => transport.handleRequest(req, res, req.body));
app.listen(3333);

Trois choses non-négociables même en POC : (1) le schéma Zod sur les inputs des tools — sans ça le LLM hallucine les types, (2) un audit log à chaque tool call avec l'identité utilisateur, (3) un sessionIdGenerator si tu fais du HTTP — sinon multi-tenants compromis.


🎬 Cas d'usage concrets

Scénario 1 — SDR Tech Paris, CRM Salesforce maison

Contexte : startup SaaS B2B à Paris (50 collaborateurs, 8 SDR), CRM Salesforce avec champs custom (segment ICP, score MEDDIC, dernière interaction). Le head of sales veut que chaque SDR puisse parler à Claude Desktop : "montre-moi mes leads inactifs depuis 14 jours en segment Mid-Market, et propose un séquençage de relance".

Solution MCP :

  • resources : salesforce://lead/{id}, salesforce://opportunity/{id} — lecture context-rich (les champs MEDDIC sont préformatés en markdown pour que le LLM les comprenne).
  • tools : create_task, log_call, update_stage — actions auditées.
  • prompts : meddic_qualify(lead_id) — template figé côté serveur pour ne pas laisser le LLM "improviser" le cadre MEDDIC.
  • Auth : OAuth via le tenant Salesforce du SDR (chaque SDR ne voit que ses leads).
  • Déploiement : Cloud Run + IAP, transport Streamable HTTP.

Résultat : les SDR génèrent 3× plus d'emails personnalisés par jour (mesuré sur 6 semaines), avec un taux de réponse +18%. ROI sur la mission : 6 semaines de freelance à 1300€/j = ~39k€, payés en 4 mois par l'augmentation pipeline.

Scénario 2 — Cabinet d'expertise comptable Lyon, intégration Pennylane

Contexte : cabinet comptable 12 EC, 350 clients PME, utilise Pennylane. Les EC perdent du temps sur les questions répétitives clients : "où en est ma TVA ?", "quelles charges manquantes ce mois ?". Le DG veut un assistant interne (pas client-facing, le cabinet reste responsable).

Solution MCP : serveur MCP exposant Pennylane à Claude Desktop pour les EC. Lecture seule sur les premières semaines (résistance au risque), puis écriture progressive (catégorisation auto, lettrage).

C'est l'end-to-end ci-dessous.

Contexte : cabinet d'avocats parisien Big-4 français, DMS iManage avec 8M+ documents. Les avocats veulent que Claude puisse "voir" le dossier client courant pendant qu'ils l'utilisent (sans uploader les docs à un tiers — RGPD + secret professionnel).

Solution MCP : serveur MCP on-prem qui expose les documents par URI (imanage://matter/{id}/doc/{docid}) avec auth SSO Azure AD. Les resources retournent du markdown (conversion via Pandoc côté serveur), pas le PDF brut, pour minimiser la fuite. Tous les accès sont loggés vers Splunk pour le RSSI.

Spécificité FR : la convention collective des cabinets d'avocats + la déontologie du Conseil National des Barreaux exigent un audit traçable. Tout tool call est signé HMAC et stocké 5 ans.


🛠️ Exemple end-to-end — MCP "Pennylane Bridge" pour comptable

Objectif : serveur MCP TypeScript déployé sur Cloud Run, exposant les factures et écritures Pennylane à Claude Desktop. Auth OAuth (chaque EC voit ses dossiers), audit log signé, observabilité OTel.

typescript
// pennylane-mcp/src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express, { Request, Response } from "express";
import { z } from "zod";
import { trace, context, SpanStatusCode } from "@opentelemetry/api";
import { PennylaneClient } from "./pennylane.js";
import { AuditLogger } from "./audit.js";
import { requireOAuth } from "./auth.js";
import crypto from "node:crypto";

const tracer = trace.getTracer("pennylane-mcp");
const audit = new AuditLogger({ bucket: "cabinet-audit-2026", signKey: process.env.AUDIT_HMAC_KEY! });

// ---------- session-scoped factory: 1 client Pennylane par session OAuth ----------
function buildServer(token: string, ecEmail: string): McpServer {
  const pn = new PennylaneClient({ accessToken: token });
  const server = new McpServer({ name: "pennylane-bridge", version: "1.4.0" });

  // ---------- RESOURCES ----------
  // Une facture, formattée pour le LLM (pas le JSON brut Pennylane)
  server.resource(
    "invoice",
    "pennylane://invoice/{id}",
    { description: "Facture client ou fournisseur. Renvoie un markdown lisible LLM." },
    async (uri, { id }) => {
      return tracer.startActiveSpan("resource.invoice", async (span) => {
        try {
          span.setAttribute("invoice.id", String(id));
          span.setAttribute("ec.email", ecEmail);
          const inv = await pn.getInvoice(String(id));
          const md = renderInvoiceMarkdown(inv);
          await audit.log({ user: ecEmail, action: "read_invoice", resource: id });
          return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: md }] };
        } catch (e: any) {
          span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
          throw e;
        } finally {
          span.end();
        }
      });
    }
  );

  // Liste des factures impayées d'un client (resource avec filtre via URI params)
  server.resource(
    "unpaid_invoices",
    "pennylane://customer/{customerId}/unpaid",
    async (uri, { customerId }) => {
      const list = await pn.listInvoices({ customerId: String(customerId), status: "unpaid" });
      const md = list.map(renderInvoiceShort).join("\n\n---\n\n") || "_Aucune facture impayée_";
      return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: md }] };
    }
  );

  // ---------- TOOLS ----------
  // Catégoriser une écriture (action: modifie l'état, audit obligatoire)
  server.tool(
    "categorize_entry",
    "Catégorise une écriture comptable non-classée. À utiliser uniquement après lecture de l'écriture.",
    {
      entryId: z.string().describe("ID Pennylane de l'écriture"),
      accountNumber: z.string().regex(/^\d{3,8}$/).describe("Numéro de compte PCG"),
      label: z.string().min(3).max(200).describe("Libellé de l'écriture"),
      vatRate: z.enum(["0", "5.5", "10", "20"]).describe("Taux TVA"),
    },
    async ({ entryId, accountNumber, label, vatRate }) => {
      return tracer.startActiveSpan("tool.categorize_entry", async (span) => {
        span.setAttribute("entry.id", entryId);
        // Garde-fou: refuse de catégoriser sur des comptes sensibles sans confirmation 4-eyes
        if (["108", "109", "129"].some(p => accountNumber.startsWith(p))) {
          return {
            content: [{ type: "text",
              text: "Refusé: compte de capitaux. Demande validation à l'EC senior d'abord." }],
            isError: true,
          };
        }
        const sig = crypto.createHmac("sha256", process.env.AUDIT_HMAC_KEY!)
          .update(JSON.stringify({ entryId, accountNumber, ecEmail, ts: Date.now() }))
          .digest("hex");
        await audit.log({
          user: ecEmail, action: "categorize_entry",
          resource: entryId, payload: { accountNumber, label, vatRate },
          signature: sig,
        });
        await pn.updateEntry(entryId, { accountNumber, label, vatRate });
        span.end();
        return { content: [{ type: "text",
          text: `OK écriture ${entryId} classée en ${accountNumber} (${label}, TVA ${vatRate}%). Audit: ${sig.slice(0, 8)}` }] };
      });
    }
  );

  // Marquer une facture payée + générer la pièce comptable
  server.tool(
    "mark_invoice_paid",
    {
      invoiceId: z.string(),
      paidAt: z.string().datetime(),
      paymentMethod: z.enum(["sepa", "card", "check", "cash", "transfer"]),
      bankAccount: z.string().regex(/^512\d+$/).describe("Compte 512xxx"),
    },
    async (args) => {
      await audit.log({ user: ecEmail, action: "mark_paid", resource: args.invoiceId, payload: args });
      await pn.markPaid(args);
      return { content: [{ type: "text", text: `Facture ${args.invoiceId} payée le ${args.paidAt}` }] };
    }
  );

  // ---------- PROMPTS ----------
  // Template figé pour la revue mensuelle d'un dossier client
  server.prompt(
    "monthly_review",
    "Revue mensuelle d'un dossier client (TVA, charges, anomalies).",
    { customerId: z.string(), month: z.string().regex(/^\d{4}-\d{2}$/) },
    ({ customerId, month }) => ({
      messages: [{
        role: "user",
        content: { type: "text", text:
          `Tu es expert-comptable. Pour le dossier ${customerId}, mois ${month}:\n` +
          `1. Lis pennylane://customer/${customerId}/unpaid\n` +
          `2. Identifie 3 anomalies probables (charges manquantes, TVA bizarre, écritures non classées).\n` +
          `3. Propose 5 actions concrètes. N'utilise jamais categorize_entry sans avoir lu l'écriture.\n` +
          `Réponds en français, ton professionnel, max 400 mots.`,
        },
      }],
    })
  );

  return server;
}

function renderInvoiceMarkdown(inv: any): string {
  return `## Facture ${inv.number}\n\n` +
    `- **Client** : ${inv.customer.name} (SIREN ${inv.customer.siren ?? "n/a"})\n` +
    `- **Date** : ${inv.date} — **Échéance** : ${inv.dueDate}\n` +
    `- **HT** : ${inv.amountExclTax}€ — **TVA** : ${inv.vatAmount}€ — **TTC** : ${inv.amountInclTax}€\n` +
    `- **Statut** : ${inv.status}${inv.status === "overdue" ? " ⚠️" : ""}\n\n` +
    `### Lignes\n${inv.lines.map((l: any) => `- ${l.label}: ${l.amount}€ HT (TVA ${l.vat}%)`).join("\n")}`;
}
function renderInvoiceShort(inv: any): string {
  return `**${inv.number}** — ${inv.amountInclTax}€ TTC, échue le ${inv.dueDate} (${inv.daysOverdue}j de retard)`;
}

// ---------- HTTP layer with OAuth + per-session MCP server ----------
const app = express();
app.use(express.json({ limit: "1mb" }));

const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: McpServer }>();

app.all("/mcp", requireOAuth, async (req: Request, res: Response) => {
  const sessionId = (req.headers["mcp-session-id"] as string) || crypto.randomUUID();
  let entry = sessions.get(sessionId);
  if (!entry) {
    const server = buildServer(req.auth!.accessToken, req.auth!.email);
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId });
    await server.connect(transport);
    entry = { transport, server };
    sessions.set(sessionId, entry);
    // GC sessions après 30 min d'inactivité
    setTimeout(() => sessions.delete(sessionId), 30 * 60_000).unref();
  }
  res.setHeader("mcp-session-id", sessionId);
  await entry.transport.handleRequest(req, res, req.body);
});

app.get("/healthz", (_, res) => res.json({ ok: true, version: "1.4.0" }));
app.listen(Number(process.env.PORT ?? 8080),
  () => console.log("pennylane-mcp listening"));

Ce qu'il faut retenir de cet exemple :

  1. Une session = un client Pennylane = un EC. Sinon fuite multi-tenant. Le buildServer(token, ecEmail) capture l'identité dans une closure.
  2. Audit signé HMAC : la signature est retournée dans la réponse pour traçabilité côté Claude Desktop, et stockée dans S3 Object Lock (WORM 5 ans) côté serveur.
  3. Garde-fous métier dans le tool : refuser de classer en compte 108 (capital), c'est un guardrail impossible à exprimer en pur JSON Schema, donc dans le handler.
  4. Resources rendent du markdown, pas du JSON : le LLM consomme 30-40% moins de tokens et fait moins d'erreurs sur les libellés FR.
  5. Le prompt monthly_review est versionné côté serveur. Quand l'EC senior améliore la consigne MEDDIC ou la revue mensuelle, tous les juniors en bénéficient sans MAJ Claude Desktop.

🧩 Composition multi-MCP — détail pratique

En 2026 un poste de travail "augmenté" connecte 5 à 12 serveurs MCP simultanément. Cas typique d'un SDR :

Claude Desktop
   ├── salesforce-mcp        (lecture leads, écriture activités)
   ├── linkedin-search-mcp   (signaux d'achat, OSS Smithery)
   ├── gmail-mcp             (lecture/envoi conditionnel)
   ├── notion-mcp            (knowledge base interne)
   ├── slack-mcp             (notifs internes)
   ├── pennylane-mcp         (vérif solvabilité prospect FR)
   └── filesystem-mcp        (drafts locaux)

Risques opérationnels :

  1. Conflit de noms de tools. Deux serveurs exposant search → ambiguïté. Convention 2026 : préfixe domaine obligatoire dans le registry (salesforce_search_leads, linkedin_search_people). Vérifie via client.listTools() au démarrage et alerte si conflit.

  2. Token budget par serveur. Chaque serveur ajoute ~500-1500 tokens de descriptions de tools dans le contexte system. 10 serveurs = 8-15k tokens avant le 1er message. Filtre les tools réellement utilisés via allowed_tools côté host.

  3. Latence cumulée. Si un serveur down ralentit listTools au démarrage, tout traîne. Mets un timeout 3s sur chaque serveur lors du handshake et marque-le "degraded" sans bloquer les autres.

  4. OAuth scope confusion. Un user peut consentir à Gmail mais pas à Salesforce. Chaque MCP gère son flow indépendamment. Le host doit afficher l'état de connexion par serveur.

  5. Audit cross-MCP. Une "session SDR" = activités sur 5 systèmes. Pour l'audit RGPD, corrèle via un correlation_id propagé en header MCP custom (X-MCP-Correlation-Id). Tous les serveurs MCP que tu écris doivent logger ce correlation_id.

🔐 Auth en détail — OAuth 2.1 vs API keys vs mTLS

ScénarioRecoPourquoi
MCP user-facing (Claude Desktop)OAuth 2.1 + PKCELe user consent flow est standardisé, refresh-token OK
MCP service-to-service (n8n→MCP server)API key + scopePlus simple, rotable, pas de browser flow
MCP intra-corporate sensible (banque, santé)mTLS + OAuthCertificat client + token, defense in depth
MCP open public (registry)OAuth 2.1Standard requis par Anthropic Registry 2026

Implémentation OAuth 2.1 minimale côté MCP server (Express middleware) :

typescript
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URI!));

export async function requireOAuth(req, res, next) {
  const auth = req.headers.authorization;
  if (!auth?.startsWith("Bearer ")) return res.status(401).json({ error: "missing_token" });
  try {
    const { payload } = await jwtVerify(auth.slice(7), JWKS, {
      issuer: process.env.OIDC_ISSUER,
      audience: "pennylane-mcp",
    });
    if (!payload.scope?.toString().split(" ").includes("mcp:pennylane")) {
      return res.status(403).json({ error: "insufficient_scope" });
    }
    req.auth = { accessToken: auth.slice(7), email: payload.email as string,
                 scopes: payload.scope as string };
    next();
  } catch (e) {
    res.status(401).json({ error: "invalid_token", detail: String(e) });
  }
}

Points souvent oubliés en prod :

  • Token refresh côté Claude Desktop : si ton serveur retourne 401, Claude Desktop relance le flow OAuth automatiquement (depuis spec 2026-03). Ton serveur DOIT retourner 401 propre (pas 500).
  • Scope granularité : mcp:pennylane:read vs mcp:pennylane:write. Les RSSI exigent du moindre privilège.
  • JWKS caching : sans cache, chaque request hit l'IdP. jose cache par défaut 10 min, vérifie.

📦 Déploiement — comparatif des cibles

CibleCoût (mois, 50k requêtes)Cold startOAuth-friendlyStateful sessionsBon pour
Cloud Run (GCP)~15-30€1-3s✅ (memory)Default 2026 EU
AWS Lambda + ALB~20-40€0.5-2s❌ (use Redis/DDB)AWS-natifs
Cloudflare Workers~5-10€<50mspartiel (DO)Edge, low-latency
Fly.io~10-25€<1sEU-FR data residency
Container OVH~30-50€~5sSouveraineté FR
Scaleway~25-45€~3sSouveraineté FR/EU
K8s on-preminfra-dep<1sBanque, santé, legal

Recommandation 2026 :

  • POC + early prod : Cloud Run ou Fly.io.
  • Edge low-latency public : Cloudflare Workers (limite : 30s CPU max).
  • Souveraineté FR (CNIL, ACPR, secret pro) : Scaleway ou OVH ou on-prem.

Dockerfile multi-stage type pour un MCP Node :

dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json tsconfig.json ./
COPY src ./src
RUN npm ci && npm run build && npm prune --production

FROM node:22-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
USER node
ENV NODE_ENV=production PORT=8080
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]

🎯 Patterns courants

Pattern A — Resources granulaires + URI templates

Une seule resource générique data://... qui fait tout → catastrophique. Le LLM ne sait pas quand l'appeler. Préfère des URIs sémantiques (invoice://, lead://, matter://) et utilise les templates RFC 6570 (invoice://{id}). Le LLM apprend à les construire.

Pattern B — Tools idempotents quand possible

Un tool send_email non idempotent → le LLM peut envoyer 2× si timeout. Ajoute un idempotencyKey optionnel ; côté serveur, dédupe sur 24h. Même esprit qu'un Stripe API.

Pattern C — Stateless first, stateful sur preuves

Stateful (session-scoped resources qui changent) ajoute complexité énorme. Garde stateless tant que le LLM peut re-fetcher en <500ms. Stateful uniquement si : (a) coût compute énorme à recréer le contexte, (b) workflow multi-step avec dépendances strictes.

Pattern D — Composition multi-MCP

Claude Desktop peut connecter 5-10 serveurs MCP. Évite les conflits de noms (mark_paid dans 2 serveurs = ambiguïté). Préfixe par domaine : pennylane_mark_invoice_paid, salesforce_mark_opportunity_won. Anthropic Registry impose maintenant les préfixes.

Pattern E — Prompts côté serveur pour les workflows régulés

Si le métier exige une procédure stricte (KYC, MEDDIC, due diligence), encode-la en prompt MCP. Le user ne peut pas la déformer, et tu versionnes côté serveur.

Pattern F — Streaming partiel pour les long-running tools

Un tool qui prend 30s à exécuter doit streamer du progress via notifications/progress. Sinon le user voit "..." et croit que c'est mort. SDK TS : ctx.sendNotification({ progress: 0.4, total: 1 }).

Pattern G — Two-phase commit pour les actions risquées

Tool propose_payment retourne un proposalId + résumé. Tool confirm_payment(proposalId) exécute. Le LLM doit poser la question à l'user entre les deux. Standard en banque FR.

Pattern H — Schémas Zod stricts + descriptions verbeuses

Plus la description Zod est précise (en FR ou EN selon la base du LLM), moins le LLM se trompe sur les arguments. z.string().describe("Numéro SIRET 14 chiffres, ex: 12345678900012") >>> z.string(). Sur l'API Messages, ajoute strict: true sur la définition du tool : le moteur garantit que les input respectent le schéma (plus de accountNumber à 2 chiffres qui passe). Côté MCP, le serveur reste seul juge des invariants métier non-exprimables en JSON Schema (le refus compte 108, Pattern A de l'exemple end-to-end) — strict couvre la forme, pas la règle.

Pattern I — Structured outputs natifs plutôt que prompt XML/JSON à la main

Quand un tool ou un prompt doit renvoyer du structuré au host (pas au LLM), n'invente pas un protocole XML/JSON dans le prompt. Côté API, utilise client.messages.parse() avec un schéma Pydantic/zod (ou output_config.format) : la réponse est validée contre le schéma, tu récupères un objet typé, et tu évites la classe de bugs « le LLM a oublié une virgule dans son JSON ». Pour les resources MCP, c'est l'inverse : tu rends du markdown ciblé au LLM (Pitfall #1), pas du JSON — le structuré sert quand un humain ou un système consomme la sortie, le markdown quand c'est le LLM.


🔄 Versions & écosystème 2026

ComposantVersion mai 2026Notes
MCP spec2026-03-15Streamable HTTP est défaut. SSE en mode legacy. Auth OAuth 2.1 normalisée.
@modelcontextprotocol/sdk1.10.x (TS)Support natif Streamable HTTP, session management, structured outputs
mcp Python SDK1.6.xFastMCP intégré au SDK officiel ; mcp.run() avec transport configurable
Anthropic Registrybeta publicregistry.modelcontextprotocol.io, signé, versionné semver
SmitheryGAmarketplace + hosted MCP runners, ~5k servers indexés
Claude Desktop1.4+Auto-discovery MCP via registry, OAuth flow intégré
Claude CodenatifMCP server config dans .mcp.json du repo
Cursor0.50+support MCP HTTP + stdio

Migration SSE → Streamable HTTP : si ton serveur date de 2024, migre. SSE pose des problèmes de proxy/load balancer en prod (Cloud Run, Cloudflare). Streamable HTTP = un endpoint POST /mcp, gère session via header, plus simple à déployer.

Transports en pratique :

  • stdio : Claude Desktop local, dev, CLI tools. Pas de réseau.
  • Streamable HTTP : prod, multi-user, derrière load balancer.
  • SSE : legacy, à éviter pour les nouveaux serveurs.
  • WebSocket : pas standard MCP, mais possible via wrapper custom. Cas de niche (notifications temps réel poussées par le serveur).

⚠️ Pitfalls

  1. Exposer le JSON brut de l'API backend. Le LLM se noie dans 200 champs Pennylane/Salesforce. Préformatte en markdown ciblé. Économise 50%+ de tokens.
  2. Tools trop génériques (run_sql, execute). Donne au LLM des outils étroits avec préconditions claires, pas un couteau suisse. Ça améliore la qualité ET la sécurité.
  3. Pas de session isolation en HTTP. Si 2 users connectent ton MCP en même temps et tu partages une closure Pennylane, fuite garantie. Toujours buildServer(token, user).
  4. OAuth scope trop large. Ne demande pas read:all si tu n'utilises que read:invoices. Les RSSI te refuseront le déploiement.
  5. Audit log non-immutable. Stocker l'audit dans la même DB que les données métier = compromis. S3 Object Lock, GCS Bucket Lock, ou ELK avec writes-only ACL.
  6. Pas de rate-limit côté MCP. Un LLM en boucle peut taper 200 req/s. Mets une rate-limit par session + par tool. Token bucket simple.
  7. Pas de timeout sur les tools. Un tool qui prend 60s gèle le LLM. Force timeout à 20s, et fais du long-running via "soumets un job → poll status".
  8. Versioning des tools. Renommer mark_paid en mark_invoice_paid casse les conversations en cours. Préfère ajouter le nouveau, garder l'ancien deprecated 30 jours.
  9. Erreurs renvoyées en stack trace. Le LLM lit le message d'erreur. Renvoie un text court actionnable : "L'écriture n'existe pas ou est déjà classée. Vérifie l'ID." — pas TypeError: cannot read property 'x'.
  10. Tester uniquement via Claude Desktop. Inreproductible. Tu DOIS avoir un test client MCP scripté (@modelcontextprotocol/sdk/client) qui appelle tes resources/tools en CI.

💰 Pricing / ROI client

Mission type : MCP server "internal-tools bridge" pour ETI/scale-up FR.

ItemJoursTJMTotal
Discovery + design API MCP31200€3 600€
Implémentation serveur + tests81200€9 600€
OAuth + audit log + observabilité31300€3 900€
Déploiement Cloud Run/Lambda + IaC21200€2 400€
Doc + handover + 2 semaines support21200€2 400€
Total1821 900€

Cas concret cabinet comptable Lyon (2026) : MCP Pennylane livré en 19 jours. Gain mesuré : 1h45/semaine/EC sur les tâches répétitives × 12 EC × 47 semaines × 75€/h coût horaire EC = ~74k€/an d'économie. Payback : 4 mois. Renouvelable en run-mode 1j/mois à 1200€ → 14k€/an.

Anti-pattern pricing : facturer au "nb de tools exposés" — le client ne comprend pas la valeur. Facture outcome : "vos EC libèrent X heures/semaine, mesurable via les audit logs".

Range FR 2026 :

  • POC MCP (1-2 tools) : 5-8k€
  • MCP production (5-10 tools, auth, audit) : 18-30k€
  • MCP multi-tenant SaaS-grade : 40-80k€

🧪 Testing / Eval

Trois niveaux à tester :

typescript
// 1. Unit: tools en isolation
import { describe, it, expect } from "vitest";
import { buildServer } from "../src/server.js";
import { InMemoryClient } from "./helpers/in-memory-client.js";

describe("categorize_entry", () => {
  it("refuse les comptes 108/109/129", async () => {
    const { client } = await connect(buildServer("fake-token", "[email protected]"));
    const res = await client.callTool({
      name: "categorize_entry",
      arguments: { entryId: "e1", accountNumber: "108100", label: "x", vatRate: "0" },
    });
    expect(res.isError).toBe(true);
    expect(res.content[0].text).toContain("Refusé");
  });
});

// 2. Integration: scénarios end-to-end avec un vrai LLM
import Anthropic from "@anthropic-ai/sdk";
it("classe correctement une note de frais restaurant", async () => {
  const anthropic = new Anthropic();
  const tools = await client.listTools(); // tools exposés par TON serveur MCP
  // Appelle Claude avec les tools MCP, vérifie qu'il appelle categorize_entry avec compte 625100.
  // mapToAnthropicTools = mappe le JSON Schema MCP vers le format `tools` de l'API Messages
  // (name, description, input_schema). Le runtime MCP ↔ Claude n'est PAS automatique côté API :
  // c'est toi qui boucles sur stop_reason === "tool_use" → appelles le serveur MCP → renvoies le tool_result.
  const resp = await anthropic.messages.create({
    model: "claude-opus-4-8",          // flagship 2026 (5 $ / 25 $ /Mtok, contexte 1M)
    max_tokens: 1024,
    thinking: { type: "adaptive" },    // pas de budget_tokens sur 4.8 (HTTP 400) — adaptive uniquement
    output_config: { effort: "low" },  // tool selection ≠ besoin d'intelligence max ; low = moins de tokens/latence
    tools: mapToAnthropicTools(tools),
    messages: [{ role: "user", content: "Classe l'écriture e42: ticket restaurant 38€ TTC" }],
  });
  expect(resp.stop_reason).toBe("tool_use");
  const call = resp.content.find(c => c.type === "tool_use");
  expect(call.input.accountNumber).toMatch(/^625/);
});

// 3. Eval: dataset de scénarios + scoring
// Utilise Inspect AI ou Promptfoo MCP plugin (2026). Run en CI, alerte si score <85%.

Métriques senior à tracker :

  • Tool selection accuracy : sur 50 scénarios, le bon tool est choisi → cible >92%.
  • Argument correctness : les args sont bien typés → cible >95%.
  • Refusal correctness : les actions interdites sont refusées → cible 100%.
  • Latence p50 / p95 par tool : OTel + Grafana.
  • Audit completeness : 100% des tool calls écrits dans S3, vérifié par job nightly.

🔁 Quand utiliser / éviter

Utilise MCP quand :

  • Tu veux que plusieurs LLMs/hosts (Claude Desktop + Cursor + Claude Code + n8n) accèdent à la même intégration sans duplication.
  • Le domaine est régulé (audit + permissions structurées indispensables).
  • Tu construis une plateforme (équipes internes consomment tes intégrations).
  • Tu vises le marketplace (publish public, ROI marketing).

Évite MCP quand :

  • Tu as un seul agent, un seul host, un seul use case → fais juste des function calls maison, gain MCP nul.
  • L'intégration est éphémère (POC 2 semaines) — overhead de protocole inutile.
  • Le backend est temps-réel critique (<50ms p99) — MCP ajoute 20-50ms par hop.
  • Le besoin est purement RAG (juste injecter du texte dans le contexte) — un retriever direct est plus simple.

Hybride pragmatique : pour de l'agentique custom serveur-side, garde des function calls natifs (perf, debug). Pour exposer à des hosts externes (Claude Desktop, Cursor, n8n), enveloppe dans MCP. Ce sont deux interfaces de la même logique métier.


🔌 Comment le host branche MCP sur le LLM (le détail que tout le monde oublie)

MCP ne parle pas directement à Claude. Le serveur MCP expose des tools/resources ; c'est le host qui traduit ça vers l'API Messages d'Anthropic et qui boucle. Deux stratégies, à connaître pour ne pas se faire piéger en entretien :

1. Loop côté host (ce que fait Claude Desktop / ton code custom). Le host appelle client.listTools() sur chaque serveur MCP, mappe le JSON Schema vers le format tools de l'API, et tient la boucle agentique : stop_reason === "tool_use" → exécute le tool via le client MCP → renvoie le tool_result. L'API Anthropic ne sait rien de MCP — elle voit juste des function tools. C'est ce que montre le test d'intégration plus haut. Pattern de prod : AsyncAnthropic + asyncio.gather pour exécuter en parallèle les tool calls indépendants, max_retries + exceptions typées (RateLimitError, OverloadedError, APITimeoutError), et prompt caching via cache_control sur le préfixe stable (system + définitions de tools) — sinon tu re-paies les ~8-15k tokens de descriptions de tools à chaque tour de boucle.

2. MCP connector natif (Anthropic-managed). L'API Messages peut se connecter elle-même à un serveur MCP Streamable HTTP distant (mcp_servers dans la requête, ou les Managed Agents via vault pour l'auth) : Anthropic tient la boucle, route les tool calls, gère le refresh OAuth. Tu écris moins de plomberie, mais tu perds le hook host-side (gating, rendering, audit local). Pour un domaine régulé FR (le scénario cabinet/legal de ce doc), tu veux souvent la loop host-side justement pour intercepter chaque tool call et signer l'audit avant exécution.

Le réflexe staff : la décision « loop host-side vs connector natif » est la même que « bash tool vs dedicated tool » en agent design — tu promeus vers du host-side dès que tu as besoin de gater, signer, rendre ou auditer l'action. Sinon le natif est plus simple.

La loop host-side de prod, en vrai (ce que les snippets ci-dessus n'osent pas montrer). Une boucle agentique naïve for ... await te coûte cher : tool calls séquentiels, re-paiement du préfixe à chaque tour, crash au premier 429. Le squelette senior :

python
import asyncio
from anthropic import AsyncAnthropic
from anthropic import RateLimitError, OverloadedError, APITimeoutError, APIStatusError

anthropic = AsyncAnthropic(max_retries=4, timeout=30.0)  # backoff + jitter géré par le SDK

SYSTEM = [  # préfixe STABLE → cache_control sur le dernier bloc (system + tools cachés ensemble)
    {"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}},
]

async def agent_turn(messages, mcp_tools):
    resp = await anthropic.messages.create(
        model="claude-opus-4-8",
        max_tokens=4096,
        thinking={"type": "adaptive"},
        output_config={"effort": "high"},   # agentique → high/xhigh, pas low
        system=SYSTEM,
        tools=mcp_tools,                     # ordre DÉTERMINISTE, sinon cache invalidé
        messages=messages,
    )
    # 1. Observabilité coût : input/output/cache_read à CHAQUE tour
    u = resp.usage
    log.info("usage", input=u.input_tokens, cache_read=u.cache_read_input_tokens, output=u.output_tokens)
    assert u.cache_read_input_tokens or len(messages) <= 1, "cache froid → invalidateur silencieux"

    if resp.stop_reason != "tool_use":
        return resp

    # 2. Exécute en PARALLÈLE les tool calls indépendants (read-only surtout)
    calls = [b for b in resp.content if b.type == "tool_use"]
    results = await asyncio.gather(*(call_mcp_server(c) for c in calls))
    messages.append({"role": "assistant", "content": resp.content})
    messages.append({"role": "user", "content": results})  # tous les tool_result en UN message
    return None  # continue la boucle

Trois choses que cette boucle fait et qu'un junior oublie : (1) cache_control sur le préfixe stable + assertion cache_read != 0 — sans ça tu re-paies tes 8-15k tokens de descriptions à chaque tour ; (2) asyncio.gather pour les tool calls indépendants — sur une session SDR qui lit 4 leads d'un coup, c'est 4× moins de latence ; (3) max_retries + exceptions typées (RateLimitError, OverloadedError, APITimeoutError) plutôt qu'un try: ... except Exception. Attention au piège du cache : si tu fais de l'asyncio.gather sur plusieurs requêtes Messages au même préfixe (fan-out), la 1ʳᵉ écrit le cache et les N-1 autres paient plein tarif tant qu'elle n'a pas commencé à streamer — envoie 1 requête, attends le 1er token, puis fan-out le reste.

Coût & observabilité concrets. Logge resp.usage (input/output/cache_read) à chaque tour : sur Opus 4.8 (5 $/25 $ par Mtok), une session SDR de 30 tours avec 10 serveurs MCP non-cachés peut brûler 300k+ tokens d'input rien qu'en re-descriptions de tools. Le caching du préfixe stable divise ça par ~5-10. Trace tool_name, latency_ms, correlation_id en OTel ; alerte si un tool dépasse son timeout (Pattern, §⚠️ Pitfalls #7).


🏋️ Exercices

Progression : de « ça marche » à « défends le chiffre / casse-le puis répare ». Fais-les dans l'ordre.

Exercice 1 — Serveur MCP minimal testable en CI

Objectif : écrire un serveur MCP TS (1 resource invoice://{id} + 1 tool mark_invoice_paid) et un test client scripté (@modelcontextprotocol/sdk/client) qui l'appelle sans Claude Desktop. Indice/Solution : utilise InMemoryTransport (ou un transport stdio en sous-processus) pour connecter client↔serveur dans le même test. Assert sur client.listTools() (le tool existe, schéma correct) puis client.callTool({name, arguments}). Si tu testes uniquement via Claude Desktop, c'est irreproductible (Pitfall #10).

Exercice 2 — Garde-fou métier + idempotence

Objectif : ajouter à mark_invoice_paid (a) un refus 4-eyes sur les comptes de capitaux (préfixe 108/109/129), (b) un idempotencyKey qui dédupe sur 24h. Indice/Solution : le refus se fait dans le handler (renvoie isError: true + message court actionnable), pas dans le JSON Schema — c'est exprimable seulement en code. Pour l'idempotence : Map/Redis key → résultat avec TTL 24h ; si la clé existe, renvoie le résultat mémorisé sans ré-exécuter (même esprit que Stripe). Écris le test qui appelle 2× avec la même clé et vérifie qu'il n'y a qu'un db.markPaid.

Exercice 3 — Isolation multi-tenant sous charge

Objectif : prouver que deux EC connectés simultanément ne voient jamais les factures l'un de l'autre, puis casser l'isolation puis la réparer. Indice/Solution : casse-la en sortant le PennylaneClient de la closure buildServer(token, ecEmail) et en le mettant en singleton module-level. Écris un test qui ouvre 2 sessions HTTP en parallèle avec 2 tokens, fait 1 read chacun, et assert que chaque réponse ne contient que les factures du bon tenant. Avec le singleton le test doit échouer (fuite). Remets la closure → il passe. C'est le Pitfall #3, le bug le plus cher du fichier.

Exercice 4 — Two-phase commit pour action risquée

Objectif : transformer mark_invoice_paid (>10k€) en propose_paymentconfirm_payment(proposalId) avec confirmation utilisateur obligatoire entre les deux. Indice/Solution : propose_payment retourne un proposalId + résumé HMAC-signé, ne touche rien. confirm_payment vérifie la signature, le TTL (5 min), et exécute. Le LLM ne peut pas auto-confirmer car le proposalId n'est pas devinable. Standard banque FR (Pattern G). Bonus : ajoute un notifications/progress si l'exécution prend >2s.

Exercice 5 — Défends le ROI

Objectif : on te conteste les « ~74k€/an d'économie » du cabinet Lyon. Reconstruis le calcul, identifie l'hypothèse la plus fragile, et donne la fourchette basse défendable. Indice/Solution : 1h45/sem × 12 EC × 47 sem × 75€/h ≈ 74k€. L'hypothèse fragile : le 1h45/EC est-il mesuré (via audit logs : nb de tool calls × temps économisé par tâche) ou déclaré ? La défense staff : « le chiffre haut suppose 100% d'adoption ; la fourchette basse à 50% d'adoption sur les 8 premières semaines donne ~37k€, payback ~8 mois, toujours positif ». Facture l'outcome mesurable, pas le nb de tools (anti-pattern pricing).

Exercice 6 — Optimise le token budget multi-MCP

Objectif : un poste SDR connecte 10 serveurs MCP = 8-15k tokens de descriptions avant le 1er message, re-payés à chaque tour. Réduis le coût d'un ordre de grandeur. Indice/Solution : (1) cache_control sur le préfixe stable (system + tools) → cache reads à ~0.1× au lieu du plein tarif ; vérifie via usage.cache_read_input_tokens (≠ 0). (2) filtre les tools réellement utiles via allowed_tools côté host — ne charge pas les 60 tools si la conversation en touche 5. (3) à grande échelle, tool search (les schémas sont appended, pas swapped → le cache du préfixe survit). Mesure les 3 sur resp.usage et défends le gain en € sur une session de 30 tours Opus 4.8.

Exercice 7 — Casse le cache, puis prouve-le au profiler

Objectif : on te livre une loop host-side dont usage.cache_read_input_tokens est à 0 à chaque tour malgré un cache_control posé. Trouve l'invalidateur silencieux, répare, et chiffre la régression évitée. Indice/Solution : le préfixe est tools → system → messages ; n'importe quel octet qui change avant le breakpoint invalide tout. Suspects classiques : (a) ordre des tools non déterministe (un Map/set itéré, ou client.listTools() de 10 serveurs MCP concaténés dans un ordre variable → trie par nom) ; (b) un datetime.now() / correlation_id interpolé dans le system prompt (sors-le en message après le breakpoint, ou en role:"system" mid-conversation) ; (c) un tool ajouté/retiré en cours de session (position 0 → cache full rebuild ; utilise tool search qui append). Preuve : diff les bytes du prompt rendu entre 2 requêtes, répare, et montre cache_read_input_tokens qui passe de 0 à ~12k. Chiffrage : 12k tokens × 30 tours × (5 $/Mtok plein − 0.5 $/Mtok cache read) ≈ le facteur 5-10 sur l'input de la session. C'est l'invariant prefix-match : une règle, toutes les conséquences en découlent.


🎤 En entretien

Q : MCP, c'est juste un wrapper REST/OpenAPI de plus ? Non — OpenAPI décrit des endpoints, MCP décrit des intentions exposées à une IA (3 primitives : resources lecture-contexte, tools à effet de bord avec JSON Schema strict, prompts versionnés serveur-side). Tu ne mappes pas ton CRUD 1:1, tu sculptes ce qui est utile au LLM, et tu rends du markdown ciblé plutôt que du JSON brut (-30-40% tokens).

Q : Comment Claude appelle-t-il un tool MCP exactement ? Il ne l'appelle pas directement. Le host mappe le schéma MCP vers le format tools de l'API Messages et tient la boucle stop_reason === "tool_use" → exécute via le client MCP → renvoie le tool_result. L'API Anthropic ignore MCP ; alternativement le connector MCP natif laisse Anthropic tenir la boucle. Choix host-side dès qu'il faut gater/signer/auditer (domaine régulé).

Q : Deux users connectent ton serveur MCP HTTP en même temps. Quel est le risque #1 ? Fuite multi-tenant si tu partages un client backend (closure Pennylane) entre sessions. Solution : buildServer(token, user) qui capture l'identité dans une closure par session, sessionIdGenerator obligatoire, et GC des sessions inactives. C'est le bug le plus cher : un test parallèle 2-tokens doit le prouver.

Q : Comment maîtriser le coût d'un poste qui connecte 10 serveurs MCP ? Le préfixe (system + 8-15k tokens de descriptions de tools) est re-payé à chaque tour. Lève prompt caching (cache_control sur le préfixe stable, vérifié via usage.cache_read_input_tokens), filtre via allowed_tools/tool search (schémas appended → cache préservé), et logge resp.usage pour piloter le € réel. Sur Opus 4.8 (5 $/25 $ /Mtok), c'est un facteur 5-10 sur la facture.

Q : stateless ou stateful par défaut ? Stateless tant que le LLM peut re-fetcher en <500ms — la session-scoped state ajoute une complexité énorme. Stateful seulement si (a) recréer le contexte coûte cher en compute, ou (b) workflow multi-step à dépendances strictes. Défaut = stateless ; on prouve le besoin avant de complexifier.

Q : Le LLM veut appeler 4 tools MCP read-only en un tour. Tu les exécutes comment, et quel piège cache te guette ?asyncio.gather sur les 4 tool calls (indépendants, read-only → parallélisables), puis tous les tool_result renvoyés dans un seul message user. Le piège n'est pas là : c'est si tu fais du fan-out sur plusieurs requêtes Messages au même préfixe caché — la 1ʳᵉ écrit le cache, les autres paient plein tarif tant qu'elle ne stream pas. Pattern : 1 requête → attends le 1er token → fan-out le reste. Et un tool parallèle-safe (grep, read) ≠ un tool parallèle-unsafe (git push, send_email) : c'est exactement pourquoi on promeut une action en dedicated tool typé plutôt que de tout passer par un bash opaque que le harness doit sérialiser.

Q : Comment garantis-tu qu'un tool d'action n'est jamais exécuté deux fois sur un timeout ? Idempotence côté serveur, pas côté LLM (qui peut re-tenter). Un idempotencyKey optionnel dans le schéma ; le handler dédupe sur 24h (key → résultat en Redis) et renvoie le résultat mémorisé sans ré-exécuter — esprit Stripe. Pour les actions à fort enjeu (>10k€), on monte d'un cran : two-phase commit (propose_paymentconfirm_payment(proposalId) avec un proposalId HMAC-signé non devinable, TTL 5 min), pour forcer une confirmation humaine entre les deux. Standard banque FR.


🔗 Liens

  • Spec MCP — https://modelcontextprotocol.io/specification
  • SDK TS — https://github.com/modelcontextprotocol/typescript-sdk
  • SDK Python — https://github.com/modelcontextprotocol/python-sdk
  • Anthropic Registry — https://registry.modelcontextprotocol.io
  • Smithery — https://smithery.ai
  • Pennylane API — https://pennylane.readme.io
  • Salesforce MCP community server — https://github.com/salesforce/mcp-server
  • Postgres MCP — https://github.com/modelcontextprotocol/servers/tree/main/src/postgres
  • GitHub MCP — https://github.com/github/github-mcp-server
  • Slack MCP — https://github.com/modelcontextprotocol/servers/tree/main/src/slack
  • Notion MCP — https://github.com/makenotion/notion-mcp-server
  • OpenTelemetry Node — https://opentelemetry.io/docs/instrumentation/js/
  • S3 Object Lock (WORM) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
  • Inspect AI MCP eval — https://inspect.ai-safety-institute.org.uk/
  • CNIL — recommandations IA & RGPD — https://www.cnil.fr/fr/intelligence-artificielle

Bibliothèque tech perso — Achref