Back

Cómo construir servidores MCP en TypeScript: Guía completa 2026

Todo IDE con IA que vale la pena en 2026 (Claude Desktop, Cursor, Windsurf, VS Code con Copilot) habla MCP. El Model Context Protocol se convirtió en el estándar universal para conectar asistentes de IA con herramientas externas, bases de datos y APIs. Pero acá está el tema: la mayoría de los devs siguen solo consumiendo servidores MCP, sin construir los propios.

Eso es desaprovechar una oportunidad enorme. Construir tu propio servidor MCP significa poder conectar tu asistente de IA a lo que quieras: las APIs internas de tu empresa, tu pipeline de deploy, tu base de datos, tus dashboards de monitoreo, tu CMS. Desde el momento en que construís tu primer servidor MCP, el asistente deja de ser un chatbot genérico y pasa a ser una extensión real de tu entorno de desarrollo.

Esta guía te lleva por todo lo que necesitás para construir servidores MCP de calidad producción en TypeScript. Partimos de cero y llegamos a production-ready, cubriendo los internos del protocolo, el SDK oficial, patrones reales, seguridad y estrategias de deploy.

Qué es MCP (versión de 30 segundos)

Si usaste servidores MCP pero nunca construiste uno, acá va el modelo mental:

MCP es un protocolo JSON-RPC 2.0 que estandariza cómo los clientes de IA (Claude, Cursor, etc.) se comunican con proveedores de capacidades externas (tu servidor). Pensalo como LSP (Language Server Protocol) pero para asistentes de IA en vez de editores de código.

Tu servidor MCP puede exponer tres tipos de capacidades:

  1. Tools : Funciones que la IA puede llamar (ej: "consultar la base de datos", "deployar a staging", "crear un ticket en Jira")
  2. Resources : Datos que la IA puede leer (ej: "el schema actual", "los error logs de hoy", "el estado del deploy")
  3. Prompts : Templates de prompt pre-armados que la IA puede usar (ej: "analizá este PR", "generá un plan de migración")

El cliente (Claude, Cursor) descubre lo que ofrece tu servidor, y el modelo de IA decide cuándo y cómo usar esas capacidades según el contexto de la conversación.

┌─────────────────┐         JSON-RPC 2.0         ┌─────────────────┐
│   Cliente IA    │ ◄──────────────────────────► │   Servidor MCP  │
│  (Claude, etc.) │    stdio / SSE / HTTP        │   (Tu código)   │
│                 │                               │                 │
│  - Descubre     │                               │  - Tools        │
│    capacidades  │                               │  - Resources    │
│  - Llama tools  │                               │  - Prompts      │
│  - Lee datos    │                               │  - Auth/Security│
└─────────────────┘                               └─────────────────┘

Configurando el proyecto

Vamos a construir un servidor MCP real. Crearemos un servidor que le da acceso a un PostgreSQL al asistente de IA, un caso de uso práctico que muestra todos los patrones fundamentales.

Prerrequisitos

node --version # v20+ recomendado npm --version # v10+

Inicialización

mkdir mcp-database-server cd mcp-database-server npm init -y npm install @modelcontextprotocol/sdk@latest zod # v1.27+ a marzo 2026 npm install -D typescript @types/node tsx

Configuración TypeScript:

// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true }, "include": ["src/**/*"] }

Actualizá tu package.json:

{ "type": "module", "bin": { "mcp-database-server": "./dist/index.js" }, "scripts": { "build": "tsc", "dev": "tsx src/index.ts", "start": "node dist/index.js" } }

Tu primer servidor MCP

El esqueleto base. Todo servidor MCP arranca igual:

// src/index.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const server = new McpServer({ name: 'mcp-database-server', version: '1.0.0', capabilities: { tools: {}, resources: {}, prompts: {}, }, }); // Acá agregamos tools, resources y prompts const transport = new StdioServerTransport(); await server.connect(transport); console.error('MCP Database Server running on stdio');

