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:
@@ -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-<nome>', 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*
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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*
|
||||
|
||||
Reference in New Issue
Block a user