>_Skillful
Need help with advanced AI agent engineering?Contact FirmAdapt
All Posts

Construire un serveur MCP de zéro avec le SDK TypeScript

Une marche pratique à travers l'architecture d'un serveur MCP, les définitions d'outils, la gestion des ressources et les tests, en utilisant le SDK TypeScript officiel.

April 26, 2026Basel Ismail
mcp tutoriel typescript

Par où commencer

Si vous travaillez avec des agents IA depuis un certain temps, vous avez probablement heurté le mur où votre agent a besoin d'accéder à quelque chose qu'il n'a pas : une API propriétaire, une base de données interne, un flux de travail spécifique. Le Model Context Protocol (MCP) est la réponse à ce mur, et construire son propre serveur est plus accessible qu'il n'y paraît.

Cette marche présume que vous êtes à l'aise avec TypeScript et que vous avez une compréhension de base de ce que fait conceptuellement le MCP. Nous irons de l'initialisation du projet à un serveur fonctionnel et testable, avec de véritables définitions d'outils et une véritable gestion de ressources.

Initialisation du projet et dépendances

Démarrez avec un projet TypeScript neuf. Vous aurez besoin du SDK officiel d'Anthropic, publié sous le nom @modelcontextprotocol/sdk sur npm. Au début 2025, le paquet est en version 0.6.x et la surface d'API s'est considérablement stabilisée par rapport aux premières bêtas.

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node

Cela vaut la peine de tirer Zod dès le départ. Le SDK l'utilise en interne pour la validation de schémas, et vous le voudrez pour définir vos schémas d'entrée d'outils d'une manière qui soit à la fois typée et automatiquement sérialisable en JSON Schema.

Votre tsconfig.json doit cibler ES2022 ou plus récent et utiliser la résolution de modules NodeNext. Le SDK est livré en ESM, vous voudrez donc que votre projet s'aligne :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist"
  }
}

Décisions d'architecture avant la première ligne

Avant de toucher au SDK, consacrez cinq minutes à l'architecture. Les serveurs MCP communiquent par défaut via stdio, ce qui correspond à la manière dont Claude Desktop et la plupart des frameworks d'agent attendent de se connecter. Mais le SDK prend également en charge HTTP avec Server-Sent Events pour les déploiements distants. Choisir le mauvais transport tôt crée plus tard des douleurs de refonte.

Pour le développement local et l'intégration avec des outils comme Claude Desktop ou Cursor, stdio est le bon choix. Pour un serveur que vous voulez exposer comme ressource partagée au sein d'une équipe, le transport HTTP a plus de sens. Le SDK fait suffisamment bien l'abstraction pour que basculer ne soit pas catastrophique, mais vos hypothèses de déploiement façonneront la façon dont vous traiterez l'authentification et l'état.

L'autre décision concerne l'état. Les serveurs MCP peuvent maintenir un état tout au long d'une session, mais la plupart des serveurs bien conçus traitent chaque appel d'outil comme relativement indépendant. Si votre serveur a besoin de maintenir un pool de connexions à une base de données ou de mettre en cache un jeton d'authentification, c'est très bien : initialisez-les au démarrage plutôt que par requête.

Création de l'instance de serveur

Le point d'entrée pour tout serveur MCP est la classe Server du SDK. Vous l'instanciez avec des métadonnées sur votre serveur, puis vous y attachez des gestionnaires pour les messages de protocole que vous voulez prendre en charge.

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';

const server = new Server(
  {
    name: 'my-mcp-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
      resources: {},
    },
  }
);

L'objet capabilities indique au client ce que votre serveur prend en charge. Si vous déclarez tools: {}, on attendra de vous que vous traitiez les requêtes tools/list et tools/call. Même schéma pour les ressources. Ne déclarez pas de capacités que vous n'avez pas implémentées : le client tentera de les utiliser.

Définition des outils

Les outils sont la primitive MCP la plus utilisée. Ce sont des fonctions appelables qu'un agent IA peut invoquer, avec des entrées et des sorties structurées. Un outil bien défini a un nom clair, une description sur laquelle le modèle peut raisonner et un schéma d'entrée serré.

Voici un exemple concret : un outil qui interroge les issues ouvertes d'un dépôt GitHub via l'API REST de GitHub.

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

