iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
Node.js · Intermédiaire

Créer un serveur MCP en Node.js

Construire un serveur MCP de A à Z : tools, resources, prompts, validation, transports stdio et HTTP — pour connecter Claude à n'importe quel système externe.

Node.jsTypeScriptMCPClaude Code
01Comprendre MCP02Premier serveur MCP03Tools avancés — validation, erreurs, structure04Resources — exposer du contenu05Prompts — templates réutilisables06Transports et déploiement07Projet complet — MCP de gestion de projet
Chapitre 7·40 min

Projet complet — MCP de gestion de projet

Ce chapitre construit un serveur MCP complet et réaliste : ProjectMCP, un outil qui connecte Claude à un système de gestion de projet. Claude peut créer des tickets, chercher des utilisateurs, lire les métriques, déclencher des déploiements — tout ça en langage naturel.

Ce qu'on construit

ProjectMCP
├── Tools
│   ├── create_ticket       — créer un ticket
│   ├── update_ticket       — modifier statut, assigné, priorité
│   ├── search_tickets      — chercher avec filtres
│   ├── get_user            — profil utilisateur
│   ├── deploy              — déclencher un déploiement
│   └── run_query           — requête SQL en lecture seule
│
├── Resources
│   ├── project://tickets/open     — tous les tickets ouverts
│   ├── project://metrics          — KPIs en temps réel
│   ├── project://deployments      — historique des déploiements
│   └── project://users/{id}       — profil dynamique par ID
│
└── Prompts
    ├── sprint_review        — analyse du sprint en cours
    └── triage_tickets       — triage des tickets non assignés

Structure du projet

project-mcp/
├── src/
│   ├── index.ts              ← point d'entrée stdio
│   ├── server.ts             ← création du Server MCP
│   ├── db.ts                 ← connexion PostgreSQL
│   ├── tools/
│   │   ├── types.ts
│   │   ├── tickets.ts
│   │   ├── users.ts
│   │   ├── deploy.ts
│   │   ├── query.ts
│   │   └── index.ts
│   ├── resources/
│   │   ├── tickets.ts
│   │   ├── metrics.ts
│   │   └── index.ts
│   └── prompts/
│       └── index.ts
├── package.json
└── tsconfig.json

La couche base de données

// src/db.ts
import pg from 'pg'
 
const { Pool } = pg
 
export const db = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30000,
})
 
// Helper typé
export async function query<T = any>(
  sql: string,
  params: any[] = []
): Promise<T[]> {
  const result = await db.query(sql, params)
  return result.rows as T[]
}
 
// Transaction helper
export async function transaction<T>(
  fn: (client: pg.PoolClient) => Promise<T>
): Promise<T> {
  const client = await db.connect()
  try {
    await client.query('BEGIN')
    const result = await fn(client)
    await client.query('COMMIT')
    return result
  } catch (err) {
    await client.query('ROLLBACK')
    throw err
  } finally {
    client.release()
  }
}

Les tools

// src/tools/tickets.ts
import { z } from 'zod'
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'
import { query } from '../db.js'
import type { ToolDefinition } from './types.js'
 
// ── create_ticket ─────────────────────────────────────────────────────
 
const CreateTicketSchema = z.object({
  title: z.string().min(3).max(200),
  description: z.string().optional(),
  type: z.enum(['bug', 'feature', 'task', 'improvement']).default('task'),
  priority: z.enum(['low', 'medium', 'high', 'critical']).default('medium'),
  assignee_id: z.number().int().optional(),
  labels: z.array(z.string()).default([]),
})
 
