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,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.
> 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>",
"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-<nome> && 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 <Nome>
* MCP <Nome> — 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-<nome>', 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-<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');
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 <Nome> - HTTP Server Mode
* MCP <Nome> — 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<string, { transport: StreamableHTTPServerTransport }>();
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-<nome>', 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-<nome>', version: '1.0.0' });
});
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
const httpServer = app.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`);
});
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<void> {
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 <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);
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-<nome>.service
@@ -308,7 +171,7 @@ After=network.target
[Service]
Type=simple
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
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 <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*