const GetIssuesSchema = z.object({
  owner: z.string().describe('The repository owner or organization'),
  repo: z.string().describe('The repository name'),
  state: z.enum(['open', 'closed', 'all']).default('open'),
  limit: z.number().int().min(1).max(100).default(20),
});

const TOOLS = [
  {
    name: 'get_github_issues',
    description:
      'Fetch open issues from a GitHub repository. Returns issue titles, numbers, labels, and URLs.',
    inputSchema: zodToJsonSchema(GetIssuesSchema),
  },
];

Le champ description compte plus que la plupart des gens ne le pensent. Le modèle l'utilise pour décider quand appeler votre outil, donc des descriptions vagues conduisent à des appels manqués ou à un usage incorrect. Soyez précis sur ce que l'outil retourne, pas seulement sur ce qu'il fait.

Enregistrez le gestionnaire de liste pour que les clients puissent découvrir vos outils :

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return { tools: TOOLS };
});

Implémentation de l'exécution des outils

Le gestionnaire d'appel est l'endroit où vit votre logique réelle. Le SDK vous fournit le nom de l'outil et les arguments ; vous validez, exécutez et retournez un résultat.

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === 'get_github_issues') {
    const parsed = GetIssuesSchema.safeParse(args);
    if (!parsed.success) {
      return {
        content: [{ type: 'text', text: `Invalid arguments: ${parsed.error.message}` }],
        isError: true,
      };
    }

    const { owner, repo, state, limit } = parsed.data;
    const url = `https://api.github.com/repos/${owner}/${repo}/issues?state=${state}&per_page=${limit}`;

    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
        Accept: 'application/vnd.github.v3+json',
      },
    });

    if (!response.ok) {
      return {
        content: [{ type: 'text', text: `GitHub API error: ${response.status}` }],
        isError: true,
      };
    }

    const issues = await response.json();
    const formatted = issues.map((i: any) =>
      `#${i.number}: ${i.title} (${i.html_url})`
    ).join('\n');

    return {
      content: [{ type: 'text', text: formatted || 'No issues found.' }],
    };
  }

  return {
    content: [{ type: 'text', text: `Unknown tool: ${name}` }],
    isError: true,
  };
});

Quelques points qui méritent d'être soulignés ici. Utilisez toujours safeParse plutôt que parse de manière à pouvoir retourner une erreur structurée plutôt que de lever une exception. Retournez isError: true en cas d'échec plutôt que de lever des exceptions ; le protocole gère les erreurs dans la charge utile de la réponse, pas via des exceptions levées. Et gardez vos messages d'erreur informatifs car le modèle les lira et pourra réessayer avec des entrées corrigées.

Gestion des ressources

Les ressources sont une primitive différente des outils. Là où les outils sont des actions, les ressources sont des sources de données que le modèle peut lire, comparables à des fichiers ou à des documents. Elles sont identifiées par des URI et peuvent être statiques ou dynamiques.

Un cas d'usage pratique : exposer un ensemble de pages de documentation interne en tant que ressources, de sorte que le modèle puisse les lire à la demande au lieu de tout entasser dans le prompt système.

import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';

const RESOURCES = [
  {
    uri: 'docs://api/authentication',
    name: 'Authentication Guide',
    mimeType: 'text/markdown',
  },
  {
    uri: 'docs://api/rate-limits',
    name: 'Rate Limits Reference',
    mimeType: 'text/markdown',
  },
];

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return { resources: RESOURCES };
});

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const { uri } = request.params;

  const docMap: Record = {
    'docs://api/authentication': '# Authentication\n\nUse Bearer tokens in the Authorization header...',
    'docs://api/rate-limits': '# Rate Limits\n\nThe API allows 1000 requests per hour per token...',
  };

  const content = docMap[uri];
  if (!content) {
    throw new Error(`Resource not found: ${uri}`);
  }

  return {
    contents: [{ uri, mimeType: 'text/markdown', text: content }],
  };
});

Dans une implémentation réelle, vous récupéreriez ces contenus depuis un CMS, un système de fichiers ou une base de données plutôt que de les coder en dur. Le schéma d'URI vous appartient ; gardez-le simplement cohérent et porteur de sens.

Démarrage du serveur

Tout assembler en bas de votre fichier d'entrée :

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error('MCP server running on stdio');
}

main().catch((err) => {
  console.error('Fatal error:', err);
  process.exit(1);
});

