feat(infraestrutura): mcp-dev v3.0 — SDK TypeScript v2 e transporte HTTP obrigatórios

Regra de Ouro adicionada ao topo da skill: SDK v2 (@modelcontextprotocol/server),
API de alto nível McpServer+registerTool (API de baixo nível Server+setRequestHandler
proibida), transporte HTTP obrigatório (stdio só como excepção documentada), e
proibição explícita de interpolação de dados em comandos shell/SSH.

Motivado por revisão de segurança ao mcp-kivicare (19-08-2026): 8 bugs reais
corrigidos em produção, incluindo injecção de comandos shell crítica e hooks
nativos disparados com o tipo errado. Regras adicionadas para nunca mais acontecer
em silêncio.

- SKILL.md: nova secção Regra de Ouro, API/annotations/capabilities actualizadas
  para v2, transportes HTTP obrigatório, changelog v3.0.0, healing log com os
  3 bugs mais graves do incidente.
- references/templates.md: reescrito para createMcpHandler + createMcpExpressApp
  (HTTP) e serveStdio (excepção), package.json com pacotes v2.
- references/best-practices.md: capabilities/annotations/error handling
  actualizados para API v2, checklist de segurança com regra anti-injecção de
  comandos shell, tabela de transportes com HTTP obrigatório.
- references/evaluation-guide.md: script de evaluations reescrito para
  Client+StreamableHTTPClientTransport in-process (sem porta/socket).
