Objetivo da Aula
Ao concluir esta aula, você será capaz de:
Implementar um MCP server com transport HTTP+SSE usando SSEServerTransport
Configurar endpoints de health check e métricas (Prometheus-compatible)
Gerenciar múltiplas conexões SSE simultâneas com Map de sessões
Containerizar um MCP server com Dockerfile de boas práticas
Conectar um MCP Inspector externo para debug e verificação do servidor
Por que isso importa
Desenvolvimento local usa InMemoryTransport ou stdio — simples, sem rede. Mas para disponibilizar um MCP server para múltiplos usuários simultâneos, você precisa de HTTP+SSE.
Por que SSE e não WebSocket? - MCP especifica SSE como transport para HTTP - SSE é unidirecional do servidor ao cliente (mensagens MCP chegam via SSE) - Mensagens do cliente chegam via POST HTTP normal - SSE funciona com load balancers, proxies e CDNs sem configuração especial - WebSocket precisaria de configuração adicional no nginx/ALB
O starter desta unidade implementa um MCP server de produção pronto para container:
curl http://localhost:3001/health → {"status":"ok"}
curl http://localhost:3001/metrics → métricas Prometheus
GET http://localhost:3001/sse → SSE stream (MCP connection)
POST http://localhost:3001/messages → mensagens do clienteConceitos Fundamentais
Transport HTTP+SSE vs InMemoryTransport
Aspecto | InMemoryTransport | HTTP+SSE |
Uso | Testes, labs | Produção |
Processo | Mesmo processo | Processos separados |
Clientes | 1 por vez | Múltiplos simultâneos |
Latência | ~0ms | 1-10ms (local) |
Deploy | Não | Sim |
Debug MCP Inspector | Não | Sim |
Fundamento: Sessão SSE do MCP
O transporte HTTP+SSE do MCP usa dois canais separados para comunicação bidirecional. O cliente faz um GET /sse que fica aberto como Server-Sent Events — o servidor envia respostas por esse stream. Para enviar comandos, o cliente faz POST /messages com um header correlacionando à sessão SSE aberta. É diferente de um request/response REST: o canal de saída (SSE) fica persistente, e o canal de entrada (POST) é stateless mas correlacionado pelo session-id. Na prática, é um padrão análogo ao WebSocket porém em HTTP puro, compatível com proxies e load balancers que não suportam upgrade de protocolo. Para o arquiteto: configure o timeout do load balancer para o GET /sse como "sem timeout" (ou muito longo) — conexões SSE que caem por timeout do LB interrompem todas as sessões ativas.
SSEServerTransport — Como Funciona
// Endpoint SSE: cliente MCP conecta aqui
app.get('/sse', async (req, res) => {
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2)}`
// SSEServerTransport recebe res (response stream) e o path POST
const transport = new SSEServerTransport('/messages', res)
const server = criarMCPServer()
// Registrar para receber POST /messages
conexoes.set(sessionId, transport)
res.on('close', () => conexoes.delete(sessionId))
// Conectar server ao transport SSE
await server.connect(transport)
})
// Endpoint POST: mensagens do cliente chegam aqui
app.post('/messages', async (req, res) => {
const sessionId = req.headers['x-session-id'] as string
const transport = conexoes.get(sessionId)
if (!transport) return res.status(404).json({ erro: 'Sessão não encontrada' })
await transport.handlePostMessage(req, res)
})O fluxo é: 1. Cliente MCP faz GET /sse → recebe SSE stream e sessionId 2. Para enviar mensagem, cliente faz POST /messages com header x-session-id 3. transport.handlePostMessage() processa e envia response via SSE stream
Health Check para Kubernetes
app.get('/health', (_, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})Simples, mas crítico para: - Kubernetes liveness probe: mata e reinicia pod se /health falhar - Kubernetes readiness probe: para de enviar tráfego se o pod não estiver pronto - Load balancer health check: remove instâncias não saudáveis do pool
Em produção, expanda:
app.get('/health', async (_, res) => {
const checks = {
server: 'ok',
conexoes_ativas: conexoes.size,
memoria_mb: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1),
}
// Adicionar checks de dependências (DB, Redis, etc)
// try { await redis.ping(); checks.redis = 'ok' } catch { checks.redis = 'error' }
const saudavel = Object.values(checks).every(v => v !== 'error')
res.status(saudavel ? 200 : 503).json({ status: saudavel ? 'ok' : 'degraded', checks })
})Endpoint de Métricas Prometheus
app.get('/metrics', (_, res) => {
res.type('text/plain').send([
`mcp_uptime_seconds ${process.uptime().toFixed(0)}`,
`mcp_memory_bytes ${process.memoryUsage().heapUsed}`,
`mcp_connections_active ${conexoes.size}`,
].join('\n'))
})Formato Prometheus (nome_metrica{labels} valor). Com um scraper Prometheus e Grafana, você visualiza essas métricas em tempo real.
Aprofundamento Técnico
Gerenciamento de Sessões e Memory Leaks
const conexoes = new Map<string, SSEServerTransport>()
app.get('/sse', async (req, res) => {
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2)}`
const transport = new SSEServerTransport('/messages', res)
const server = criarMCPServer()
conexoes.set(sessionId, transport)
// CRÍTICO: limpar sessão quando cliente desconectar
res.on('close', () => {
conexoes.delete(sessionId)
console.log(`Conexão encerrada: ${sessionId}`)
})
await server.connect(transport)
})
res.on('close') é o listener de desconexão. Sem ele, o Map conexoes cresce indefinidamente (memory leak). Em produção, adicione também um TTL por sessão:
// Após 30 minutos sem atividade, limpar sessão
const TIMEOUT_MS = 30 * 60 * 1000
const sessionTimers = new Map<string, NodeJS.Timeout>()
function resetTimer(sessionId: string) {
clearTimeout(sessionTimers.get(sessionId))
sessionTimers.set(sessionId, setTimeout(() => {
conexoes.delete(sessionId)
sessionTimers.delete(sessionId)
}, TIMEOUT_MS))
}Containerização com Docker
# Etapa 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY tsconfig.json .
COPY *.ts .
RUN npx tsc
# Etapa 2: runtime (imagem menor)
FROM node:20-alpine AS runtime
WORKDIR /app
# Usuário não-root (segurança)
RUN addgroup -S mcp && adduser -S mcp -G mcp
USER mcp
# Apenas o necessário para runtime
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Healthcheck integrado no container
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:${PORT:-3001}/health || exit 1
EXPOSE 3001
ENV PORT=3001
ENV NODE_ENV=production
CMD ["node", "dist/starter.js"]Boas práticas neste Dockerfile: 1. Multi-stage build: imagem final sem devDependencies e arquivos TypeScript (menor e mais segura) 2. node:20-alpine: alpine = imagem mínima (~5MB vs ~1GB para node:20) 3. Usuário não-root: USER mcp — se o container for comprometido, o atacante não tem acesso root 4. HEALTHCHECK: Kubernetes e Docker Compose usam isso para verificar saúde 5. ENV NODE_ENV=production: ativa otimizações do Node.js
Conectando MCP Inspector ao Server
O MCP Inspector é uma ferramenta oficial para testar servidores MCP:
# Terminal 1: rodar o server
npx ts-node starter.ts
# Terminal 2: conectar inspector
npx @modelcontextprotocol/inspector http://localhost:3001/sseO Inspector permite: - Listar tools disponíveis - Chamar tools com argumentos custom - Ver o JSON-RPC 2.0 trafegado - Debug de respostas
Exemplos Anotados
Exemplo 1: Server Completo com Autenticação Bearer
import express from 'express'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const app = express()
app.use(express.json())
const API_KEY = process.env.MCP_API_KEY ?? 'dev-key-insegura'
// Middleware de autenticação
function autenticar(req: express.Request, res: express.Response, next: express.NextFunction) {
const auth = req.headers.authorization
if (!auth?.startsWith('Bearer ') || auth.slice(7) !== API_KEY) {
return res.status(401).json({ erro: 'Não autorizado' })
}
next()
}
function criarMCPServer(): Server {
const server = new Server(
{ name: 'producao-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
)
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'ping',
description: 'Verifica se o server está respondendo',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'obter_info_sistema',
description: 'Retorna informações do sistema: versão, uptime, memória',
inputSchema: { type: 'object', properties: {} },
},
],
}))
server.setRequestHandler(CallToolRequestSchema, async ({ params: { name } }) => {
if (name === 'ping') {
return { content: [{ type: 'text', text: `pong — ${new Date().toISOString()}` }] }
}
if (name === 'obter_info_sistema') {
return {
content: [{
type: 'text',
text: JSON.stringify({
versao: '1.0.0',
uptime_s: process.uptime().toFixed(0),
memoria_mb: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1),
node_version: process.version,
timestamp: new Date().toISOString(),
}, null, 2),
}],
}
}
throw new Error(`Tool desconhecida: ${name}`)
})
return server
}
const conexoes = new Map<string, SSEServerTransport>()
// Saúde (sem auth — load balancer não envia token)
app.get('/health', (_, res) => {
res.json({
status: 'ok',
conexoes_ativas: conexoes.size,
uptime_s: process.uptime().toFixed(0),
timestamp: new Date().toISOString(),
})
})
// Métricas Prometheus (sem auth — Prometheus scraper interno)
app.get('/metrics', (_, res) => {
res.type('text/plain').send([
`mcp_uptime_seconds ${process.uptime().toFixed(0)}`,
`mcp_memory_bytes ${process.memoryUsage().heapUsed}`,
`mcp_connections_active ${conexoes.size}`,
].join('\n'))
})
// Endpoint SSE com autenticação
app.get('/sse', autenticar, async (req, res) => {
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2)}`
console.log(`Nova conexão MCP: ${sessionId}`)
const transport = new SSEServerTransport('/messages', res)
const server = criarMCPServer()
conexoes.set(sessionId, transport)
res.on('close', () => {
conexoes.delete(sessionId)
console.log(`Conexão encerrada: ${sessionId} (total: ${conexoes.size})`)
})
await server.connect(transport)
})
// Endpoint POST com autenticação
app.post('/messages', autenticar, async (req, res) => {
const sessionId = req.headers['x-session-id'] as string
const transport = conexoes.get(sessionId)
if (!transport) return res.status(404).json({ erro: 'Sessão não encontrada' })
await transport.handlePostMessage(req, res)
})
const PORT = parseInt(process.env.PORT ?? '3001')
app.listen(PORT, () => {
console.log(`MCP Server rodando em http://localhost:${PORT}`)
console.log(`Health: http://localhost:${PORT}/health`)
console.log(`SSE: http://localhost:${PORT}/sse (requer Authorization: Bearer ${API_KEY})`)
})Exemplo 2: Cliente que Conecta ao Server HTTP
import Anthropic from '@anthropic-ai/sdk'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
async function consultarServerHTTP(query: string) {
const anthropic = new Anthropic()
// Conectar ao server HTTP (em vez de InMemoryTransport)
const transport = new SSEClientTransport(
new URL('http://localhost:3001/sse'),
{
requestInit: {
headers: { Authorization: `Bearer ${process.env.MCP_API_KEY}` }
}
}
)
const client = new Client({ name: 'cliente-http', version: '1.0.0' }, { capabilities: {} })
await client.connect(transport)
const { tools: mcpTools } = await client.listTools()
const tools: Anthropic.Tool[] = mcpTools.map(t => ({
name: t.name,
description: t.description ?? '',
input_schema: t.inputSchema as Anthropic.Tool['input_schema'],
}))
const mensagens: Anthropic.MessageParam[] = [{ role: 'user', content: query }]
while (true) {
const r = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 300,
tools,
messages: mensagens,
})
mensagens.push({ role: 'assistant', content: r.content })
if (r.stop_reason === 'end_turn') {
const text = r.content.find(b => b.type === 'text')
console.log('Resposta:', text?.type === 'text' ? text.text : '')
break
}
const results: Anthropic.ToolResultBlockParam[] = []
for (const b of r.content) {
if (b.type !== 'tool_use') continue
const res = await client.callTool({ name: b.name, arguments: b.input as Record<string, unknown> })
results.push({
type: 'tool_result', tool_use_id: b.id,
content: res.content.filter(c => c.type === 'text').map(c => c.text).join('')
})
}
mensagens.push({ role: 'user', content: results })
}
await client.close()
}
await consultarServerHTTP('O server está funcionando? Quais informações você tem?')Padrões e Armadilhas
Padrões
Padrão 1: Um server MCP por instância Express (não singleton)
// O starter cria um novo server por conexão SSE:
app.get('/sse', async (req, res) => {
const server = criarMCPServer() // novo server por conexão
await server.connect(transport)
})Isso é correto: cada conexão SSE tem seu próprio server MCP isolado. State de uma conexão não vaza para outra.
Padrão 2: Logs estruturados com sessionId
// Inclua sessionId em todos os logs para correlação
console.log(JSON.stringify({
level: 'info',
sessionId,
event: 'connection_open',
timestamp: new Date().toISOString(),
}))Em produção, esses logs vão para Datadog, CloudWatch ou Loki. Sem sessionId, é impossível rastrear problemas.
Padrão 3: /health sem autenticação, /metrics e /sse com autenticação Load balancers e probes Kubernetes chamam /health sem token. Metrics scrapers internos também. Mas /sse e /messages devem exigir autenticação.
Armadilhas
⚠️ Armadilha 1: Não limpar sessões = memory leak
// SEM cleanup (ERRADO):
conexoes.set(sessionId, transport)
// O Map cresce para sempre
// COM cleanup (CORRETO):
conexoes.set(sessionId, transport)
res.on('close', () => conexoes.delete(sessionId))⚠️ Armadilha 2: PORT hardcoded
// ERRADO: não funciona em container
const PORT = 3001
// CORRETO: sempre leia de env
const PORT = parseInt(process.env.PORT ?? '3001')Em Kubernetes, o PORT pode ser configurado externamente. process.env.PORT é padrão universal.
⚠️ Armadilha 3: Rodar como root no container
# ERRADO: container roda como root
FROM node:20-alpine
CMD ["node", "dist/server.js"]
# CORRETO: usuário não-root
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
USER app
CMD ["node", "dist/server.js"]Se o container for comprometido via vulnerabilidade, usuário não-root limita o blast radius.
Se não for realizar o laboratório, pule para o próximo capítulo.
Ponte para o Lab
O starter desta unidade é um MCP server HTTP+SSE completo — sem TODOs de código.
Parte 1: Verificar o server
npx ts-node starter.ts
# Em outro terminal:
curl http://localhost:3001/health
curl http://localhost:3001/metricsParte 2: Conectar MCP Inspector
npx @modelcontextprotocol/inspector http://localhost:3001/sseUse o Inspector para chamar as tools ping e obter_info_sistema manualmente.
Parte 3: Adicionar autenticação Bearer Implemente o middleware autenticar do Exemplo 1. Verifique que /sse sem token retorna 401, e com token retorna a conexão SSE.
Parte 4: Dockerfile (opcional) Crie o Dockerfile do Aprofundamento Técnico. Construa e rode o container:
docker build -t mcp-server .
docker run -p 3001:3001 -e MCP_API_KEY=minha-chave mcp-serverAgora você está pronto para o lab.