From 18e48db49005a7259b45fa56a0b3d2ea3c789c60 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Wed, 19 Aug 2026 09:16:31 +0100 Subject: [PATCH] =?UTF-8?q?feat(infraestrutura):=20mcp-dev=20v3.0=20?= =?UTF-8?q?=E2=80=94=20SDK=20TypeScript=20v2=20e=20transporte=20HTTP=20obr?= =?UTF-8?q?igat=C3=B3rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- infraestrutura/.claude-plugin/plugin.json | 2 +- infraestrutura/skills/mcp-dev/SKILL.md | 99 ++++-- .../mcp-dev/references/best-practices.md | 106 +++--- .../mcp-dev/references/evaluation-guide.md | 23 +- .../skills/mcp-dev/references/templates.md | 331 ++++++------------ 5 files changed, 240 insertions(+), 321 deletions(-) diff --git a/infraestrutura/.claude-plugin/plugin.json b/infraestrutura/.claude-plugin/plugin.json index cee9d9e..aa2e84a 100644 --- a/infraestrutura/.claude-plugin/plugin.json +++ b/infraestrutura/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "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).", - "version": "1.2.2", + "version": "1.3.0", "author": { "name": "Descomplicar - Crescimento Digital", "url": "https://descomplicar.pt" diff --git a/infraestrutura/skills/mcp-dev/SKILL.md b/infraestrutura/skills/mcp-dev/SKILL.md index fa431bc..3f927b5 100644 --- a/infraestrutura/skills/mcp-dev/SKILL.md +++ b/infraestrutura/skills/mcp-dev/SKILL.md @@ -1,12 +1,24 @@ --- 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 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 Consultar ANTES de executar: @@ -48,7 +60,7 @@ mcp__notebooklm__notebook_query({ 1. Consultar NotebookLM (notebook acima) 2. Verificar MCPs existentes — evitar duplicaçã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 **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 1. Scaffold local no desktop: `/media/ealmeida/Dados/Dev//` -2. Implementar tools com **annotations obrigatórias** e validação Zod -3. Definir capabilities completas (tools + resources + prompts) +2. Implementar tools com **annotations obrigatórias** e validação Zod (`z.object(...)` de `zod/v4`) +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) 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 -annotations: { - readOnlyHint: true, // não modifica estado - destructiveHint: false, // não é destrutiva - idempotentHint: true, // mesmo resultado em chamadas repetidas - openWorldHint: false, // opera em sistema fechado (interno) -} -``` - -Usar `inferAnnotations()` do SDK para inferência automática: -```typescript -import { inferAnnotations } from '@modelcontextprotocol/sdk/server/utils.js'; -const annotated = inferAnnotations(tool); -``` - -**Capabilities — sempre completas (anti-erro 471):** -```typescript -capabilities: { tools: {}, resources: {}, prompts: {} } +server.registerTool( + 'get_customer', + { + description: 'Obtém dados de um cliente pelo ID', + inputSchema: z.object({ customer_id: z.number().int().positive() }), + annotations: { + readOnlyHint: true, // não modifica estado + destructiveHint: false, // não é destrutiva + idempotentHint: true, // mesmo resultado em chamadas repetidas + openWorldHint: false, // opera em sistema fechado (interno) + }, + }, + async ({ customer_id }) => { /* ... */ } +); ``` **Dual format de resposta:** @@ -119,13 +128,15 @@ return { 2. `pnpm audit` — sem vulnerabilidades críticas 3. Validar limites de nomes de tools (máx. 40 chars) 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 **Grep de validação rápida:** ```bash grep -rn '`.*\${.*}`' src/ | grep -i 'select\|insert\|update\|delete' 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 @@ -162,12 +173,11 @@ npm run eval:ci # build + evaluations (para CI/CD) ├── package.json ├── tsconfig.json ├── src/ -│ ├── index.ts # Entry point stdio -│ ├── index-http.ts # Entry point HTTP -│ └── tools/ -│ └── index.ts # Definição de tools +│ ├── server.ts # Factory McpServer + registerTool (partilhado) +│ ├── http.ts # Entry point HTTP (createMcpHandler + Express) — obrigatório +│ └── stdio.ts # Entry point stdio (serveStdio) — só se excepção justificada ├── eval/ -│ └── run-evals.ts # Evaluations automáticas +│ └── run-evals.ts # Evaluations automáticas (Client + StreamableHTTPClientTransport in-process) ├── .env.example ├── README.md └── .gitignore @@ -179,7 +189,7 @@ npm run eval:ci # build + evaluations (para CI/CD) ## Configuração -**~/.claude.json (HTTP — recomendado):** +**~/.claude.json (HTTP — obrigatório):** ```json { "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 { "mcpServers": { - "": { "command": "node", "args": ["/home/ealmeida/mcp-servers//dist/index.js"] } + "": { "command": "node", "args": ["/home/ealmeida/mcp-servers//dist/stdio.js"] } } } ``` @@ -203,9 +213,9 @@ npm run eval:ci # build + evaluations (para CI/CD) | Transporte | Estado | Quando usar | |------------|--------|-------------| -| StreamableHTTP | **Recomendado** | Novos MCPs, acesso remoto, gateway | -| stdio | Válido | Claude Code local, scripts únicos | -| SSE | Deprecated | Retrocompatibilidade apenas | +| Streamable HTTP (SDK v2) | **Obrigatório** | Todos os MCPs novos — acesso remoto, gateway, múltiplos clientes | +| 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 — nunca usar em MCP novo | --- @@ -224,9 +234,9 @@ npm run eval:ci # build + evaluations (para CI/CD) | Ficheiro | Conteúdo | |----------|----------| -| `references/best-practices.md` | Nomenclatura, annotations, segurança, SQL Perfex | -| `references/evaluation-guide.md` | Guia completo de evaluations, script automatizado | -| `references/templates.md` | Templates TypeScript prontos (stdio, HTTP, systemd) | +| `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 (Client v2 in-process) | +| `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. @@ -234,6 +244,16 @@ npm run eval:ci # build + evaluations (para CI/CD) ## 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) - Refactorização: SKILL.md de 1163 para <500 linhas (progressive disclosure) - **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 {"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.* diff --git a/infraestrutura/skills/mcp-dev/references/best-practices.md b/infraestrutura/skills/mcp-dev/references/best-practices.md index d8d58f3..827d366 100644 --- a/infraestrutura/skills/mcp-dev/references/best-practices.md +++ b/infraestrutura/skills/mcp-dev/references/best-practices.md @@ -65,38 +65,27 @@ Cada tool deve declarar as suas annotations para orientar o modelo: | `idempotentHint` | boolean | Resultado idêntico em múltiplas chamadas | | `openWorldHint` | boolean | Interage com sistemas externos/web | -**Inferência automática com `inferAnnotations()`:** - -```typescript -import { inferAnnotations } from '@modelcontextprotocol/sdk/server/utils.js'; - -// Inferir automaticamente a partir do nome e descrição da tool -const annotated = inferAnnotations(tool); -``` +**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. --- -## 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 -// ERRADO: capabilities incompletas -> erro 471 -capabilities: { tools: {} } +// ERRADO — API de baixo nível v1, proibida nesta casa +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 -capabilities: { - tools: {}, - resources: {}, - prompts: {} -} +// CORRECTO — API de alto nível v2, a única aceite +import { McpServer } from '@modelcontextprotocol/server'; +const server = new McpServer({ name: 'mcp-', version: '1.0.0' }); +server.registerTool('get_customer', { description: '...', inputSchema: z.object({...}) }, async (args) => {...}); ``` -**Handlers mínimos obrigatórios:** -```typescript -server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...] })); -server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] })); -server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] })); -server.setRequestHandler(CallToolRequestSchema, async (req) => { ... }); -``` +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). --- @@ -148,7 +137,7 @@ return { ## Validação com Zod ```typescript -import { z } from 'zod'; +import * as z from 'zod/v4'; const GetCustomerSchema = z.object({ 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(), }); -// No handler -const validated = GetCustomerSchema.parse(args); -// Zod lança ZodError automaticamente se inválido +server.registerTool( + 'get_customer', + { 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 -```typescript -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; +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). - try { - const result = await handleTool(name, args); - return result; - } catch (error) { - if (error instanceof z.ZodError) { +```typescript +server.registerTool( + 'get_customer', + { description: 'Obtém dados de um cliente pelo ID', inputSchema: z.object({ customer_id: z.number() }) }, + async ({ customer_id }) => { + const customer = await db.getCustomer(customer_id); + if (!customer) { + // Devolver isError explicitamente dá controlo total sobre o `content` return { - content: [{ - type: 'text', - text: `Parâmetros inválidos:\n${error.errors.map(e => ` - ${e.path.join('.')}: ${e.message}`).join('\n')}` - }], - isError: true + content: [{ type: 'text', text: `Cliente ${customer_id} não encontrado. Verificar: 1) ID existe na BD 2) Permissões de acesso` }], + isError: true, }; } - - return { - content: [{ - type: 'text', - text: `Erro: ${error instanceof Error ? error.message : String(error)}` - }], - isError: true - }; - } -}); + return { content: [{ type: 'text', text: `Cliente: ${customer.name}` }], structuredContent: customer }; + }, +); ``` --- @@ -199,7 +186,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { ## Logging ```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}] 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? - [ ] 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 - [ ] Recursos (pool.connect) têm `finally` com release - [ ] Cleanup (ROLLBACK) tem try-catch próprio - [ ] `crypto.randomBytes()` em vez de `Math.random()` - [ ] Secrets em variáveis de ambiente, nunca hardcoded - [ ] `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:** ```bash @@ -227,6 +218,9 @@ grep -rn 'Math.random' src/ # connect() sem finally 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 | |------------|--------|-----| -| StreamableHTTP | Recomendado | Novos MCPs, acesso remoto | -| stdio | Válido | Claude Code local, scripts | -| SSE | Deprecated | Retrocompatibilidade apenas | +| Streamable HTTP (SDK v2) | **Obrigatório** | Todos os MCPs novos — acesso remoto, gateway | +| 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 — nunca em MCP novo | **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* diff --git a/infraestrutura/skills/mcp-dev/references/evaluation-guide.md b/infraestrutura/skills/mcp-dev/references/evaluation-guide.md index f91ad34..462f805 100644 --- a/infraestrutura/skills/mcp-dev/references/evaluation-guide.md +++ b/infraestrutura/skills/mcp-dev/references/evaluation-guide.md @@ -130,12 +130,15 @@ npx @modelcontextprotocol/inspector # 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 // eval/run-evals.ts -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { createServer } from '../src/server.js'; // a mesma factory usada em src/http.ts interface EvalResult { id: string; @@ -160,7 +163,6 @@ async function runEval( const result = await client.callTool({ name: toolName, arguments: input }); const duration = Date.now() - start; - // Verificar assertions if (assertions.notError && result.isError) { return { id: toolName, passed: false, duration, error: 'Esperava sucesso, recebeu erro' }; } @@ -170,7 +172,7 @@ async function runEval( } if (assertions.containsFields) { - const text = result.content[0]?.text || ''; + const text = (result.content?.[0] as { text?: string })?.text || ''; for (const field of assertions.containsFields) { if (!text.includes(field)) { return { id: toolName, passed: false, duration, error: `Campo "${field}" não encontrado` }; @@ -191,12 +193,12 @@ async function runEval( // Executar todas as evaluations async function main() { - const transport = new StdioClientTransport({ - command: 'node', - args: ['dist/index.js'] + const handler = createMcpHandler(createServer); + const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + 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); const results: EvalResult[] = []; @@ -226,6 +228,7 @@ async function main() { }); await client.close(); + await handler.close(); 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* diff --git a/infraestrutura/skills/mcp-dev/references/templates.md b/infraestrutura/skills/mcp-dev/references/templates.md index b5ed8f2..473eda5 100644 --- a/infraestrutura/skills/mcp-dev/references/templates.md +++ b/infraestrutura/skills/mcp-dev/references/templates.md @@ -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 `` 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-", "version": "1.0.0", "type": "module", - "main": "dist/index.js", + "main": "dist/http.js", "scripts": { "build": "tsc", - "start": "node dist/index.js", - "start:http": "node dist/index-http.js", - "dev": "tsx src/index.ts", - "dev:http": "tsx src/index-http.ts", + "start": "node dist/http.js", + "dev": "tsx src/http.ts", "eval": "tsx eval/run-evals.ts", "eval:ci": "npm run build && npm run eval", "reload:http": "npm run build && systemctl --user restart mcp- && sleep 2 && curl -s http://127.0.0.1:32XX/health" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "zod": "^3.22.0" + "@modelcontextprotocol/server": "^2.0.0", + "@modelcontextprotocol/express": "^2.0.0", + "@modelcontextprotocol/node": "^2.0.0", + "express": "^4.19.0", + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^20.0.0", - "typescript": "^5.0.0", - "tsx": "^4.0.0" + "@types/express": "^4.17.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 @@ -42,8 +49,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "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 -#!/usr/bin/env node /** - * MCP + * MCP — factory partilhada por HTTP e (excepcionalmente) stdio. * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, - ListResourcesRequestSchema, - ListPromptsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; -import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; -// --- Schemas de validação --- -const ExampleSchema = z.object({ - param: z.string().min(1, 'param é obrigatório'), -}); +export function createServer(): McpServer { + const server = new McpServer({ name: 'mcp-', version: '1.0.0' }); -// --- Definição de tools --- -const allTools = [ - { - name: 'example_get_item', // {serviço}_{acção}_{recurso} - description: 'Obtém um item pelo ID', - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - inputSchema: { - type: 'object' as const, - properties: { - param: { type: 'string', description: 'ID do item' }, + server.registerTool( + 'example_get_item', // {serviço}_{acção}_{recurso}, snake_case, <=40 chars + { + description: 'Obtém um item pelo ID', + inputSchema: z.object({ + param: z.string().min(1).describe('ID do item'), + }), + outputSchema: z.object({ + id: z.string(), + status: z.string(), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, }, - required: ['param'], }, - handler: async (args: unknown) => { - const { param } = ExampleSchema.parse(args); - // ... lógica aqui + async ({ param }) => { + // ... lógica aqui — nunca interpolar `param` num comando shell/SQL; + // se este tool fizer ponte SSH/CLI, ver best-practices.md §Segurança. return { - content: [{ type: 'text' as const, text: `Item: ${param}` }], + content: [{ type: 'text', text: `Item: ${param}` }], structuredContent: { id: param, status: 'found' }, }; }, - }, -]; + ); -// --- Servidor --- -const server = new Server( - { name: 'mcp-', 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 running on stdio'); + return server; } - -main().catch(console.error); ``` --- -## src/index-http.ts (StreamableHTTP — padrão remoto) +## src/http.ts — entry point HTTP (obrigatório) ```typescript -#!/usr/bin/env node /** - * MCP - HTTP Server Mode + * MCP — HTTP Server (Streamable HTTP) * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - ListToolsRequestSchema, - CallToolRequestSchema, - ListResourcesRequestSchema, - ListPromptsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; -import * as http from 'http'; -import { URL } from 'url'; -import { randomUUID } from 'crypto'; +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { createServer } from './server.js'; -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 STATEFUL = process.env.MCP_STATEFUL !== 'false'; -const sessions = new Map(); +const handler = createMcpHandler(createServer); +const node = toNodeHandler(handler); -// Importar allTools do mesmo local que stdio -import { allTools } from './tools/index.js'; +// createMcpExpressApp já valida Host/Origin contra DNS rebinding no bind 127.0.0.1 +const app = createMcpExpressApp(); -function createMcpServer(): Server { - const server = new Server( - { name: 'mcp-', version: '1.0.0' }, - { capabilities: { tools: {}, resources: {}, prompts: {} } } - ); +app.all('/mcp', (req, res) => void node(req, res, req.body)); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: allTools.map(t => ({ - name: t.name, - description: t.description, - annotations: t.annotations, - inputSchema: t.inputSchema, - })), - })); +app.get('/health', (_req, res) => { + res.json({ status: 'ok', name: 'mcp-', version: '1.0.0' }); +}); - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] })); - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] })); +const httpServer = app.listen(PORT, HOST, () => { + console.log(`MCP HTTP v1.0.0`); + console.log(` Endpoint: http://${HOST}:${PORT}/mcp`); + console.log(` Health: http://${HOST}:${PORT}/health`); +}); - 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) { - return { - content: [{ type: 'text', text: `Erro: ${error instanceof Error ? error.message : String(error)}` }], - isError: true, - }; - } - }); - - return server; +async function shutdown(): Promise { + await handler.close(); + httpServer.close(() => process.exit(0)); } - -async function main() { - 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 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); +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); ``` +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-.service @@ -308,7 +171,7 @@ After=network.target [Service] Type=simple WorkingDirectory=/home/ealmeida/mcp-servers/mcp- -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 RestartSec=5 Environment=NODE_ENV=production @@ -323,7 +186,7 @@ WantedBy=default.target --- -## Configuração ~/.claude.json +## Configuração ~/.claude.json (HTTP — obrigatório) ```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 — 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 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": { + "": { + "command": "node", + "args": ["/home/ealmeida/mcp-servers//dist/stdio.js"] + } + } +} +``` + +--- + +*templates.md v2.0 | 2026-08-19*