export const createTicketTool: ToolDefinition = {
  name: 'create_ticket',
  description: 'Crée un nouveau ticket dans le système de gestion de projet',
  inputSchema: {
    type: 'object',
    properties: {
      title: { type: 'string', description: 'Titre du ticket (3-200 caractères)' },
      description: { type: 'string', description: 'Description détaillée (optionnel)' },
      type: { type: 'string', enum: ['bug', 'feature', 'task', 'improvement'], default: 'task' },
      priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], default: 'medium' },
      assignee_id: { type: 'number', description: 'ID de l\'assigné (optionnel)' },
      labels: { type: 'array', items: { type: 'string' }, default: [] },
    },
    required: ['title'],
  },
  schema: CreateTicketSchema,
  handler: async (rawArgs) => {
    const args = CreateTicketSchema.parse(rawArgs)
 
    const [ticket] = await query<{ id: number; key: string }>(
      `INSERT INTO tickets (title, description, type, priority, assignee_id, labels, status, created_at)
       VALUES ($1, $2, $3, $4, $5, $6, 'open', NOW())
       RETURNING id, 'TK-' || id::text AS key`,
      [args.title, args.description, args.type, args.priority, args.assignee_id, args.labels]
    )
 
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          success: true,
          ticket: {
            id: ticket.id,
            key: ticket.key,
            title: args.title,
            type: args.type,
            priority: args.priority,
            status: 'open',
          },
          message: `Ticket ${ticket.key} créé avec succès.`,
        }, null, 2),
      }],
    }
  },
}
 
// ── search_tickets ────────────────────────────────────────────────────
 
const SearchTicketsSchema = z.object({
  query: z.string().optional(),
  status: z.enum(['open', 'in_progress', 'review', 'done', 'cancelled']).optional(),
  type: z.enum(['bug', 'feature', 'task', 'improvement']).optional(),
  priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
  assignee_id: z.number().int().optional(),
  limit: z.number().int().min(1).max(50).default(20),
})
 
export const searchTicketsTool: ToolDefinition = {
  name: 'search_tickets',
  description: 'Cherche des tickets avec des filtres optionnels',
  inputSchema: {
    type: 'object',
    properties: {
      query: { type: 'string', description: 'Recherche dans le titre et la description' },
      status: { type: 'string', enum: ['open', 'in_progress', 'review', 'done', 'cancelled'] },
      type: { type: 'string', enum: ['bug', 'feature', 'task', 'improvement'] },
      priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
      assignee_id: { type: 'number' },
      limit: { type: 'number', default: 20, description: 'Nombre max de résultats (1-50)' },
    },
  },
  schema: SearchTicketsSchema,
  handler: async (rawArgs) => {
    const args = SearchTicketsSchema.parse(rawArgs)
 
    const conditions: string[] = []
    const params: any[] = []
    let i = 1
 
    if (args.query) {
      conditions.push(`(title ILIKE $${i} OR description ILIKE $${i})`)
      params.push(`%${args.query}%`)
      i++
    }
    if (args.status) { conditions.push(`status = $${i++}`); params.push(args.status) }
    if (args.type) { conditions.push(`type = $${i++}`); params.push(args.type) }
    if (args.priority) { conditions.push(`priority = $${i++}`); params.push(args.priority) }
    if (args.assignee_id) { conditions.push(`assignee_id = $${i++}`); params.push(args.assignee_id) }
 
    params.push(args.limit)
 
    const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
    const tickets = await query(
      `SELECT t.id, 'TK-' || t.id::text AS key, t.title, t.status, t.type,
              t.priority, t.created_at, u.name AS assignee
       FROM tickets t
       LEFT JOIN users u ON t.assignee_id = u.id
       ${where}
       ORDER BY t.created_at DESC
       LIMIT $${i}`,
      params
    )
 
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ count: tickets.length, tickets }, null, 2),
      }],
    }
  },
}
// src/tools/deploy.ts
import { z } from 'zod'
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'
import { query } from '../db.js'
import type { ToolDefinition } from './types.js'
 
const DeploySchema = z.object({
  service: z.string(),
  environment: z.enum(['dev', 'staging', 'production']),
  branch: z.string().default('main'),
  dry_run: z.boolean().default(false),
})
 
export const deployTool: ToolDefinition = {
  name: 'deploy',
  description: 'Déclenche un déploiement. Demande confirmation pour la production.',
  inputSchema: {
    type: 'object',
    properties: {
      service: { type: 'string', description: 'Nom du service à déployer' },
      environment: { type: 'string', enum: ['dev', 'staging', 'production'] },
      branch: { type: 'string', default: 'main' },
      dry_run: { type: 'boolean', default: false, description: 'Simuler sans déployer réellement' },
    },
    required: ['service', 'environment'],
  },
  schema: DeploySchema,
  handler: async (rawArgs) => {
    const args = DeploySchema.parse(rawArgs)
 
    if (args.environment === 'production' && !args.dry_run) {
      // Vérifier qu'il y a une confirmation dans les derniers appels
      // En pratique : logger et demander à Claude de confirmer
      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            warning: 'Déploiement en PRODUCTION demandé.',
            service: args.service,
            branch: args.branch,
            action_required: 'Confirme explicitement pour procéder. Tape "confirme le déploiement de X en production".',
          }, null, 2),
        }],
        isError: false,
      }
    }
 
    if (args.dry_run) {
      return {
        content: [{
          type: 'text',
          text: `[DRY RUN] Déploiement simulé : ${args.service} (${args.branch}) → ${args.environment}. Aucune action réelle effectuée.`,
        }],
      }
    }
 
    // Enregistrer en base et déclencher le pipeline
    const [deployment] = await query<{ id: number }>(
      `INSERT INTO deployments (service, environment, branch, status, triggered_at)
       VALUES ($1, $2, $3, 'pending', NOW())
       RETURNING id`,
      [args.service, args.environment, args.branch]
    )
 
    // Déclencher le webhook CI/CD
    await fetch(`${process.env.CI_WEBHOOK_URL}/deploy`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.CI_TOKEN}` },
      body: JSON.stringify({ deployment_id: deployment.id, service: args.service, branch: args.branch, env: args.environment }),
    })
 
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          deployment_id: deployment.id,
          service: args.service,
          environment: args.environment,
          branch: args.branch,
          status: 'pending',
          message: `Déploiement #${deployment.id} déclenché. Surveille le statut dans le pipeline CI.`,
        }, null, 2),
      }],
    }
  },
}

Les resources

// src/resources/metrics.ts
import { query } from '../db.js'
 
export async function getMetricsContent(): Promise<string> {
  const [ticketStats] = await query<any>(`
    SELECT
      COUNT(*) FILTER (WHERE status = 'open') AS open_tickets,
      COUNT(*) FILTER (WHERE status = 'in_progress') AS in_progress,
      COUNT(*) FILTER (WHERE status = 'done' AND updated_at > NOW() - INTERVAL '7 days') AS closed_this_week,
      COUNT(*) FILTER (WHERE priority = 'critical' AND status != 'done') AS critical_open
    FROM tickets
  `)
 
  const [deployStats] = await query<any>(`
    SELECT
      COUNT(*) FILTER (WHERE status = 'success' AND triggered_at > NOW() - INTERVAL '7 days') AS deploys_this_week,
      COUNT(*) FILTER (WHERE status = 'failed' AND triggered_at > NOW() - INTERVAL '7 days') AS failures_this_week
    FROM deployments
  `)
 
  const topAssignees = await query<any>(`
    SELECT u.name, COUNT(*) AS open_tickets
    FROM tickets t
    JOIN users u ON t.assignee_id = u.id
    WHERE t.status IN ('open', 'in_progress')
    GROUP BY u.id, u.name
    ORDER BY open_tickets DESC
    LIMIT 5
  `)
 
  return JSON.stringify({
    tickets: ticketStats,
    deployments: deployStats,
    top_assignees: topAssignees,
    generated_at: new Date().toISOString(),
  }, null, 2)
}
// src/resources/index.ts
import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { getMetricsContent } from './metrics.js'
import { query } from '../db.js'
 
