Danila (Dayfing)
Retour aux articles
2 400 mots14 min

Serveur MCP en TypeScript : Streamable HTTP, OAuth et privilèges minimaux

Ce guide construit un petit serveur MCP distant pour un service de notes. L’exemple utilise la branche v2 stable du SDK TypeScript, qui implémente la révision MCP 2026-07-28. Le serveur accepte les requêtes modernes Streamable HTTP, vérifie des jetons d’accès OAuth comme serveur de ressources, expose des outils de lecture et de suppression, valide les arguments avec Zod et place le contrôle d’autorisation près de l’opération protégée. Le code est un exemple exécutable, mais cet article ne prétend pas qu’il a été lancé dans votre environnement.

La version du protocole est importante. Les paquets v2 sont séparés entre @modelcontextprotocol/server, @modelcontextprotocol/client, @modelcontextprotocol/core et des adaptateurs d’exécution. Ne copiez pas dans un nouveau service les anciens exemples qui importent @modelcontextprotocol/sdk/server/mcp.js. Le guide de migration MCP 2026-07-28 détaille les changements du protocole et des paquets.

Commencer par le modèle réseau moderne

Streamable HTTP expose un seul endpoint MCP, généralement /mcp, et chaque requête JSON-RPC du client est un POST HTTP distinct. La réponse est soit un objet JSON, soit un flux SSE limité à cette requête. Une notification est confirmée par le statut 202 sans corps. Le client annonce les deux types application/json et text/event-stream dans Accept, puis envoie le JSON avec Content-Type: application/json.

La révision 2026-07-28 supprime l’ancien flux GET, les identifiants de session du protocole et la reprise avec l’historique Last-Event-ID. Elle supprime aussi les requêtes JSON-RPC indépendantes du serveur vers le client. Si un outil a besoin d’une confirmation ou d’une valeur supplémentaire, il retourne un résultat input_required. Le client répond à la requête intégrée et réessaie l’appel initial. Les notifications de changement de longue durée utilisent le flux de réponse subscriptions/listen, et non une connexion GET générale.

Chaque POST moderne contient MCP-Protocol-Version, dont la valeur doit correspondre à _meta.io.modelcontextprotocol/protocolVersion dans le corps JSON. Mcp-Method reflète la méthode JSON-RPC de chaque requête. Mcp-Name reflète params.name ou params.uri pour les requêtes tools/call, resources/read et prompts/get. Une passerelle peut utiliser ces en-têtes pour router, mais l’application doit encore refuser toute divergence entre les en-têtes et le corps. Le corps reste la source de vérité.

Un appel moderne minimal ressemble à ceci :

POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: notes.search

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"notes.search","arguments":{"query":"gateway"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"notes-cli","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}

Installer le SDK v2

Créez un projet Node.js ESM avec Node.js 20 ou une version ultérieure, TypeScript 6 et les paquets du SDK. Verrouillez les versions dans le lockfile de l’application. Au moment de la vérification, le paquet serveur stable, l’adaptateur Node et l’adaptateur Express sont en version 2.0.0. Le paquet serveur utilise Zod 4 pour les schémas Zod.

npm install @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] express@5 zod@4
npm install --save-dev typescript@6 @types/node @types/express tsx

Avec TypeScript 6, incluez explicitement les types Node.js si votre configuration ne les inclut pas déjà :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "types": ["node"]
  }
}

Construire le serveur et la barrière du serveur de ressources

createMcpHandler reçoit une factory et construit un McpServer neuf pour chaque requête HTTP. toNodeHandler adapte sa face web standard fetch aux objets de requête et de réponse de Node. L’adaptateur Express fournit le parsing JSON, les protections host et origin pour un hôte configuré, ainsi que le middleware OAuth.

Un serveur MCP est un serveur de ressources OAuth. Il vérifie les jetons émis par un serveur d’autorisation, mais n’émet pas lui-même de jetons. Le vérificateur ci-dessous utilise l’introspection RFC 7662. Il vérifie volontairement active, sub et exp, transforme le champ scope séparé par des espaces et renvoie l’AuthInfo du SDK. N’utilisez une validation JWT locale qu’après avoir implémenté et testé l’issuer, l’audience, les signatures, les clés, l’horloge et la rotation des clés.