Notá que logeamos a stderr, no a stdout. El canal stdout está reservado para la comunicación JSON-RPC con el cliente. Si escribís algo a stdout que no sea JSON-RPC válido, rompés el protocolo. Es un error clásico de primer intento.

Agregando Tools

Los tools son la primitiva más poderosa de MCP. Le permiten a la IA ejecutar acciones en tu nombre.

Definición básica de un tool

import { z } from 'zod'; server.tool( 'query', 'Execute a read-only SQL query against the database', { sql: z.string().describe('The SQL query to execute (SELECT only)'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional() .describe('Parameterized query values'), }, async ({ sql, params }) => { const normalized = sql.trim().toUpperCase(); if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH') && !normalized.startsWith('EXPLAIN')) { return { content: [{ type: 'text', text: 'Error: Only SELECT, WITH, and EXPLAIN queries are allowed.', }], isError: true, }; } try { const result = await pool.query(sql, params); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }], }; } catch (error) { return { content: [{ type: 'text', text: `Query error: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; } } );

La anatomía:

  1. Nombre ('query') : Identificador único del tool
  2. Descripción : Crucial. La IA la lee para decidir cuándo usar el tool. Escribila como si le explicaras a un dev junior competente.
  3. Schema : Schema de Zod que define los parámetros de entrada. La IA lo usa para armar llamadas válidas.
  4. Handler : La función async que se ejecuta cuando se llama al tool.

Buenas prácticas para descripciones de tools

Las descripciones son prompt engineering. El modelo las lee para decidir qué tool usar y cómo. Descripciones malas = la IA elige el tool equivocado o pasa parámetros incorrectos.

// ❌ Malo: Muy vago server.tool('query', 'Run a query', { sql: z.string() }, handler); // ❌ Malo: Muy corto, sin restricciones server.tool('query', 'SQL query', { sql: z.string() }, handler); // ✅ Bueno: Específico, con restricciones y ejemplos server.tool( 'query', 'Execute a read-only SQL query against the PostgreSQL database. ' + 'Only SELECT, WITH (CTE), and EXPLAIN queries are allowed. ' + 'Use parameterized queries ($1, $2, ...) for user-provided values. ' + 'Returns results as JSON array of objects. ' + 'Example: SELECT id, name FROM users WHERE active = $1', { sql: z.string().describe('PostgreSQL query (SELECT/WITH/EXPLAIN only)'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional() .describe('Values for parameterized query placeholders ($1, $2, ...)'), }, handler );

Patrón multi-tool

Los servidores reales exponen múltiples tools relacionados con guardrails de seguridad apropiados:

// Solo lectura: Sin confirmación necesaria server.tool( 'list_tables', 'List all tables in the database with their column count and row count estimates', {}, async () => { const result = await pool.query(` SELECT schemaname, tablename, (SELECT count(*) FROM information_schema.columns c WHERE c.table_schema = t.schemaname AND c.table_name = t.tablename) as column_count, n_live_tup as estimated_rows FROM pg_stat_user_tables t ORDER BY schemaname, tablename `); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }] }; } ); // Solo lectura con parámetros server.tool( 'describe_table', 'Get detailed schema information for a specific table including columns, types, constraints, and indexes', { table: z.string().describe('Table name (can include schema prefix like "public.users")'), }, async ({ table }) => { const [schema, tableName] = table.includes('.') ? table.split('.') : ['public', table]; const columns = await pool.query(` SELECT column_name, data_type, is_nullable, column_default, character_maximum_length FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position `, [schema, tableName]); const indexes = await pool.query(` SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 AND tablename = $2 `, [schema, tableName]); return { content: [{ type: 'text', text: JSON.stringify({ columns: columns.rows, indexes: indexes.rows }, null, 2), }], }; } ); // Escritura: Resultados claros para que la IA confirme con el usuario server.tool( 'execute_migration', 'Execute a SQL migration statement (CREATE, ALTER, DROP). ' + 'WARNING: This modifies the database schema. ' + 'The AI should always confirm with the user before calling this tool.', { sql: z.string().describe('The DDL statement to execute'), description: z.string().describe('Human-readable description of what this migration does'), }, async ({ sql, description }) => { try { await pool.query('BEGIN'); await pool.query(sql); await pool.query('COMMIT'); return { content: [{ type: 'text', text: `Migration successful: ${description}\n\nExecuted:\n${sql}` }], }; } catch (error) { await pool.query('ROLLBACK'); return { content: [{ type: 'text', text: `Migration failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\nRolled back.` }], isError: true, }; } } );

Agregando Resources

Los resources le permiten a la IA leer datos sin ejecutar una acción. Ideales para dar contexto, información de fondo que ayuda a la IA a tomar mejores decisiones.

Resources estáticos

server.resource( 'schema', 'db://schema', { description: 'Current database schema including all tables, columns, and relationships', mimeType: 'application/json', }, async () => { const result = await pool.query(` SELECT t.table_schema, t.table_name, json_agg(json_build_object( 'column', c.column_name, 'type', c.data_type, 'nullable', c.is_nullable = 'YES', 'default', c.column_default ) ORDER BY c.ordinal_position) as columns FROM information_schema.tables t JOIN information_schema.columns c ON c.table_schema = t.table_schema AND c.table_name = t.table_name WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' GROUP BY t.table_schema, t.table_name ORDER BY t.table_name `); return { contents: [{ uri: 'db://schema', mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2) }], }; } );

Resource Templates (Resources dinámicos)

Permiten exponer datos parametrizados:

server.resource( 'table-data', 'db://tables/{tableName}/sample', { description: 'Sample data (first 10 rows) from a specific table', mimeType: 'application/json' }, async (uri) => { const tableName = uri.pathname.split('/')[2]; // Validar nombre de tabla (prevenir SQL injection) const validTable = await pool.query( `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = $1`, [tableName] ); if (validTable.rows.length === 0) throw new Error(`Table '${tableName}' not found`); const result = await pool.query(`SELECT * FROM "${tableName}" LIMIT 10`); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2) }], }; } );

Agregando Prompts

Los prompts son templates pre-armados que ayudan a la IA con tareas específicas. Se usan poco pero son muy poderosos para codificar conocimiento de dominio.

server.prompt( 'analyze-query-performance', 'Analyze a slow SQL query and suggest optimizations', { query: z.string().describe('The SQL query to analyze') }, async ({ query }) => { // Obtener el plan de ejecución const explainResult = await pool.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`); const plan = JSON.stringify(explainResult.rows[0], null, 2); // Obtener estadísticas de tablas relevantes const tables = extractTableNames(query); const stats = await Promise.all( tables.map(async (table) => { const result = await pool.query(` SELECT relname, reltuples::bigint as estimated_rows, pg_size_pretty(pg_total_relation_size(oid)) as total_size FROM pg_class WHERE relname = $1 `, [table]); return result.rows[0]; }) ); return { messages: [{ role: 'user', content: { type: 'text', text: [ `Analyze this PostgreSQL query for performance:`, '', '```sql', query, '```', '', 'Execution Plan:', '```json', plan, '```', '', 'Table Stats:', '```json', JSON.stringify(stats, null, 2), '```', '', 'Provide: bottlenecks, CREATE INDEX suggestions, query rewrites, expected improvement.', ].join('\n'), }, }], }; } );

Capas de transporte: stdio, SSE y Streamable HTTP

MCP soporta múltiples mecanismos de transporte. Elegir el correcto importa para tu modelo de deploy.

stdio (Standard I/O)

El default para servidores locales. El cliente lanza tu servidor como proceso hijo y se comunica via stdin/stdout.

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const transport = new StdioServerTransport(); await server.connect(transport);

Cuándo usarlo: Herramientas de desarrollo locales, integraciones CLI, servidores que corren en la misma máquina que el cliente.

Ventajas: Simple, sin setup de red, lifecycle automático.
Desventajas: Solo local, un cliente por instancia.

SSE (Server-Sent Events) : Deprecado

⚠️ Nota: El transporte SSE está deprecado desde la especificación MCP 2025-11-25. Los proyectos nuevos deben usar Streamable HTTP. Acá lo mostramos porque muchos servidores existentes todavía lo usan.

Para servidores remotos accesibles por HTTP:

import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import express from 'express'; const app = express(); const transports = new Map<string, SSEServerTransport>(); app.get('/sse', (req, res) => { const transport = new SSEServerTransport('/messages', res); transports.set(transport.sessionId, transport); res.on('close', () => transports.delete(transport.sessionId)); server.connect(transport); }); app.post('/messages', (req, res) => { const sessionId = req.query.sessionId as string; const transport = transports.get(sessionId); if (transport) transport.handlePostMessage(req, res); else res.status(404).send('Session not found'); }); app.listen(3001, () => console.error('MCP SSE server on port 3001'));

Cuándo usarlo: Servidores legacy, compatibilidad con clientes anteriores. Para proyectos nuevos, usá Streamable HTTP.

Streamable HTTP (Recomendado)

El transporte recomendado para todos los servidores MCP remotos nuevos en 2026. Reemplazó al SSE como transporte web estándar, ofreciendo un endpoint único, soporte para respuestas batch y streaming, manejo de sesiones, y escalamiento horizontal:

import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; const app = express(); app.use(express.json()); app.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // modo stateless }); res.on('close', () => transport.close()); await server.connect(transport); await transport.handleRequest(req, res); }); app.listen(3001);

Cuándo usarlo: Deploys serverless (AWS Lambda, Vercel Edge), APIs stateless, entornos donde las conexiones de larga vida son problemáticas.

Seguridad: No deploys un servidor RCE

Acá es donde la mayoría de los tutoriales paran, y es exactamente donde empieza el trabajo de verdad. Un servidor MCP inseguro es básicamente un endpoint de ejecución de código remoto que un modelo de IA puede activar.

Validación de inputs

Nunca confíes en los inputs del modelo. Validá todo:

server.tool( 'query', 'Execute a read-only SQL query', { sql: z.string() .max(10000) // Limitar largo del query .refine( (sql) => { const upper = sql.trim().toUpperCase(); return upper.startsWith('SELECT') || upper.startsWith('WITH') || upper.startsWith('EXPLAIN'); }, { message: 'Only SELECT, WITH, and EXPLAIN queries are allowed' } ) .refine( (sql) => { const upper = sql.toUpperCase(); const dangerous = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'TRUNCATE', 'GRANT', 'REVOKE', 'CREATE']; return !dangerous.some(kw => new RegExp(`\\b${kw}\\b`).test(upper)); }, { message: 'Query contains disallowed DDL/DML keywords' } ), }, handler );

Seguridad de la conexión a la DB

Usá una conexión read-only para los tools de consulta:

import pg from 'pg'; // Pool de conexión read-only const readPool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5, options: '-c default_transaction_read_only=on', }); // Pool separado para escritura (si lo necesitás) const writePool = new pg.Pool({ connectionString: process.env.DATABASE_WRITE_URL, max: 2, });

Rate limiting

Prevení que loops de IA descontrolados martillen tu servidor:

class RateLimiter { private calls: Map<string, number[]> = new Map(); check(toolName: string, maxCalls: number, windowMs: number): boolean { const now = Date.now(); const timestamps = this.calls.get(toolName) || []; const recent = timestamps.filter(t => now - t < windowMs); if (recent.length >= maxCalls) return false; recent.push(now); this.calls.set(toolName, recent); return true; } }

Timeouts

function withTimeout<T>(promise: Promise<T>, ms: number, op: string): Promise<T> { return Promise.race([ promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${op} timed out after ${ms}ms`)), ms) ), ]); } // En tu tool handler: const result = await withTimeout(pool.query(sql, params), 30_000, 'Database query');

Manejo de errores

Lo que separa un servidor de juguete de uno de producción.

function handleToolError(error: unknown, context: string) { console.error(`[${context}]`, error); if (error instanceof z.ZodError) { return { content: [{ type: 'text' as const, text: `Validation error: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`, }], isError: true, }; } if (error instanceof pg.DatabaseError) { return { content: [{ type: 'text' as const, text: `Database error (${error.code}): ${error.message}` + (error.detail ? `\nDetail: ${error.detail}` : '') + (error.hint ? `\nHint: ${error.hint}` : ''), }], isError: true, }; } if (error instanceof Error && error.message.includes('timed out')) { return { content: [{ type: 'text' as const, text: `Operation timed out. The query may be too complex or the database may be under heavy load.`, }], isError: true, }; } return { content: [{ type: 'text' as const, text: `Error in ${context}: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; }

El punto clave: los mensajes de error son prompts. La IA los lee y los usa para ajustar su enfoque. Incluí información accionable:

  • "Error: 42P01" : Inútil para la IA
  • "Table 'userz' not found. Did you mean 'users'? Available tables: users, posts, comments" : La IA puede auto-corregirse

Testing

Testing manual con MCP Inspector

npx @modelcontextprotocol/inspector tsx src/index.ts

Se abre un UI en el browser donde podés:

  • Ver todos los tools/resources/prompts registrados
  • Probarlos con inputs de test
  • Inspeccionar los mensajes JSON-RPC
  • Debuggear problemas de transporte

Integración con Claude Desktop

Agregá tu servidor a ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):

{ "mcpServers": { "database": { "command": "tsx", "args": ["/absolute/path/to/src/index.ts"], "env": { "DATABASE_URL": "postgresql://user:pass@localhost:5432/mydb" } } } }

Reiniciá Claude Desktop y tus tools aparecen en el ícono de herramientas.

Tests automatizados

// test/server.test.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; describe('MCP Database Server', () => { let server: McpServer; let client: Client; beforeEach(async () => { server = createServer(); client = new Client({ name: 'test-client', version: '1.0.0' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); }); test('list_tables returns table information', async () => { const result = await client.callTool({ name: 'list_tables', arguments: {} }); expect(result.isError).toBeFalsy(); const tables = JSON.parse(result.content[0].text); expect(tables).toBeInstanceOf(Array); expect(tables.length).toBeGreaterThan(0); }); test('query rejects INSERT statements', async () => { const result = await client.callTool({ name: 'query', arguments: { sql: "INSERT INTO users (name) VALUES ('hack')" }, }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Only SELECT'); }); test('query handles parameterized queries', async () => { const result = await client.callTool({ name: 'query', arguments: { sql: 'SELECT * FROM users WHERE id = $1', params: [1] }, }); expect(result.isError).toBeFalsy(); }); });

El servidor completo

Juntemos todo en un servidor listo para producción:

// src/index.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import pg from 'pg'; // ── Configuración ────────────────────────────────────────── const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { console.error('DATABASE_URL environment variable is required'); process.exit(1); } const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 5, options: '-c default_transaction_read_only=on -c statement_timeout=30000', }); pool.query('SELECT 1').catch((err) => { console.error('Failed to connect to database:', err.message); process.exit(1); }); // ── Server Setup ─────────────────────────────────────────── const server = new McpServer({ name: 'mcp-database-server', version: '1.0.0', capabilities: { tools: {}, resources: {}, prompts: {} }, }); // ── Tools ────────────────────────────────────────────────── server.tool( 'list_tables', 'List all tables in the public schema with column count and estimated row count', {}, async () => { const result = await pool.query(` SELECT tablename, (SELECT count(*) FROM information_schema.columns c WHERE c.table_schema = 'public' AND c.table_name = t.tablename) as columns, n_live_tup as estimated_rows FROM pg_stat_user_tables t WHERE schemaname = 'public' ORDER BY tablename `); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }] }; } ); server.tool( 'describe_table', 'Get detailed column info, types, constraints, and indexes for a table', { table: z.string().describe('Table name in the public schema') }, async ({ table }) => { const columns = await pool.query(` SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position `, [table]); if (columns.rows.length === 0) { return { content: [{ type: 'text', text: `Table '${table}' not found.` }], isError: true }; } const indexes = await pool.query(` SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 `, [table]); const fks = await pool.query(` SELECT kcu.column_name, ccu.table_name AS foreign_table, ccu.column_name AS foreign_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $1 `, [table]); return { content: [{ type: 'text', text: JSON.stringify({ table, columns: columns.rows, indexes: indexes.rows, foreignKeys: fks.rows }, null, 2), }], }; } ); server.tool( 'query', 'Execute a read-only SQL query. Only SELECT, WITH (CTE), and EXPLAIN allowed. ' + 'Use $1, $2, ... for parameterized values. Returns JSON array.', { sql: z.string().max(10000).describe('PostgreSQL SELECT query'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional().describe('Parameter values for $1, $2, ... placeholders'), }, async ({ sql, params }) => { const upper = sql.trim().toUpperCase(); const disallowed = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'TRUNCATE', 'CREATE', 'GRANT', 'REVOKE']; for (const kw of disallowed) { if (new RegExp(`\\b${kw}\\b`).test(upper)) { return { content: [{ type: 'text', text: `Blocked: '${kw}' statements are not allowed.` }], isError: true }; } } try { const result = await pool.query(sql, params || []); const truncated = result.rows.length > 100 ? { rows: result.rows.slice(0, 100), note: `Showing 100 of ${result.rows.length} rows` } : { rows: result.rows, total: result.rows.length }; return { content: [{ type: 'text', text: JSON.stringify(truncated, null, 2) }] }; } catch (err) { const pgErr = err as pg.DatabaseError; return { content: [{ type: 'text', text: `Query failed: ${pgErr.message}${pgErr.hint ? `\nHint: ${pgErr.hint}` : ''}` }], isError: true, }; } } ); // ── Resources ────────────────────────────────────────────── server.resource( 'schema-overview', 'db://schema', { description: 'Complete database schema overview', mimeType: 'application/json' }, async () => { const result = await pool.query(` SELECT t.tablename, json_agg(json_build_object('column', c.column_name, 'type', c.data_type, 'nullable', c.is_nullable = 'YES') ORDER BY c.ordinal_position) as columns FROM pg_stat_user_tables t JOIN information_schema.columns c ON c.table_schema = 'public' AND c.table_name = t.tablename WHERE t.schemaname = 'public' GROUP BY t.tablename ORDER BY t.tablename `); return { contents: [{ uri: 'db://schema', mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2) }] }; } ); // ── Prompts ──────────────────────────────────────────────── server.prompt( 'optimize-query', 'Analyze a SQL query and suggest performance optimizations', { query: z.string().describe('The SQL query to optimize') }, async ({ query }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `Analyze this PostgreSQL query for performance:\n\n\`\`\`sql\n${query}\n\`\`\`\n\nSuggest index optimizations, query rewrites, and explain the reasoning.`, }, }], }) ); // ── Lifecycle ────────────────────────────────────────────── process.on('SIGINT', async () => { await pool.end(); process.exit(0); }); // ── Start ────────────────────────────────────────────────── const transport = new StdioServerTransport(); await server.connect(transport); console.error('MCP Database Server running');

Patrones de deploy

Patrón 1: Paquete npm (distribución local)

npm run build npm pack # Distribuir el .tgz o publicar en npm # Los usuarios instalan y configuran: npm install -g mcp-database-server

Configuración en Claude Desktop:

{ "mcpServers": { "database": { "command": "mcp-database-server", "env": { "DATABASE_URL": "postgresql://..." } } } }

Patrón 2: Docker (equipo/remoto)

FROM node:20-slim WORKDIR /app COPY package*.json ./ RUN npm ci --production COPY dist/ ./dist/ EXPOSE 3001 CMD ["node", "dist/sse-server.js"]

Patrón 3: Serverless (Streamable HTTP)

Para Vercel, AWS Lambda o Cloudflare Workers:

// api/mcp.ts (función serverless de Vercel) import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; export default async function handler(req: Request): Promise<Response> { const server = createServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); return transport.handleRequest(req); }

Errores comunes y cómo evitarlos

1. Logear a stdout

// ❌ Rompe el protocolo JSON-RPC console.log('Server started'); // ✅ Usá stderr console.error('Server started');

2. No manejar el ciclo de vida de la conexión

// ❌ Los recursos se filtran al desconectar const server = new McpServer(config); // ✅ Limpiá apropiadamente server.onclose = async () => { await pool.end(); console.error('Connection closed, resources cleaned up'); };

3. Devolver datos de más

// ❌ Vuelca la tabla entera al context window return { content: [{ type: 'text', text: JSON.stringify(allRows) }] }; // ✅ Paginá y resumí const rows = result.rows.slice(0, 50); return { content: [{ type: 'text', text: JSON.stringify({ rows, total: result.rowCount, note: result.rowCount > 50 ? `Showing 50 of ${result.rowCount} rows. Use LIMIT/OFFSET for pagination.` : undefined, }, null, 2), }], };

4. Error messages sin contexto

// ❌ La IA no puede auto-corregirse return { content: [{ type: 'text', text: 'Error' }], isError: true }; // ✅ La IA puede ajustar su approach return { content: [{ type: 'text', text: `Column "user_name" not found in table "users". Available columns: id, email, full_name, created_at. Did you mean "full_name"?`, }], isError: true, };

5. Sin timeout en queries

// ❌ Un query complejo bloquea la conexión para siempre await pool.query(complexSql); // ✅ Setear statement_timeout a nivel de pool const pool = new pg.Pool({ connectionString: DATABASE_URL, options: '-c statement_timeout=30000', // 30 segundos });

Lo que viene para MCP

El protocolo evoluciona rápido. Lo que ya salió y lo que viene:

Ya en la especificación (2025-11-25):

  • OAuth 2.1 : Los servidores MCP ahora se clasifican como OAuth Resource Servers con descubrimiento estandarizado de Protected Resource Metadata. Se terminaron los patrones de auth ad-hoc.
  • Structured tool output : Los tools pueden devolver datos estructurados además de texto, facilitando el procesamiento programático de resultados.
  • Elicitation : Los servidores pueden pedir input adicional a los usuarios durante interacciones (ej: "ingresá tu API key" o "autorizá este flujo OAuth").
  • Icon metadata : Tools, resources y prompts pueden incluir URLs de íconos, devueltos en respuestas list para UIs de cliente más ricas.
  • Campo title : Un nuevo campo title provee nombres de visualización amigables, mientras name se mantiene como identificador programático.

Próximamente (SDK v2 y más):

  • MCP agent-to-agent : Servidores MCP que son clientes de otros servidores, habilitando cadenas de herramientas componibles.
  • Tasks (experimental) : Una nueva primitiva para tracking de estado durable y recuperación diferida de resultados, útil para operaciones largas.
  • Ejecución en sandbox : Aislamiento basado en contenedores con sistemas de permisos por manifesto.

El ecosistema ya tiene cientos de servidores comunitarios para GitHub, Slack, Jira, Kubernetes, AWS, Terraform y más. Construir el tuyo es la mejor forma de entender el protocolo : y de darle superpoderes a tu asistente de IA.

Conclusión

Construir un servidor MCP no es difícil. El SDK maneja la complejidad del protocolo; vos te enfocás en lo que importa: definir los tools, resources y prompts correctos para tu caso de uso.

Puntos clave:

  1. Empezá por los tools. Son la primitiva más impactante. Poné uno a funcionar en Claude Desktop e iterá desde ahí.
  2. Las descripciones son prompt engineering. Literalmente. La IA las usa para decidir cuándo y cómo usar tus tools.
  3. La seguridad no es opcional. Validá inputs, usá conexiones read-only, implementá rate limiting y timeouts.
  4. Los errores son prompts. Devolvé contexto accionable para que la IA se pueda auto-corregir.
  5. Elegí bien el transporte. stdio para local, Streamable HTTP para remoto (SSE está deprecado).

La diferencia entre "IA que charla sobre código" e "IA que opera tu infraestructura" es exactamente un servidor MCP. Es hora de construirlo.

MCPTypeScriptAIClaudeModel Context ProtocolNode.jsAI agentsdeveloper tools

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit