Tutoriel · MCP · TypeScript · Sécurité

Serveur MCP distant : Streamable HTTP en TypeScript

Passer du processus local au service partagé : transport Streamable HTTP, serveur sans état, authentification par jeton, protections réseau et mise en production derrière un proxy.

Du processus local au service

En stdio, chaque poste lance sa propre copie du serveur, avec ses propres secrets. C’est simple pour un outil de développeur, mais difficile à maintenir dès qu’une équipe entière l’utilise : versions qui divergent, clés d’API distribuées sur chaque machine, aucune trace centralisée.

Un serveur distant inverse la logique : une seule instance, déployée et surveillée comme n’importe quelle API, à laquelle chaque client se connecte avec sa propre identité. Les secrets d’accès au ticketing restent côté serveur.

Le transport Streamable HTTP

Le transport distant standard de MCP est Streamable HTTP, qui remplace l’ancien transport HTTP+SSE. Le principe :

  • le client envoie chaque message JSON-RPC en POST sur un point d’accès unique, par exemple /mcp ;
  • le serveur répond soit par un JSON simple, soit par un flux SSE quand il doit envoyer des notifications de progression avant le résultat ;
  • en mode avec état, le serveur attribue un identifiant de session (en-tête Mcp-Session-Id) et peut ouvrir un flux GET pour pousser des notifications.

Le mode sans état suffit à la plupart des serveurs d’outils : chaque requête est autonome, ce qui permet de répartir la charge sur plusieurs instances sans session partagée.

Un serveur sans état avec Express

Première étape : sortir la construction du serveur dans une fonction, pour réutiliser les mêmes outils, ressources et prompts quel que soit le transport.

// src/mcp.ts : la construction du serveur ne dépend plus du transport.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

export function creerServeur(): McpServer {
  const server = new McpServer({ name: "acme-support", version: "1.0.0" });
  enregistrerOutils(server);      // rechercher_tickets, ajouter_commentaire…
  enregistrerRessources(server);  // charte, fiches de la base de connaissances
  enregistrerPrompts(server);     // repondre_ticket
  return server;
}

Le point d’accès HTTP crée ensuite un serveur et un transport par requête. Avec sessionIdGenerator: undefined, le transport n’émet pas d’identifiant de session.

// src/http.ts
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { creerServeur } from "./mcp.js";
import { exigerJeton, metadonneesRessource } from "./auth.js";

const app = express();
app.use(express.json({ limit: "1mb" }));

app.get("/.well-known/oauth-protected-resource", metadonneesRessource);

app.post("/mcp", exigerJeton, async (req, res) => {
  // Sans état : un serveur et un transport par requête, aucune session à partager entre instances.
  const server = creerServeur();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on("close", () => {
    transport.close();
    server.close();
  });
  try {
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  } catch (e) {
    console.error("Erreur MCP", e);
    if (!res.headersSent) {
      res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Erreur interne" }, id: null });
    }
  }
});

// En mode sans état, pas de flux GET ni de fermeture de session.
app.all("/mcp", (_req, res) => res.status(405).set("Allow", "POST").end());

app.listen(Number(process.env.PORT ?? 3000), "127.0.0.1");

Le même fichier server.ts en stdio continue d’exister pour le développement local : il appelle creerServeur() et le connecte à StdioServerTransport.

Authentifier par jeton OAuth

La spécification MCP s’appuie sur OAuth 2.1 : le serveur MCP est une ressource protégée, et c’est un serveur d’autorisation distinct (le fournisseur d’identité de l’entreprise) qui délivre les jetons. Le serveur MCP n’a qu’à les vérifier.

  1. Sans jeton, le serveur répond 401 avec un en-tête WWW-Authenticate qui pointe vers ses métadonnées.
  2. Le client lit ces métadonnées, découvre le serveur d’autorisation et y fait connecter l’utilisateur.
  3. Il rappelle le serveur MCP avec un jeton Bearer, dont l’audience désigne ce serveur.
// src/auth.ts
import type { RequestHandler } from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";