import {
  createMcpExpressApp,
  getOAuthProtectedResourceMetadataUrl,
  mcpAuthMetadataRouter,
  requireBearerAuth,
  type OAuthTokenVerifier
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import {
  createMcpHandler,
  McpServer,
  OAuthError,
  OAuthErrorCode,
  type AuthInfo,
  type OAuthMetadata
} from "@modelcontextprotocol/server";
import type { Request, Response } from "express";
import * as z from "zod/v4";

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

const host = process.env.HOST ?? "127.0.0.1";
const mcpUrl = new URL(required("MCP_URL"));
const scopes = ["mcp", "notes:read", "notes:write"];
const oauthMetadata: OAuthMetadata = {
  issuer: required("OAUTH_ISSUER"),
  authorization_endpoint: required("OAUTH_AUTHORIZATION_ENDPOINT"),
  token_endpoint: required("OAUTH_TOKEN_ENDPOINT"),
  response_types_supported: ["code"],
  scopes_supported: scopes,
  code_challenge_methods_supported: ["S256"],
  authorization_response_iss_parameter_supported: true
};
const notes = [
  { id: "1", text: "Rotate the signing key after the release." },
  { id: "2", text: "Review the MCP gateway rate limit." }
];

async function verifyAccessToken(token: string): Promise<AuthInfo> {
  const credentials = Buffer.from(`${required("OAUTH_CLIENT_ID")}:${required("OAUTH_CLIENT_SECRET")}`).toString("base64");
  const response = await fetch(required("OAUTH_INTROSPECTION_URL"), {
    method: "POST",
    headers: {
      authorization: `Basic ${credentials}`,
      "content-type": "application/x-www-form-urlencoded"
    },
    body: new URLSearchParams({ token }).toString()
  });
  if (!response.ok) throw new OAuthError(OAuthErrorCode.InvalidToken, "Token introspection failed");
  const payload = await response.json() as {
    active?: unknown;
    sub?: unknown;
    client_id?: unknown;
    scope?: unknown;
    exp?: unknown;
  };
  if (payload.active !== true || typeof payload.sub !== "string" || typeof payload.exp !== "number") {
    throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is inactive or incomplete");
  }
  const tokenScopes = typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) : [];
  return {
    token,
    clientId: typeof payload.client_id === "string" ? payload.client_id : payload.sub,
    scopes: tokenScopes,
    expiresAt: payload.exp
  };
}

const verifier: OAuthTokenVerifier = { verifyAccessToken };

function buildServer(): McpServer {
  const server = new McpServer({ name: "notes", version: "1.0.0" });
  server.registerTool(
    "notes.search",
    {
      title: "Search notes",
      description: "Find notes containing a phrase.",
      inputSchema: z.object({
        query: z.string().trim().min(1).max(200),
        limit: z.number().int().min(1).max(20).default(10)
      })
    },
    async ({ query, limit }, ctx) => {
      if (!ctx.http?.authInfo?.scopes.includes("notes:read")) {
        return { content: [{ type: "text", text: "insufficient_scope: notes:read is required" }], isError: true };
      }
      const needle = query.toLocaleLowerCase();
      const matches = notes.filter(note => note.text.toLocaleLowerCase().includes(needle)).slice(0, limit);
      return { content: [{ type: "text", text: JSON.stringify(matches) }] };
    }
  );
  server.registerTool(
    "notes.delete",
    {
      title: "Delete a note",
      description: "Delete one note by id.",
      inputSchema: z.object({ id: z.string().regex(/^\d+$/) })
    },
    async ({ id }, ctx) => {
      if (!ctx.http?.authInfo?.scopes.includes("notes:write")) {
        return { content: [{ type: "text", text: "insufficient_scope: notes:write is required" }], isError: true };
      }
      const index = notes.findIndex(note => note.id === id);
      if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
      const [removed] = notes.splice(index, 1);
      return { content: [{ type: "text", text: `deleted ${removed.id}` }] };
    }
  );
  return server;
}

const handler = createMcpHandler(buildServer);
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpUrl);
const app = createMcpExpressApp({ host, allowedHosts: [mcpUrl.hostname], jsonLimit: "64kb" });
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpUrl, scopesSupported: scopes, resourceName: "Notes MCP" }));
const auth = requireBearerAuth({ verifier, requiredScopes: ["mcp"], resourceMetadataUrl });
const node = toNodeHandler(handler);
app.all(mcpUrl.pathname, auth, (req: Request, res: Response) => {
  void node(req, res, req.body);
});
const port = mcpUrl.port ? Number(mcpUrl.port) : Number(process.env.PORT ?? 3000);
app.listen(port, host);

