Construire un serveur MCP de A à Z : tools, resources, prompts, validation, transports stdio et HTTP — pour connecter Claude à n'importe quel système externe.
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.
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ésproject-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// 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()
}
}// 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),
}],
}
},
}// 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}`)
})
}// 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
}{
"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}"
}
}
}
}> 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_reviewUn serveur MCP complet avec :
createServer() / index.ts pour supporter HTTP si nécessaireLa 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.