export function registerResources(server: Server) {
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
    resources: [
      {
        uri: 'project://tickets/open',
        name: 'Tickets ouverts',
        description: 'Tous les tickets avec statut open ou in_progress',
        mimeType: 'application/json',
      },
      {
        uri: 'project://metrics',
        name: 'Métriques projet',
        description: 'KPIs en temps réel : tickets, déploiements, assignations',
        mimeType: 'application/json',
      },
    ],
    resourceTemplates: [
      {
        uriTemplate: 'project://users/{id}',
        name: 'Profil utilisateur',
        description: 'Profil complet d\'un membre de l\'équipe',
        mimeType: 'application/json',
      },
    ],
  }))
 
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
    const { uri } = request.params
 
    if (uri === 'project://tickets/open') {
      const tickets = await query(`
        SELECT t.id, 'TK-' || t.id::text AS key, t.title, t.status,
               t.type, t.priority, u.name AS assignee, t.created_at
        FROM tickets t
        LEFT JOIN users u ON t.assignee_id = u.id
        WHERE t.status IN ('open', 'in_progress')
        ORDER BY
          CASE t.priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
          t.created_at DESC
      `)
 
      return {
        contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(tickets, null, 2) }],
      }
    }
 
    if (uri === 'project://metrics') {
      return {
        contents: [{ uri, mimeType: 'application/json', text: await getMetricsContent() }],
      }
    }
 
    const userMatch = uri.match(/^project:\/\/users\/(\d+)$/)
    if (userMatch) {
      const [user] = await query(
        `SELECT u.id, u.name, u.email, u.role,
                COUNT(t.id) FILTER (WHERE t.status IN ('open', 'in_progress')) AS open_tickets
         FROM users u
         LEFT JOIN tickets t ON t.assignee_id = u.id
         WHERE u.id = $1
         GROUP BY u.id`,
        [parseInt(userMatch[1])]
      )
 
      if (!user) throw new McpError(ErrorCode.InvalidRequest, `Utilisateur ${userMatch[1]} introuvable`)
 
      return {
        contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(user, null, 2) }],
      }
    }
 
    throw new McpError(ErrorCode.InvalidRequest, `Resource inconnue : ${uri}`)
  })
}

Le point d'entrée

// src/index.ts
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { createServer } from './server.js'
 
const server = createServer()
const transport = new StdioServerTransport()
await server.connect(transport)
 
console.error('[project-mcp] Connecté et prêt.')
// src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { registerTools } from './tools/index.js'
import { registerResources } from './resources/index.js'
import { registerPrompts } from './prompts/index.js'
 
export function createServer(): Server {
  const server = new Server(
    { name: 'project-mcp', version: '1.0.0' },
    { capabilities: { tools: {}, resources: {}, prompts: {} } }
  )
 
  registerTools(server)
  registerResources(server)
  registerPrompts(server)
 
  return server
}

Configuration Claude Code

{
  "mcpServers": {
    "project": {
      "command": "node",
      "args": ["/path/to/project-mcp/dist/index.js"],
      "env": {
        "DATABASE_URL": "postgresql://localhost/project_db",
        "CI_WEBHOOK_URL": "https://ci.monapp.fr",
        "CI_TOKEN": "${CI_TOKEN}"
      }
    }
  }
}

Conversations possibles

> quels sont les tickets critiques en cours ?
→ Claude lit project://tickets/open, filtre par priority: critical
 
> crée un ticket bug pour l'erreur 500 sur /api/auth
→ Claude appelle create_ticket({ title: "Erreur 500 sur /api/auth", type: "bug", priority: "high" })
 
> montre-moi les métriques du projet
→ Claude lit project://metrics et interprète les KPIs
 
> quel est le profil de l'utilisateur 7 ?
→ Claude lit project://users/7
 
> déploie le service api en staging depuis la branche feature/auth
→ Claude appelle deploy({ service: "api", environment: "staging", branch: "feature/auth" })
 
> donne-moi un résumé du sprint
→ Claude utilise le prompt sprint_review

Ce qu'on a construit

Un serveur MCP complet avec :

  • 5 tools typés et validés avec Zod
  • 2 resources statiques + 1 template URI dynamique
  • Architecture modulaire (tools/resources/prompts séparés)
  • Gestion d'erreurs structurée (McpError + isError)
  • Transport stdio prêt pour Claude Code
  • Séparation createServer() / index.ts pour supporter HTTP si nécessaire

La même architecture s'adapte à n'importe quel système — remplacez les queries PostgreSQL par des appels à votre API, ajoutez des tools pour vos besoins spécifiques, exposez les resources qui représentent votre état système.

Précédent
Transports et déploiement

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main