const RESSOURCE = "https://mcp.acme-demo.example/mcp";
const EMETTEUR = "https://auth.acme-demo.example";
const METADONNEES = "https://mcp.acme-demo.example/.well-known/oauth-protected-resource";
const cles = createRemoteJWKSet(new URL(`${EMETTEUR}/.well-known/jwks.json`));

// Indique au client quel serveur d'autorisation délivre les jetons pour cette ressource (RFC 9728).
export const metadonneesRessource: RequestHandler = (_req, res) => {
  res.json({ resource: RESSOURCE, authorization_servers: [EMETTEUR], scopes_supported: ["tickets:lecture", "tickets:ecriture"] });
};

export const exigerJeton: RequestHandler = async (req, res, next) => {
  const refuser = () =>
    res.status(401).set("WWW-Authenticate", `Bearer resource_metadata="${METADONNEES}"`).end();

  const jeton = req.headers.authorization?.match(/^Bearer (.+)$/)?.[1];
  if (!jeton) return refuser();
  try {
    // L'audience doit désigner CE serveur : un jeton émis pour une autre API est refusé.
    const { payload } = await jwtVerify(jeton, cles, { issuer: EMETTEUR, audience: RESSOURCE });
    (req as any).auth = {
      token: jeton,
      clientId: String(payload.client_id ?? payload.azp ?? ""),
      scopes: String(payload.scope ?? "").split(" ").filter(Boolean),
      extra: { utilisateur: payload.sub },
    };
    next();
  } catch {
    refuser();
  }
};

Le contrôle de l’audience est essentiel : il empêche de réutiliser ici un jeton émis pour une autre API. À l’inverse, le serveur MCP ne transmet jamais le jeton reçu au ticketing : il utilise ses propres identifiants vers les systèmes en aval. Les flux OAuth eux-mêmes sont détaillés dans l’article sur Authentifier une intégration Salesforce avec OAuth 2.0.

Utiliser l’identité dans les outils

Le transport transmet aux handlers l’objet placé dans req.auth, sous la forme extra.authInfo. Un outil d’écriture peut ainsi exiger un scope et attribuer l’action à la bonne personne :

async ({ ticketId, texte }, extra) => {
  const auth = extra.authInfo;
  if (!auth?.scopes.includes("tickets:ecriture")) {
    return { isError: true, content: [{ type: "text", text: "Droit tickets:ecriture requis pour commenter." }] };
  }
  // Le commentaire est attribué à la personne, pas à un compte technique partagé.
  await ticketing.commenter(ticketId, texte, { auteur: String(auth.extra?.utilisateur) });
  return { content: [{ type: "text", text: `Commentaire ajouté au ticket ${ticketId}.` }] };
}

Pour aller plus loin, on peut ne déclarer les outils d’écriture qu’aux clients qui ont le scope correspondant : le modèle ne voit alors même pas ce qu’il n’a pas le droit de faire.

Protections réseau

  • HTTPS obligatoire en dehors du poste local : le jeton circule dans chaque requête.
  • Origin : un serveur MCP qui écoute sur le poste local doit rejeter les requêtes dont l’en-tête Origin n’est pas attendu, faute de quoi une page Web malveillante peut l’appeler depuis le navigateur (DNS rebinding). Il écoute alors sur 127.0.0.1, jamais sur toutes les interfaces.
  • Taille et débit : limiter le corps des requêtes et le nombre d’appels par client, comme pour toute API publique.
  • Journalisation : tracer l’outil appelé, l’utilisateur et la durée, sans écrire le jeton ni les données sensibles des arguments.

Déployer

Un serveur sans état se déploie comme une API Node ordinaire : un conteneur, plusieurs instances derrière un proxy inverse qui termine TLS, une sonde de santé sur une route dédiée. Le proxy doit laisser passer les réponses en flux (SSE) sans les mettre en tampon, sous peine de retarder les notifications de progression.

Côté client, la déclaration ne contient plus de commande mais une URL, par exemple https://mcp.acme-demo.example/mcp ; l’authentification se fait au premier appel. Reste à s’assurer que chaque version publiée fonctionne : c’est l’objet du dernier article de la série.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.