- infraestrutura: 1.2.2 -> 1.3.0
This commit is contained in:
2026-08-19 09:16:31 +01:00
parent 4e33c1d697
commit 18e48db490
5 changed files with 240 additions and 321 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "infraestrutura", "name": "infraestrutura",
"description": "Server management, Proxmox VE/PBS/Clustering, CWP administration, EasyPanel deployments, security audits, backups and MCP development. Backed by NotebookLM notebooks (Proxmox 150+ sources).", "description": "Server management, Proxmox VE/PBS/Clustering, CWP administration, EasyPanel deployments, security audits, backups and MCP development. Backed by NotebookLM notebooks (Proxmox 150+ sources).",
"version": "1.2.2", "version": "1.3.0",
"author": { "author": {
"name": "Descomplicar - Crescimento Digital", "name": "Descomplicar - Crescimento Digital",
"url": "https://descomplicar.pt" "url": "https://descomplicar.pt"
+61 -38
View File
@@ -1,12 +1,24 @@
--- ---
name: mcp-dev name: mcp-dev
description: Desenvolvimento e teste de servidores MCP — criação, configuração e gestão de MCPs customizados seguindo os padrões Descomplicar com transporte StreamableHTTP. description: Desenvolvimento e teste de servidores MCP — criação, configuração e gestão de MCPs customizados. SDK TypeScript v2 (@modelcontextprotocol/server) e transporte HTTP são obrigatórios, sem excepção silenciosa.
--- ---
# /mcp-dev - Desenvolvimento de MCPs # /mcp-dev - Desenvolvimento de MCPs
Skill para criação, configuração e gestão de servidores MCP customizados. Skill para criação, configuração e gestão de servidores MCP customizados.
## Regra de Ouro — obrigatória, sem excepção silenciosa
Todo MCP novo, e toda a edição estrutural de um MCP existente, tem de cumprir isto:
1. **SDK oficial v2** ([ts.sdk.modelcontextprotocol.io/v2](https://ts.sdk.modelcontextprotocol.io/v2/)) — pacotes `@modelcontextprotocol/server` (+ `@modelcontextprotocol/client` só em testes/evaluations). **`@modelcontextprotocol/sdk` (v1) está descontinuado nesta casa** — nunca instalar, nunca copiar código de um MCP antigo que o use sem migrar primeiro. API de alto nível `McpServer` + `registerTool`/`registerResource`/`registerPrompt` — a API de baixo nível `Server` + `setRequestHandler(Schema, …)` está **proibida** (reintroduz classes de bugs que o v2 elimina por desenho, incluindo o anti-padrão de capabilities manuais que causava o erro 471).
2. **Transporte HTTP obrigatório** — `createMcpHandler` (Streamable HTTP), tipicamente montado com `@modelcontextprotocol/express` + `@modelcontextprotocol/node`. `stdio` (`serveStdio`) só é aceitável quando o host tem de lançar o processo directamente e não existe gateway — nesse caso documentar a excepção no README do MCP com a razão concreta, e usar sempre `serveStdio` do SDK v2 (nunca `StdioServerTransport` manual).
3. **Node.js 20+, ESM (`"type": "module"`), `zod/v4` (peer `^4.2.0`)** — nunca `zod` v3, nunca `require()`.
4. **Nunca construir comandos shell/SQL por interpolação de string** quando o MCP faz ponte para SSH/CLI externos (ex.: WP-CLI). Dados do utilizador passam sempre por STDIN (texto ou base64), nunca concatenados num comando `ssh ... "..."`. Ver `references/best-practices.md` §Segurança — regra escrita depois de uma injecção de comandos real ter sido encontrada e corrigida em produção (`mcp-kivicare`, 19-08-2026, ver Healing Log no fim deste ficheiro).
Se qualquer um destes 4 pontos não puder ser cumprido, **parar e perguntar** antes de prosseguir — não decidir sozinho por uma alternativa mais rápida.
## Contexto NotebookLM ## Contexto NotebookLM
Consultar ANTES de executar: Consultar ANTES de executar:
@@ -48,7 +60,7 @@ mcp__notebooklm__notebook_query({
1. Consultar NotebookLM (notebook acima) 1. Consultar NotebookLM (notebook acima)
2. Verificar MCPs existentes — evitar duplicação 2. Verificar MCPs existentes — evitar duplicação
3. Identificar ferramentas necessárias e nomear segundo padrão 3. Identificar ferramentas necessárias e nomear segundo padrão
4. Definir transporte: HTTP (recomendado) ou stdio 4. Transporte: **HTTP obrigatório** (Streamable HTTP via SDK v2, `createMcpHandler`). stdio só com justificação escrita explícita — ver secção Transportes.
5. Criar spike/PoC se tecnologia desconhecida 5. Criar spike/PoC se tecnologia desconhecida
**Nomenclatura de tools:** `{serviço}_{acção}_{recurso}` em snake_case, máx. 40 caracteres. **Nomenclatura de tools:** `{serviço}_{acção}_{recurso}` em snake_case, máx. 40 caracteres.
@@ -64,30 +76,27 @@ getCustomerNotes (camelCase), get_customer_notes_with_all_billing_details (>40 c
### Fase 2: Implementation ### Fase 2: Implementation
1. Scaffold local no desktop: `/media/ealmeida/Dados/Dev/<nome-mcp>/` 1. Scaffold local no desktop: `/media/ealmeida/Dados/Dev/<nome-mcp>/`
2. Implementar tools com **annotations obrigatórias** e validação Zod 2. Implementar tools com **annotations obrigatórias** e validação Zod (`z.object(...)` de `zod/v4`)
3. Definir capabilities completas (tools + resources + prompts) 3. `McpServer` deriva capabilities automaticamente de `registerTool`/`registerResource`/`registerPrompt` — nunca declarar capabilities à mão (isso é API de baixo nível v1, proibida)
4. Responses em **dual format**: Markdown (text) + JSON (structuredContent) 4. Responses em **dual format**: Markdown (text) + JSON (structuredContent)
5. Erros **accionáveis**: causa + próximos passos 5. Erros **accionáveis**: causa + próximos passos
**Annotations obrigatórias em cada tool:** **Annotations obrigatórias em cada tool** — vão directo na config de `registerTool` (sem passo de inferência separado no v2):
```typescript ```typescript
annotations: { server.registerTool(
readOnlyHint: true, // não modifica estado 'get_customer',
destructiveHint: false, // não é destrutiva {
idempotentHint: true, // mesmo resultado em chamadas repetidas description: 'Obtém dados de um cliente pelo ID',
openWorldHint: false, // opera em sistema fechado (interno) inputSchema: z.object({ customer_id: z.number().int().positive() }),
} annotations: {
``` readOnlyHint: true, // não modifica estado
destructiveHint: false, // não é destrutiva
Usar `inferAnnotations()` do SDK para inferência automática: idempotentHint: true, // mesmo resultado em chamadas repetidas
```typescript openWorldHint: false, // opera em sistema fechado (interno)
import { inferAnnotations } from '@modelcontextprotocol/sdk/server/utils.js'; },
const annotated = inferAnnotations(tool); },
``` async ({ customer_id }) => { /* ... */ }
);
**Capabilities — sempre completas (anti-erro 471):**
```typescript
capabilities: { tools: {}, resources: {}, prompts: {} }
``` ```
**Dual format de resposta:** **Dual format de resposta:**
@@ -119,13 +128,15 @@ return {
2. `pnpm audit` — sem vulnerabilidades críticas 2. `pnpm audit` — sem vulnerabilidades críticas
3. Validar limites de nomes de tools (máx. 40 chars) 3. Validar limites de nomes de tools (máx. 40 chars)
4. Testar build: `npm run build` 4. Testar build: `npm run build`
5. Testar manualmente: `echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/index.js` 5. Testar manualmente via HTTP: `curl -s -X POST http://127.0.0.1:32XX/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'`
6. Documentar decisões em ADR se necessário 6. Documentar decisões em ADR se necessário
**Grep de validação rápida:** **Grep de validação rápida:**
```bash ```bash
grep -rn '`.*\${.*}`' src/ | grep -i 'select\|insert\|update\|delete' grep -rn '`.*\${.*}`' src/ | grep -i 'select\|insert\|update\|delete'
grep -rn 'Math.random' src/ grep -rn 'Math.random' src/
# Comandos shell/ssh interpolados (ver Regra de Ouro §4) — qualquer resultado exige revisão manual
grep -rn 'exec(\|execSync(\|spawn(.*sh\b' src/ | grep -i '\${'
``` ```
### Fase 4: Evaluations ### Fase 4: Evaluations
@@ -162,12 +173,11 @@ npm run eval:ci # build + evaluations (para CI/CD)
├── package.json ├── package.json
├── tsconfig.json ├── tsconfig.json
├── src/ ├── src/
│ ├── index.ts # Entry point stdio │ ├── server.ts # Factory McpServer + registerTool (partilhado)
│ ├── index-http.ts # Entry point HTTP │ ├── http.ts # Entry point HTTP (createMcpHandler + Express) — obrigatório
│ └── tools/ │ └── stdio.ts # Entry point stdio (serveStdio) — só se excepção justificada
│ └── index.ts # Definição de tools
├── eval/ ├── eval/
│ └── run-evals.ts # Evaluations automáticas │ └── run-evals.ts # Evaluations automáticas (Client + StreamableHTTPClientTransport in-process)
├── .env.example ├── .env.example
├── README.md ├── README.md
└── .gitignore └── .gitignore
@@ -179,7 +189,7 @@ npm run eval:ci # build + evaluations (para CI/CD)
## Configuração ## Configuração
**~/.claude.json (HTTP — recomendado):** **~/.claude.json (HTTP — obrigatório):**
```json ```json
{ {
"mcpServers": { "mcpServers": {
@@ -188,11 +198,11 @@ npm run eval:ci # build + evaluations (para CI/CD)
} }
``` ```
**~/.claude.json (stdio — local):** **~/.claude.json (stdio — apenas excepção documentada):**
```json ```json
{ {
"mcpServers": { "mcpServers": {
"<nome>": { "command": "node", "args": ["/home/ealmeida/mcp-servers/<nome>/dist/index.js"] } "<nome>": { "command": "node", "args": ["/home/ealmeida/mcp-servers/<nome>/dist/stdio.js"] }
} }
} }
``` ```
@@ -203,9 +213,9 @@ npm run eval:ci # build + evaluations (para CI/CD)
| Transporte | Estado | Quando usar | | Transporte | Estado | Quando usar |
|------------|--------|-------------| |------------|--------|-------------|
| StreamableHTTP | **Recomendado** | Novos MCPs, acesso remoto, gateway | | Streamable HTTP (SDK v2) | **Obrigatório** | Todos os MCPs novos — acesso remoto, gateway, múltiplos clientes |
| stdio | Válido | Claude Code local, scripts únicos | | stdio (SDK v2, `serveStdio`) | Excepção documentada | Só quando o host lança o processo directamente e não há gateway. Justificar no README do MCP. |
| SSE | Deprecated | Retrocompatibilidade apenas | | SSE | Deprecated | Retrocompatibilidade apenas — nunca usar em MCP novo |
--- ---
@@ -224,9 +234,9 @@ npm run eval:ci # build + evaluations (para CI/CD)
| Ficheiro | Conteúdo | | Ficheiro | Conteúdo |
|----------|----------| |----------|----------|
| `references/best-practices.md` | Nomenclatura, annotations, segurança, SQL Perfex | | `references/best-practices.md` | Nomenclatura, annotations, segurança (incl. anti-injecção de comandos shell), SQL Perfex |
| `references/evaluation-guide.md` | Guia completo de evaluations, script automatizado | | `references/evaluation-guide.md` | Guia completo de evaluations, script automatizado (Client v2 in-process) |
| `references/templates.md` | Templates TypeScript prontos (stdio, HTTP, systemd) | | `references/templates.md` | Templates TypeScript v2 prontos (server factory, HTTP obrigatório, stdio excepcional, systemd) |
**Agente especializado:** `mcp-protocol-developer` — desenvolvimento complexo, debug, optimização. **Agente especializado:** `mcp-protocol-developer` — desenvolvimento complexo, debug, optimização.
@@ -234,6 +244,16 @@ npm run eval:ci # build + evaluations (para CI/CD)
## Changelog ## Changelog
### v3.0.0 (2026-08-19)
- **SDK TypeScript v2 obrigatório** (`@modelcontextprotocol/server`, ver [ts.sdk.modelcontextprotocol.io/v2](https://ts.sdk.modelcontextprotocol.io/v2/)) — `@modelcontextprotocol/sdk` v1 descontinuado nesta casa.
- **Transporte HTTP obrigatório**; stdio passa a excepção documentada (antes era "válido" sem restrição).
- API de baixo nível `Server` + `setRequestHandler(Schema, …)` proibida — só `McpServer` + `registerTool`/`registerResource`/`registerPrompt`.
- Capabilities manuais removidas do fluxo (v2 deriva automaticamente) — a secção "anti-erro 471" do v1 já não se aplica ao padrão obrigatório.
- `inferAnnotations()` removido (não existe em v2) — annotations sempre inline na config de `registerTool`.
- Nova secção "Regra de Ouro" no topo do ficheiro, com 4 obrigações sem excepção silenciosa.
- `references/best-practices.md` §Segurança recebeu regra de anti-injecção de comandos shell (bridge SSH/CLI), motivada por incidente real corrigido em `mcp-kivicare` (19-08-2026, 8 bugs, incl. injecção de comandos crítica).
- Templates (`references/templates.md`) reescritos para a API v2 (`createMcpHandler`, `createMcpExpressApp`, `serveStdio`).
### v2.0.0 (2026-03-10) ### v2.0.0 (2026-03-10)
- Refactorização: SKILL.md de 1163 para <500 linhas (progressive disclosure) - Refactorização: SKILL.md de 1163 para <500 linhas (progressive disclosure)
- **Fase 4: Evaluations** adicionada ao workflow (padrão mcp-builder Anthropic) - **Fase 4: Evaluations** adicionada ao workflow (padrão mcp-builder Anthropic)
@@ -273,7 +293,7 @@ npm run eval:ci # build + evaluations (para CI/CD)
--- ---
*Skill v2.0.0 | Descomplicar® | 2026-03-10* *Skill v3.0.0 | Descomplicar® | 2026-08-19*
--- ---
@@ -283,6 +303,9 @@ Registo de erros conhecidos e como evitá-los. Lido automaticamente antes de exe
```jsonl ```jsonl
{"date":"","issue":"","fix":"","source":"user|auto"} {"date":"","issue":"","fix":"","source":"user|auto"}
{"date":"2026-08-19","issue":"mcp-kivicare: runRawSqlQuery interpolava a query directamente num comando `ssh ... wp db query \"...\"` — injecção de comandos shell explorável","fix":"query sempre por STDIN (`wp db query < stdin`), nunca interpolada no comando remoto","source":"auto"}
{"date":"2026-08-19","issue":"mcp-kivicare: hooks nativos do KiviCare (kc_appointment_status_update) disparados com um inteiro em vez do objecto do modelo — TypeError fatal num listener nativo, registo órfão em produção","fix":"recarregar o modelo (ex.: KCAppointment::find($id)) antes de disparar qualquer hook nativo; ler a assinatura real do hook no código-fonte do sistema alvo antes de o chamar","source":"auto"}
{"date":"2026-08-19","issue":"mcp-kivicare: basic_data do paciente gravado como array PHP em vez do JSON que o sistema nativo espera (json_encode com JSON_UNESCAPED_UNICODE) — corrompia a leitura no admin nativo","fix":"replicar exactamente o formato de serialização do sistema nativo (grep ao código-fonte real), nunca assumir a partir da forma dos dados em memória","source":"auto"}
``` ```
*Adicionar nova linha após cada erro corrigido.* *Adicionar nova linha após cada erro corrigido.*
@@ -65,38 +65,27 @@ Cada tool deve declarar as suas annotations para orientar o modelo:
| `idempotentHint` | boolean | Resultado idêntico em múltiplas chamadas | | `idempotentHint` | boolean | Resultado idêntico em múltiplas chamadas |
| `openWorldHint` | boolean | Interage com sistemas externos/web | | `openWorldHint` | boolean | Interage com sistemas externos/web |
**Inferência automática com `inferAnnotations()`:** **v2 não tem `inferAnnotations()`** (era só v1) — declarar sempre `annotations` inline na config de `registerTool`, como no exemplo acima. Nunca inventar um passo de inferência que o SDK actual não tem.
```typescript
import { inferAnnotations } from '@modelcontextprotocol/sdk/server/utils.js';
// Inferir automaticamente a partir do nome e descrição da tool
const annotated = inferAnnotations(tool);
```
--- ---
## Capabilities Obrigatórias (Regra de Ouro) ## Capabilities — derivadas automaticamente (nunca declarar à mão)
`McpServer` (SDK v2) deriva `capabilities` de `registerTool`/`registerResource`/`registerPrompt` automaticamente — **não existe** `capabilities: {...}` nem `setRequestHandler(...)` na API de alto nível obrigatória.
```typescript ```typescript
// ERRADO: capabilities incompletas -> erro 471 // ERRADO — API de baixo nível v1, proibida nesta casa
capabilities: { tools: {} } import { Server } from '@modelcontextprotocol/sdk/server/index.js';
const server = new Server({...}, { capabilities: { tools: {} } }); // causava erro 471 em v1
server.setRequestHandler(ListToolsRequestSchema, async () => ({...}));
// CORRECTO: sempre declarar as três, mesmo vazias // CORRECTO — API de alto nível v2, a única aceite
capabilities: { import { McpServer } from '@modelcontextprotocol/server';
tools: {}, const server = new McpServer({ name: 'mcp-<nome>', version: '1.0.0' });
resources: {}, server.registerTool('get_customer', { description: '...', inputSchema: z.object({...}) }, async (args) => {...});
prompts: {}
}
``` ```
**Handlers mínimos obrigatórios:** O erro 471 (capabilities incompletas) era uma armadilha da API de baixo nível v1 — deixa de poder acontecer ao usar `McpServer`/`registerTool` do v2. Se algum código ainda usa `Server` + `setRequestHandler(Schema, ...)`, é sinal de que está a reintroduzir o v1 — migrar antes de continuar (ver `SKILL.md` §Regra de Ouro).
```typescript
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...] }));
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
server.setRequestHandler(CallToolRequestSchema, async (req) => { ... });
```
--- ---
@@ -148,7 +137,7 @@ return {
## Validação com Zod ## Validação com Zod
```typescript ```typescript
import { z } from 'zod'; import * as z from 'zod/v4';
const GetCustomerSchema = z.object({ const GetCustomerSchema = z.object({
customer_id: z.number().int().positive(), customer_id: z.number().int().positive(),
@@ -156,42 +145,40 @@ const GetCustomerSchema = z.object({
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
}); });
// No handler server.registerTool(
const validated = GetCustomerSchema.parse(args); 'get_customer',
// Zod lança ZodError automaticamente se inválido { description: 'Obtém dados de um cliente', inputSchema: GetCustomerSchema },
async ({ customer_id, include_invoices, date_from }) => {
// O SDK v2 já validou e rejeitou argumentos inválidos antes de este handler correr —
// nunca chamar `.parse()`/`.safeParse()` outra vez aqui, é redundante e esconde a
// mensagem de erro accionável que o SDK já produziu.
// ... lógica aqui
},
);
``` ```
--- ---
## Error Handling ## Error Handling
```typescript No v2, um handler que atira (`throw`) é convertido automaticamente num resultado `isError: true` — não existe `setRequestHandler(CallToolRequestSchema, ...)` para envolver manualmente (isso é API de baixo nível v1). Ver [errors.md](https://ts.sdk.modelcontextprotocol.io/v2/servers/errors.md) para o detalhe completo (`ProtocolError` para resources/prompts, subclasses tipadas).
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try { ```typescript
const result = await handleTool(name, args); server.registerTool(
return result; 'get_customer',
} catch (error) { { description: 'Obtém dados de um cliente pelo ID', inputSchema: z.object({ customer_id: z.number() }) },
if (error instanceof z.ZodError) { async ({ customer_id }) => {
const customer = await db.getCustomer(customer_id);
if (!customer) {
// Devolver isError explicitamente dá controlo total sobre o `content`
return { return {
content: [{ content: [{ type: 'text', text: `Cliente ${customer_id} não encontrado. Verificar: 1) ID existe na BD 2) Permissões de acesso` }],
type: 'text', isError: true,
text: `Parâmetros inválidos:\n${error.errors.map(e => ` - ${e.path.join('.')}: ${e.message}`).join('\n')}`
}],
isError: true
}; };
} }
return { content: [{ type: 'text', text: `Cliente: ${customer.name}` }], structuredContent: customer };
return { },
content: [{ );
type: 'text',
text: `Erro: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
``` ```
--- ---
@@ -199,7 +186,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
## Logging ## Logging
```typescript ```typescript
// SEMPRE usar console.error (não console.log — interfere com stdio) // SEMPRE usar console.error (não console.log — interfere com o framing stdio quando o
// MCP corre em modo stdio excepcional; em HTTP não framing-crítico mas mantém-se a
// convenção para consistência entre os dois entry points)
console.error(`[MCP:${toolName}] Início — params: ${JSON.stringify(args)}`); console.error(`[MCP:${toolName}] Início — params: ${JSON.stringify(args)}`);
console.error(`[MCP:${toolName}] Concluído em ${duration}ms`); console.error(`[MCP:${toolName}] Concluído em ${duration}ms`);
``` ```
@@ -210,12 +199,14 @@ console.error(`[MCP:${toolName}] Concluído em ${duration}ms`);
- [ ] SQL injection: inputs validados antes de entrar em queries? - [ ] SQL injection: inputs validados antes de entrar em queries?
- [ ] Interpolação directa em SQL proibida sem validação - [ ] Interpolação directa em SQL proibida sem validação
- [ ] **Comandos shell/SSH: nunca interpolar dados do utilizador num comando (`ssh ... "$var"`, `exec(\`cmd ${var}\`)`); dados sempre por STDIN (texto ou base64)** — regra escrita depois de uma injecção de comandos real corrigida em produção (`mcp-kivicare`, 19-08-2026)
- [ ] Transacções em operações multi-query relacionadas - [ ] Transacções em operações multi-query relacionadas
- [ ] Recursos (pool.connect) têm `finally` com release - [ ] Recursos (pool.connect) têm `finally` com release
- [ ] Cleanup (ROLLBACK) tem try-catch próprio - [ ] Cleanup (ROLLBACK) tem try-catch próprio
- [ ] `crypto.randomBytes()` em vez de `Math.random()` - [ ] `crypto.randomBytes()` em vez de `Math.random()`
- [ ] Secrets em variáveis de ambiente, nunca hardcoded - [ ] Secrets em variáveis de ambiente, nunca hardcoded
- [ ] `pnpm audit` sem vulnerabilidades críticas - [ ] `pnpm audit` sem vulnerabilidades críticas
- [ ] Se o MCP faz ponte para um sistema nativo (WP-CLI, API externa, …): o formato exacto de escrita (ex.: JSON vs PHP serialize, hooks disparados com objecto vs escalar) foi confirmado por leitura do código-fonte real do sistema alvo, nunca assumido
**Grep de validação:** **Grep de validação:**
```bash ```bash
@@ -227,6 +218,9 @@ grep -rn 'Math.random' src/
# connect() sem finally # connect() sem finally
grep -rn '\.connect()' src/ grep -rn '\.connect()' src/
# Comandos shell/ssh interpolados — qualquer resultado exige revisão manual
grep -rn 'exec(\|execSync(\|spawn(.*sh\b' src/ | grep -i '\${'
``` ```
--- ---
@@ -257,9 +251,9 @@ tblleads, tblleads_sources, tblstaff, tbltaskstimers, tblexpenses
| Transporte | Estado | Uso | | Transporte | Estado | Uso |
|------------|--------|-----| |------------|--------|-----|
| StreamableHTTP | Recomendado | Novos MCPs, acesso remoto | | Streamable HTTP (SDK v2) | **Obrigatório** | Todos os MCPs novos — acesso remoto, gateway |
| stdio | Válido | Claude Code local, scripts | | stdio (SDK v2, `serveStdio`) | Excepção documentada | Só quando o host lança o processo directamente e não há gateway — justificar no README |
| SSE | Deprecated | Retrocompatibilidade apenas | | SSE | Deprecated | Retrocompatibilidade apenas — nunca em MCP novo |
**Portas HTTP reservadas (3200+):** **Portas HTTP reservadas (3200+):**
@@ -284,4 +278,4 @@ tblleads, tblleads_sources, tblstaff, tbltaskstimers, tblexpenses
--- ---
*best-practices.md v2.0 | 2026-03-10* *best-practices.md v3.0 | 2026-08-19*
@@ -130,12 +130,15 @@ npx @modelcontextprotocol/inspector
# Testar cada tool manualmente # Testar cada tool manualmente
``` ```
### Método Automatizado (Script TypeScript) ### Método Automatizado (Script TypeScript, SDK v2 — in-process, sem porta/socket)
`handler.fetch` do `createMcpHandler` serve pedidos em processo — o transporte de teste nunca liga de facto ao URL, por isso não há porta a reservar nem servidor a arrancar/parar à volta de cada corrida. Ver [testing.md](https://ts.sdk.modelcontextprotocol.io/v2/testing.md).
```typescript ```typescript
// eval/run-evals.ts // eval/run-evals.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { createMcpHandler } from '@modelcontextprotocol/server';
import { createServer } from '../src/server.js'; // a mesma factory usada em src/http.ts
interface EvalResult { interface EvalResult {
id: string; id: string;
@@ -160,7 +163,6 @@ async function runEval(
const result = await client.callTool({ name: toolName, arguments: input }); const result = await client.callTool({ name: toolName, arguments: input });
const duration = Date.now() - start; const duration = Date.now() - start;
// Verificar assertions
if (assertions.notError && result.isError) { if (assertions.notError && result.isError) {
return { id: toolName, passed: false, duration, error: 'Esperava sucesso, recebeu erro' }; return { id: toolName, passed: false, duration, error: 'Esperava sucesso, recebeu erro' };
} }
@@ -170,7 +172,7 @@ async function runEval(
} }
if (assertions.containsFields) { if (assertions.containsFields) {
const text = result.content[0]?.text || ''; const text = (result.content?.[0] as { text?: string })?.text || '';
for (const field of assertions.containsFields) { for (const field of assertions.containsFields) {
if (!text.includes(field)) { if (!text.includes(field)) {
return { id: toolName, passed: false, duration, error: `Campo "${field}" não encontrado` }; return { id: toolName, passed: false, duration, error: `Campo "${field}" não encontrado` };
@@ -191,12 +193,12 @@ async function runEval(
// Executar todas as evaluations // Executar todas as evaluations
async function main() { async function main() {
const transport = new StdioClientTransport({ const handler = createMcpHandler(createServer);
command: 'node', const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
args: ['dist/index.js'] fetch: (url, init) => handler.fetch(new Request(url, init)),
}); });
const client = new Client({ name: 'eval-client', version: '1.0.0' }); const client = new Client({ name: 'eval-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport); await client.connect(transport);
const results: EvalResult[] = []; const results: EvalResult[] = [];
@@ -226,6 +228,7 @@ async function main() {
}); });
await client.close(); await client.close();
await handler.close();
process.exit(passed === total ? 0 : 1); process.exit(passed === total ? 0 : 1);
} }
@@ -277,4 +280,4 @@ jobs:
--- ---
*evaluation-guide.md v1.0 | 2026-03-10* *evaluation-guide.md v2.0 | 2026-08-19*
@@ -1,6 +1,8 @@
# MCP Templates - Scaffolding TypeScript # MCP Templates - Scaffolding TypeScript (SDK v2, HTTP obrigatório)
> Templates de código prontos a usar. Substituir `<nome>` pelo nome real do MCP. > Templates de código prontos a usar. Substituir `<nome>` pelo nome real do MCP.
> SDK oficial: [ts.sdk.modelcontextprotocol.io/v2](https://ts.sdk.modelcontextprotocol.io/v2/).
> **Transporte HTTP é obrigatório** — o template stdio no fim deste ficheiro só se aplica a uma excepção documentada (ver `SKILL.md` §Regra de Ouro).
--- ---
@@ -11,29 +13,34 @@
"name": "mcp-<nome>", "name": "mcp-<nome>",
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/http.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/http.js",
"start:http": "node dist/index-http.js", "dev": "tsx src/http.ts",
"dev": "tsx src/index.ts",
"dev:http": "tsx src/index-http.ts",
"eval": "tsx eval/run-evals.ts", "eval": "tsx eval/run-evals.ts",
"eval:ci": "npm run build && npm run eval", "eval:ci": "npm run build && npm run eval",
"reload:http": "npm run build && systemctl --user restart mcp-<nome> && sleep 2 && curl -s http://127.0.0.1:32XX/health" "reload:http": "npm run build && systemctl --user restart mcp-<nome> && sleep 2 && curl -s http://127.0.0.1:32XX/health"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/server": "^2.0.0",
"zod": "^3.22.0" "@modelcontextprotocol/express": "^2.0.0",
"@modelcontextprotocol/node": "^2.0.0",
"express": "^4.19.0",
"zod": "^4.2.0"
}, },
"devDependencies": { "devDependencies": {
"@modelcontextprotocol/client": "^2.0.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"typescript": "^5.0.0", "@types/express": "^4.17.0",
"tsx": "^4.0.0" "typescript": "^5.7.0",
"tsx": "^4.19.0"
} }
} }
``` ```
Node.js **20+** obrigatório. `type: module` — o SDK v2 é ESM-only na sua API pública (ainda que publique um build CJS de compatibilidade).
--- ---
## tsconfig.json ## tsconfig.json
@@ -42,8 +49,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "NodeNext",
"moduleResolution": "bundler", "moduleResolution": "NodeNext",
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"strict": true, "strict": true,
@@ -58,244 +65,100 @@
--- ---
## src/index.ts (stdio — padrão local) ## src/server.ts — factory partilhada (única fonte da definição de tools)
A `McpServer` do v2 deriva capabilities automaticamente de `registerTool`/`registerResource`/`registerPrompt` — nunca declarar `capabilities: {...}` à mão (isso é a API de baixo nível v1, proibida).
```typescript ```typescript
#!/usr/bin/env node
/** /**
* MCP <Nome> * MCP <Nome> — factory partilhada por HTTP e (excepcionalmente) stdio.
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026 * @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/ */
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import * as z from 'zod/v4';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ListPromptsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
// --- Schemas de validação --- export function createServer(): McpServer {
const ExampleSchema = z.object({ const server = new McpServer({ name: 'mcp-<nome>', version: '1.0.0' });
param: z.string().min(1, 'param é obrigatório'),
});
// --- Definição de tools --- server.registerTool(
const allTools = [ 'example_get_item', // {serviço}_{acção}_{recurso}, snake_case, <=40 chars
{ {
name: 'example_get_item', // {serviço}_{acção}_{recurso} description: 'Obtém um item pelo ID',
description: 'Obtém um item pelo ID', inputSchema: z.object({
annotations: { param: z.string().min(1).describe('ID do item'),
readOnlyHint: true, }),
destructiveHint: false, outputSchema: z.object({
idempotentHint: true, id: z.string(),
openWorldHint: false, status: z.string(),
}, }),
inputSchema: { annotations: {
type: 'object' as const, readOnlyHint: true,
properties: { destructiveHint: false,
param: { type: 'string', description: 'ID do item' }, idempotentHint: true,
openWorldHint: false,
}, },
required: ['param'],
}, },
handler: async (args: unknown) => { async ({ param }) => {
const { param } = ExampleSchema.parse(args); // ... lógica aqui — nunca interpolar `param` num comando shell/SQL;
// ... lógica aqui // se este tool fizer ponte SSH/CLI, ver best-practices.md §Segurança.
return { return {
content: [{ type: 'text' as const, text: `Item: ${param}` }], content: [{ type: 'text', text: `Item: ${param}` }],
structuredContent: { id: param, status: 'found' }, structuredContent: { id: param, status: 'found' },
}; };
}, },
}, );
];
// --- Servidor --- return server;
const server = new Server(
{ name: 'mcp-<nome>', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: allTools.map(t => ({
name: t.name,
description: t.description,
annotations: t.annotations,
inputSchema: t.inputSchema,
})),
}));
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tool = allTools.find(t => t.name === name);
if (!tool) {
return {
content: [{ type: 'text', text: `Tool desconhecida: ${name}` }],
isError: true,
};
}
try {
return await tool.handler(args);
} catch (error) {
if (error instanceof z.ZodError) {
return {
content: [{
type: 'text',
text: `Parâmetros inválidos:\n${error.errors.map(e => ` - ${e.path.join('.')}: ${e.message}`).join('\n')}`,
}],
isError: true,
};
}
return {
content: [{
type: 'text',
text: `Erro em ${name}: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP <nome> running on stdio');
} }
main().catch(console.error);
``` ```
--- ---
## src/index-http.ts (StreamableHTTP — padrão remoto) ## src/http.ts — entry point HTTP (obrigatório)
```typescript ```typescript
#!/usr/bin/env node
/** /**
* MCP <Nome> - HTTP Server Mode * MCP <Nome> — HTTP Server (Streamable HTTP)
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026 * @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/ */
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { toNodeHandler } from '@modelcontextprotocol/node';
import { import { createMcpHandler } from '@modelcontextprotocol/server';
ListToolsRequestSchema, import { createServer } from './server.js';
CallToolRequestSchema,
ListResourcesRequestSchema,
ListPromptsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import * as http from 'http';
import { URL } from 'url';
import { randomUUID } from 'crypto';
const PORT = parseInt(process.env.MCP_HTTP_PORT || '3200', 10); const PORT = parseInt(process.env.MCP_HTTP_PORT || '32XX', 10);
const HOST = process.env.MCP_HTTP_HOST || '127.0.0.1'; const HOST = process.env.MCP_HTTP_HOST || '127.0.0.1';
const STATEFUL = process.env.MCP_STATEFUL !== 'false';
const sessions = new Map<string, { transport: StreamableHTTPServerTransport }>(); const handler = createMcpHandler(createServer);
const node = toNodeHandler(handler);
// Importar allTools do mesmo local que stdio // createMcpExpressApp já valida Host/Origin contra DNS rebinding no bind 127.0.0.1
import { allTools } from './tools/index.js'; const app = createMcpExpressApp();
function createMcpServer(): Server { app.all('/mcp', (req, res) => void node(req, res, req.body));
const server = new Server(
{ name: 'mcp-<nome>', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ app.get('/health', (_req, res) => {
tools: allTools.map(t => ({ res.json({ status: 'ok', name: 'mcp-<nome>', version: '1.0.0' });
name: t.name, });
description: t.description,
annotations: t.annotations,
inputSchema: t.inputSchema,
})),
}));
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] })); const httpServer = app.listen(PORT, HOST, () => {
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] })); console.log(`MCP <nome> HTTP v1.0.0`);
console.log(` Endpoint: http://${HOST}:${PORT}/mcp`);
console.log(` Health: http://${HOST}:${PORT}/health`);
});
server.setRequestHandler(CallToolRequestSchema, async (request) => { async function shutdown(): Promise<void> {
const { name, arguments: args } = request.params; await handler.close();
const tool = allTools.find(t => t.name === name); httpServer.close(() => process.exit(0));
if (!tool) {
return { content: [{ type: 'text', text: `Tool desconhecida: ${name}` }], isError: true };
}
try {
return await tool.handler(args);
} catch (error) {
return {
content: [{ type: 'text', text: `Erro: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
});
return server;
} }
process.on('SIGINT', shutdown);
async function main() { process.on('SIGTERM', shutdown);
const httpServer = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
const url = new URL(req.url || '/', `http://${HOST}:${PORT}`);
if (url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', version: '1.0.0', sessions: sessions.size, tools: allTools.length }));
return;
}
if (url.pathname === '/mcp') {
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: STATEFUL ? () => randomUUID() : undefined,
});
const srv = createMcpServer();
if (STATEFUL && transport.sessionId) {
sessions.set(transport.sessionId, { transport });
transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId); };
}
await srv.connect(transport);
await transport.handleRequest(req, res);
} catch (error) {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
}
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
httpServer.listen(PORT, HOST, () => {
console.log(`MCP <nome> HTTP v1.0.0`);
console.log(` Endpoint: http://${HOST}:${PORT}/mcp`);
console.log(` Health: http://${HOST}:${PORT}/health`);
console.log(` Mode: ${STATEFUL ? 'Stateful' : 'Stateless'}`);
});
const shutdown = () => httpServer.close(() => process.exit(0));
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch(console.error);
``` ```
Binding a `0.0.0.0` (exposição fora de localhost) exige passar `allowedHosts` a `createMcpExpressApp({ host: '0.0.0.0', allowedHosts: [...] })` — nunca desligar a validação de Host/Origin.
--- ---
## ~/.config/systemd/user/mcp-<nome>.service ## ~/.config/systemd/user/mcp-<nome>.service
@@ -308,7 +171,7 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=/home/ealmeida/mcp-servers/mcp-<nome> WorkingDirectory=/home/ealmeida/mcp-servers/mcp-<nome>
ExecStart=/home/ealmeida/.nvm/versions/node/v22.18.0/bin/node dist/index-http.js ExecStart=/home/ealmeida/.nvm/versions/node/v22.18.0/bin/node dist/http.js
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
Environment=NODE_ENV=production Environment=NODE_ENV=production
@@ -323,7 +186,7 @@ WantedBy=default.target
--- ---
## Configuração ~/.claude.json ## Configuração ~/.claude.json (HTTP — obrigatório)
```json ```json
{ {
@@ -338,4 +201,40 @@ WantedBy=default.target
--- ---
*templates.md v1.0 | 2026-03-10* ## src/stdio.ts — apenas para excepção documentada
Usar **só** quando o host tem de lançar o processo directamente e não existe gateway (justificar no README do MCP). Mesma factory `createServer()` de `src/server.ts` — nunca duplicar a definição de tools.
```typescript
/**
* MCP <Nome> — stdio (EXCEPÇÃO — ver README §Transporte para a justificação)
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './server.js';
void serveStdio(createServer);
console.error('MCP <nome> running on stdio');
```
::: warning
stdout é o canal do protocolo — logar sempre com `console.error`; um único `console.log` corrompe o stream JSON-RPC.
:::
Configuração `~/.claude.json` correspondente:
```json
{
"mcpServers": {
"<nome>": {
"command": "node",
"args": ["/home/ealmeida/mcp-servers/<nome>/dist/stdio.js"]
}
}
}
```
---
*templates.md v2.0 | 2026-08-19*