Le scope mcp protège l’endpoint. Les deux contrôles des outils donnent une granularité supplémentaire. Un client qui ne possède que mcp peut découvrir l’endpoint, mais reçoit un résultat d’outil ordinaire avec isError: true lorsqu’il appelle une opération protégée. Le refus reste visible pour le modèle. Si tout l’endpoint nécessite un scope, placez-le dans requiredScopes. Le middleware renverra alors 403 avec insufficient_scope.

Le metadata router publie les Protected Resource Metadata RFC 9728 et reflète les metadata RFC 8414 du serveur d’autorisation. Le client suit resource_metadata depuis WWW-Authenticate, découvre les endpoints, obtient un jeton puis réessaie. Le SDK TypeScript n’implémente pas votre fournisseur d’identité. Utilisez un fournisseur maintenu. Les helpers v1 dans @modelcontextprotocol/server-legacy/auth sont gelés pour la migration.

Les règles 2026-07-28 demandent aussi de comparer iss à l’issuer découvert avant d’envoyer le code au token endpoint. Les credentials doivent être séparés par issuer. Les Client ID Metadata Documents sont préférés à la Dynamic Client Registration, conservée pour compatibilité, afin d’éviter un authorization-server mix-up.

Rendre l’absence d’état explicite

La factory crée un serveur pour chaque requête. Ne placez donc pas le caller authentifié, la phase d’un workflow ou une décision d’autorisation dans une instance en espérant la retrouver au POST suivant. Les requêtes modernes peuvent arriver sur n’importe quelle réplique derrière un load balancer round-robin. Stockez l’état métier durable dans une base ou une file, et transmettez un handle explicite, court et validé comme argument d’outil.

Pour une confirmation, retournez input_required et protégez son état. Le SDK exporte inputRequired, acceptedContent et createRequestStateCodec pour ce modèle :

