Files
claude-plugins/infraestrutura/skills/mcp-dev/references/best-practices.md
T
ealmeida 18e48db490 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
2026-08-19 09:16:31 +01:00

8.9 KiB

MCP Best Practices - Referência Completa

Extraído de auditorias a 27+ projectos MCP (500+ ferramentas). Ver também: PROC-MCP-Desenvolvimento.md


Nomenclatura de Tools

Padrão: {serviço}_{acção}_{recurso} em snake_case

# Correcto
get_customer_notes
create_project_task
list_invoice_items
delete_session_token

# Errado
getCustomerNotes     (camelCase)
customer-notes-get   (kebab-case)
get_customer_notes_with_all_billing_details_and_history  (>40 chars)

Limites obrigatórios:

  • Tool name: ≤ 40 caracteres
  • Total com prefixo mcp__<servidor>__<tool>: ≤ 64 caracteres

Validação em código:

function validateToolName(name: string): void {
  if (name.length > 40) {
    throw new Error(`Tool "${name}" excede limite de 40 chars (${name.length})`);
  }
}
allTools.forEach(t => validateToolName(t.name));

Annotations Obrigatórias

Cada tool deve declarar as suas annotations para orientar o modelo:

{
  name: 'get_customer',
  description: 'Obtém dados de um cliente pelo ID',
  annotations: {
    readOnlyHint: true,       // Não modifica estado
    destructiveHint: false,   // Não é destrutiva
    idempotentHint: true,     // Mesmo resultado em chamadas repetidas
    openWorldHint: false,     // Opera em dados internos (fechado)
  },
  inputSchema: { ... }
}

Referência rápida:

Annotation Tipo Significado
readOnlyHint boolean Leitura sem efeitos secundários
destructiveHint boolean Pode apagar/substituir dados
idempotentHint boolean Resultado idêntico em múltiplas chamadas
openWorldHint boolean Interage com sistemas externos/web

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 — 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.

// 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 — API de alto nível v2, a única aceite
import { McpServer } from '@modelcontextprotocol/server';
const server = new McpServer({ name: 'mcp-<nome>', version: '1.0.0' });
server.registerTool('get_customer', { description: '...', inputSchema: z.object({...}) }, async (args) => {...});

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).


Formatos de Resposta

Dual Format (JSON + Markdown)

Para máxima compatibilidade, devolver ambos:

return {
  content: [
    {
      type: 'text',
      text: `# Cliente ${data.name}\n\n**ID:** ${data.id}\n**Email:** ${data.email}`
    }
  ],
  structuredContent: {
    id: data.id,
    name: data.name,
    email: data.email,
    status: data.status
  }
};

Erros Accionáveis

// ERRADO: mensagem genérica
throw new Error('Falha ao obter dados');

// CORRECTO: mensagem accionável com contexto e próximos passos
return {
  content: [{
    type: 'text',
    text: [
      `Erro ao obter cliente ID ${customerId}.`,
      `Causa: ${error.message}`,
      `Verificar: 1) ID existe na BD  2) Permissões de acesso  3) Conexão à BD`
    ].join('\n')
  }],
  isError: true
};

Validação com Zod

import * as z from 'zod/v4';

const GetCustomerSchema = z.object({
  customer_id: z.number().int().positive(),
  include_invoices: z.boolean().optional().default(false),
  date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
});

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

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 para o detalhe completo (ProtocolError para resources/prompts, subclasses tipadas).

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: `Cliente ${customer_id} não encontrado. Verificar: 1) ID existe na BD  2) Permissões de acesso` }],
        isError: true,
      };
    }
    return { content: [{ type: 'text', text: `Cliente: ${customer.name}` }], structuredContent: customer };
  },
);

Logging

// 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`);

Segurança (Checklist Pré-Commit)

  • 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:

# Interpolação SQL perigosa
grep -rn '`.*\${.*}`' src/ | grep -i 'select\|insert\|update\|delete\|order'

# Math.random em produção
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 '\${'

Padrões SQL (Perfex CRM)

// Perfex usa 0 como "não definido", NÃO NULL
client_id || 0,
project_id || 0,

// Excepção: source em leads precisa ID válido
const [defaultSource] = await db.query(
  'SELECT id FROM tblleads_sources ORDER BY id ASC LIMIT 1'
);
leadSource = source || defaultSource?.id || 1;

Tabelas Perfex (prefixo tbl):

tblclients, tblprojects, tbltasks, tblinvoices,
tblleads, tblleads_sources, tblstaff, tbltaskstimers, tblexpenses

Transportes

Transporte Estado Uso
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+):

Porta MCP
3200 outline-postgresql
3201+ disponíveis

Portas SSE reservadas (3100+):

Porta MCP
3100 desk-crm-v3
3101 mem0
3102 wikijs
3103 ssh-unified
3105 n8n
3106 cwp
3107 youtube-research
3108 moloni
3109+ disponíveis

best-practices.md v3.0 | 2026-08-19