Notez que la journalisation va sur stderr, pas sur stdout. Le transport stdio utilise stdout pour les messages de protocole, donc tout ce que vous journalisez sur stdout corrompra le flux de messages. C'est une erreur courante de débutant qui produit des erreurs de parsing déroutantes côté client.

Tester votre serveur

Le SDK livre un InMemoryTransport conçu pour les tests. Il permet de créer une paire client/serveur connectée dans le même processus, sans aucun véritable I/O, ce qui rend les tests unitaires immédiats.

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';

async function createTestClient() {
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

  const client = new Client(
    { name: 'test-client', version: '1.0.0' },
    { capabilities: {} }
  );

  await server.connect(serverTransport);
  await client.connect(clientTransport);

  return client;
}

// In your test file (using Vitest or Jest):
test('lists available tools', async () => {
  const client = await createTestClient();
  const result = await client.listTools();
  expect(result.tools).toHaveLength(1);
  expect(result.tools[0].name).toBe('get_github_issues');
});

test('returns error for invalid arguments', async () => {
  const client = await createTestClient();
  const result = await client.callTool({
    name: 'get_github_issues',
    arguments: { owner: 123 }, // wrong type
  });
  expect(result.isError).toBe(true);
});

Pour les tests d'intégration contre l'API GitHub réelle, utilisez MSW (Mock Service Worker) pour intercepter les appels HTTP. Cela permet de tester l'ensemble du chemin d'exécution de votre outil sans atteindre les limites de débit ni exiger des identifiants en CI.

Au-delà des tests unitaires, l'Inspector MCP mérite d'être connu. C'est un outil en ligne de commande (npx @modelcontextprotocol/inspector) qui se connecte à votre serveur et qui vous permet d'invoquer manuellement les outils et de parcourir les ressources via une interface simple. C'est plus rapide que de câbler tout un agent juste pour vérifier que votre serveur répond correctement.

Considérations de sécurité avant la mise en production

Quelques points faciles à oublier. Premièrement, validez toutes les entrées même si vous utilisez des schémas Zod, car le JSON qui arrive sur le fil n'est pas typé et un client malveillant pourrait envoyer n'importe quoi. Le motif safeParse couvre ce point, mais assurez-vous de ne pas transmettre directement les args bruts à un système en aval.

Deuxièmement, soyez prudent sur ce que vous exposez via les ressources. Les ressources sont lisibles par tout client qui se connecte à votre serveur, donc ne servez rien via une URI de ressource que vous ne seriez pas à l'aise de voir lu par le modèle et potentiellement inclus dans sa sortie.

Troisièmement, restreignez étroitement les permissions de vos outils. Si votre outil a seulement besoin d'un accès en lecture à une base de données, ne lui donnez pas une chaîne de connexion avec permissions d'écriture. Le rayon d'explosion d'une attaque par injection de prompt, où un document malveillant pousse le modèle à appeler votre outil avec des arguments inattendus, est directement proportionnel à ce que votre outil est autorisé à faire.

Quand vous référencerez votre serveur sur un répertoire comme Skillful.sh, le scanner de sécurité signalera les problèmes courants comme des schémas d'entrée trop larges, l'absence de validation d'entrée et la mauvaise gestion des variables d'environnement. Passer cette checklist avant la publication vous évite la note de sécurité basse qui décourage l'adoption.

Connexion à Claude Desktop

Une fois votre serveur construit et testé, le connecter à Claude Desktop revient à ajouter une entrée dans le fichier de configuration situé à ~/Library/Application Support/Claude/claude_desktop_config.json sur macOS :

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"],
      "env": {
        "GITHUB_TOKEN": "your-token-here"
      }
    }
  }
}

Redémarrez Claude Desktop et vos outils apparaîtront dans l'interface. Si quelque chose ne fonctionne pas, consultez les journaux MCP à ~/Library/Logs/Claude/mcp*.log, qui capturent à la fois les messages de protocole et tout ce que votre processus de serveur écrit sur stderr.

À partir de là, le cycle d'itération est simple : mettez à jour vos définitions d'outils, recompilez, redémarrez Claude Desktop, testez. La boucle complète prend moins d'une minute une fois l'échafaudage en place.


Lectures complémentaires

Parcourez les serveurs MCP sur Skillful.sh. Cherchez plus de 137 000 outils IA sur Skillful.sh.