import { acceptedContent, createRequestStateCodec, inputRequired, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

function buildServerWithConfirmation(): McpServer {
  const key = process.env.REQUEST_STATE_KEY;
  if (!key) throw new Error("Missing REQUEST_STATE_KEY");
  type DeleteState = { noteId: string; step: "confirm" };
  const stateCodec = createRequestStateCodec<DeleteState>({
    key,
    ttlSeconds: 300,
    bind: ctx => `${ctx.mcpReq.method}\u0000${ctx.http?.authInfo?.clientId ?? ""}`
  });
  const server = new McpServer(
    { name: "notes-confirmation", version: "1.0.0" },
    { requestState: { verify: stateCodec.verify } }
  );
  const notes = [{ id: "1", text: "Rotate the signing key after the release." }];
  const confirmationSchema = z.object({ confirm: z.boolean() });
  server.registerTool(
    "notes.delete",
    { inputSchema: z.object({ id: z.string().regex(/^\d+$/) }) },
    async ({ id }, ctx) => {
      const confirmed = acceptedContent(ctx.mcpReq.inputResponses, "confirm", confirmationSchema);
      if (!confirmed) {
        return inputRequired({
          inputRequests: {
            confirm: inputRequired.elicit({
              message: "Delete this note?",
              requestedSchema: confirmationSchema
            })
          },
          requestState: await stateCodec.mint({ noteId: id, step: "confirm" }, ctx)
        });
      }
      if (!confirmed.confirm) return { content: [{ type: "text", text: "deletion declined" }], isError: true };
      const index = notes.findIndex(note => note.id === id);
      if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
      notes.splice(index, 1);
      return { content: [{ type: "text", text: `deleted ${id}` }] };
    }
  );
  return server;
}

Attachez stateCodec.verify à l’option requestState.verify transmise à McpServer. L’état est signé, pas chiffré. Le SDK considère l’état renvoyé par le client comme non fiable jusqu’à l’exécution de ce hook. Liez-le au principal et à l’opération, imposez une expiration et n’y placez aucun secret. Toutes les répliques pouvant recevoir le retry doivent partager la clé HMAC.

Valider, limiter et déployer

La validation Zod est la première frontière applicative, pas tout le modèle de sécurité. Limitez les chaînes, tableaux, nombres, identifiants, URL, chemins et tailles de résultat. Validez encore à la frontière de l’API en aval, car un schéma prouve la forme, pas l’autorisation ni l’innocuité d’un effet de bord. N’autorisez pas le modèle à choisir librement des méthodes HTTP, hôtes, chemins de fichiers, fragments SQL ou arguments shell. Utilisez des listes autorisées et des API paramétrées.

Le rate limiting doit utiliser clientId authentifié ou le tenant comme clé principale, avec des limites distinctes pour la découverte, les outils coûteux et les opérations simultanées. Retournez 429 avec Retry-After au niveau edge ou passerelle. Un compteur local convient à un seul processus de développement, mais ce n’est pas une limite de cluster. Pour plusieurs répliques, utilisez un limiteur partagé comme Redis. Placez method et tool name dans les métriques, sans jamais journaliser le bearer token.

Terminez TLS au point public ou utilisez TLS de bout en bout, préservez le flux SSE et désactivez la mise en tampon du proxy avec X-Accel-Buffering: no si votre proxy le permet. Configurez explicitement les délais du corps, des en-têtes, de l’inactivité et de l’upstream. Un serveur local doit écouter sur 127.0.0.1, pas 0.0.0.0, sauf si une liste autorisée et l’authentification le protègent. La factory Express active les protections host et origin uniquement pour la classe localhost configurée. Pour un bind public, renseignez volontairement allowedHosts et allowedOrigins.

Le protocole moderne n’a pas besoin de sticky sessions. Pour distribuer les notifications subscriptions/listen entre répliques, fournissez un ServerEventBus partagé sur votre système pub/sub. Gardez les pools et caches au niveau du module, mais les décisions du caller dans le contexte de requête. Lors de l’arrêt gracieux, appelez handler.close().

Tester le wire, pas seulement la fonction

Le SDK v2 documente une voie de test in-process qui appelle createMcpHandler par StreamableHTTPClientTransport avec une fonction fetch personnalisée. Utilisez le vrai chemin request/response pour couvrir la négociation de version, les en-têtes obligatoires, les réponses JSON et SSE, le passage de l’authentification, les erreurs de schéma, l’annulation et les retries input_required. Ajoutez des tests séparés pour 401 sans jeton, 401 pour un jeton inactif, 403 sans scope d’endpoint et un refus isError intégré quand un outil n’a pas son scope.

Utilisez mode: { pin: "2026-07-28" } pour le wire moderne. Utilisez mode: "auto" dans un test de compatibilité et vérifiez le fallback legacy avec une fixture ancienne. Envoyez deux requêtes avec des callers différents et vérifiez que la visibilité et l’autorisation ne fuient pas. Pour un test multi-node, envoyez le retry vers un autre processus et vérifiez que le state codec et le stockage durable préservent le workflow.

Dépanner en partant de la première couche en échec

Un HTTP 400 avec HeaderMismatch signifie que l’en-tête de protocole et la valeur _meta divergent, ou qu’un en-tête de routage obligatoire manque. Corrigez le client ou la passerelle. Ne revenez pas silencieusement à un ancien protocole lorsque le corps est une erreur moderne reconnue. Un HTTP 415 signifie que le type média du POST n’est pas application/json. Une valeur comme text/plain; a=application/json n’est pas valide. Un 404 pour GET est attendu sur l’endpoint moderne. Un 404 pour POST peut être une méthode inconnue ou une route qui n’a jamais atteint le handler MCP.

Un 401 devrait exposer un challenge WWW-Authenticate. Vérifiez l’URL metadata, l’issuer, l’audience ou la ressource, la signature ou l’introspection, puis l’expiration numérique. La barrière bearer du SDK rejette un AuthInfo sans expiresAt. Un 403 insufficient_scope indique un challenge au niveau endpoint et peut déclencher le step-up du client. Un refus avec isError: true n’autorise pas automatiquement un jeton plus large.

Si un client reste bloqué, vérifiez l’URL, la mise en tampon SSE du reverse proxy, le délai d’inactivité et la réponse correspondant au même identifiant JSON-RPC. Si un client moderne détecte un serveur legacy, vérifiez que versionNegotiation vaut auto ou pin, que les versions du SDK correspondent et qu’une passerelle n’a pas supprimé _meta ou les en-têtes obligatoires. Si la confirmation recommence sans fin, vérifiez la clé de réponse intégrée, validez inputResponses, limitez le nombre de rounds et inspectez la phase de l’état signé. Pour le contexte de migration et la matrice de compatibilité, consultez le guide de migration MCP 2026-07-28. Pour les menaces d’injection, d’empoisonnement d’outils et d’autorisation autour d’un serveur MCP, consultez injection de prompt et sécurité MCP. Pour les frontières de services, les files et l’isolation des pannes, utilisez l’architecture d’un agent IA en production.

Sources

Plus d’articles