Ir al contenido
Volver a Tutoriales

Construir, Asegurar y Desplegar un Servidor MCP Personalizado: De la Definición de Herramientas a Producción

Avanzado · 1 hour 30 minutes · 10 min de lectura · Byte Smith ·

Antes de comenzar

  • Familiaridad con conceptos de MCP — completa primero [Cómo Configurar Agentes de Codificación con MCP](/es/tutorials/mcp-coding-agents-setup)
  • Node.js 20+ y npm instalados
  • Conocimiento básico de PostgreSQL
  • Docker y Docker Compose instalados

Lo que aprenderás

  • Crear la estructura de un servidor MCP con el SDK oficial de TypeScript
  • Definir herramientas con esquemas Zod para consultas seguras a bases de datos
  • Exponer esquemas de base de datos como recursos MCP para descubrimiento por agentes
  • Añadir middleware de autenticación con API key
  • Implementar validación de entrada, prevención de inyección SQL y enmascaramiento de columnas sensibles
  • Escribir tests unitarios y de integración para herramientas MCP
  • Containerizar y desplegar con Docker Compose
1
2
3
4
En esta página

La mayoría de tutoriales de MCP se detienen en “aquí hay una herramienta que devuelve hello world.” Eso está bien para aprender el protocolo, pero es inútil cuando necesitas conectar agentes de IA a una base de datos real, aplicar autenticación, prevenir inyección SQL y empaquetar todo para despliegue. Este tutorial cierra esa brecha.

Construirás un servidor MCP desde cero usando el SDK oficial de TypeScript que va mucho más allá de las demos hello-world. Al final, tendrás un servidor completamente funcional que se conecta a PostgreSQL, expone herramientas seguras de consulta a bases de datos, provee recursos de descubrimiento de esquemas, aplica autenticación con API key, valida todas las entradas, enmascara columnas sensibles, maneja errores limpiamente, pasa una suite de tests y se ejecuta dentro de Docker Compose. El código completo funcional está en el repositorio mcp-enterprise-starter.

Si no has trabajado con MCP antes, empieza con Cómo Configurar Agentes de Codificación con MCP para tener los fundamentos en su lugar. Si quieres la visión conceptual de por qué los servidores MCP personalizados importan para flujos de trabajo empresariales, lee el artículo complementario: Building Custom MCP Servers.

Paso 1: Crea la estructura del servidor MCP

Comienza inicializando el proyecto y haciendo que un servidor MCP mínimo se ejecute para que un agente pueda conectarse.

Inicializa el proyecto

Crea el directorio del proyecto e instala las dependencias que necesitarás a lo largo de este tutorial:

mkdir mcp-enterprise-starter && cd mcp-enterprise-starter
npm init -y
npm install @modelcontextprotocol/sdk zod pg
npm install -D typescript @types/node @types/pg vitest tsx

Establece "type": "module" en tu package.json ya que el SDK de MCP usa ES modules.

Crea un tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}

Crea el punto de entrada del servidor

El SDK de MCP proporciona una clase Server que maneja la negociación del protocolo, el anuncio de capacidades y el enrutamiento de mensajes. Tu trabajo es instanciarlo, registrar handlers de solicitudes para listado de herramientas y llamadas a herramientas, y conectarlo a un transporte.

Archivo: src/server.ts

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

function log(level: string, message: string, data?: Record<string, unknown>) {
  const entry = { timestamp: new Date().toISOString(), level, message, ...data };
  process.stderr.write(JSON.stringify(entry) + "\n");
}

const server = new Server(
  { name: "mcp-enterprise-starter", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } },
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  log("info", "MCP Enterprise Starter server running on stdio");
}

main().catch((error) => {
  log("error", "Failed to start server", {
    error: error instanceof Error ? error.message : String(error),
  });
  process.exit(1);
});

Algunas cosas a notar aquí. El servidor usa transporte stdio, que es la elección correcta para desarrollo local y para agentes como Claude Desktop que inician el servidor como un proceso hijo. Todo el logging va a stderr como JSON estructurado — stdout está reservado para mensajes del protocolo MCP. Nunca escribas output de debug a stdout.

Advertencia

Nunca escribas a stdout en un servidor MCP. El protocolo MCP usa stdout para comunicación entre cliente y servidor. Cualquier console.log perdido corromperá el flujo del protocolo y causará fallos de conexión.

Opciones de transporte más allá de stdio

Para despliegues remotos o multi-usuario donde el servidor se ejecuta como un servicio HTTP independiente, usa transporte Streamable HTTP — el estándar actual en la especificación MCP, reemplazando el transporte SSE independiente anterior. El SDK de TypeScript de MCP proporciona transportes de servidor Streamable HTTP para este propósito. Este tutorial se enfoca en transporte stdio, que es la elección correcta para desarrollo local con Claude Desktop y configuraciones de un solo usuario.

Prueba con Claude Desktop

Añade scripts a package.json:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "dev": "tsx src/server.ts",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Para verificar que el servidor inicia correctamente, configura Claude Desktop para conectarse. Añade esto a tu archivo de configuración MCP de Claude Desktop:

{
  "mcpServers": {
    "enterprise-starter": {
      "command": "npx",
      "args": ["tsx", "src/server.ts"],
      "cwd": "/path/to/mcp-enterprise-starter"
    }
  }
}

Reinicia Claude Desktop y verifica la lista de servidores MCP. Deberías ver mcp-enterprise-starter listado sin herramientas aún. Si el servidor falla al iniciar, verifica el output de stderr en los logs de Claude Desktop.

Ahora tienes un esqueleto de servidor MCP funcional. No hace nada útil aún, pero el handshake del protocolo funciona.

Paso 2: Define tu primera herramienta — Consulta segura a base de datos

Aquí es donde el servidor se vuelve útil. Crearás una herramienta query_database que permite a los agentes ejecutar consultas SQL de solo lectura contra PostgreSQL con guardrails de seguridad incorporados desde el inicio.

Configura el pool de conexión a la base de datos

Archivo: src/utils/db.ts

import pg from "pg";

const { Pool } = pg;

let pool: pg.Pool | null = null;

export function getPool(): pg.Pool {
  if (!pool) {
    pool = new Pool({
      connectionString:
        process.env.DATABASE_URL ||
        "postgres://mcp_user:mcp_password@localhost:5432/mcp_enterprise",
      max: 10,
      idleTimeoutMillis: 30000,
      connectionTimeoutMillis: 5000,
    });
  }
  return pool;
}

export async function healthCheck(): Promise<boolean> {
  try {
    const client = await getPool().connect();
    await client.query("SELECT 1");
    client.release();
    return true;
  } catch {
    return false;
  }
}

export async function shutdown(): Promise<void> {
  if (pool) {
    await pool.end();
    pool = null;
  }
}

El pool de conexión usa un connection string DATABASE_URL del entorno. El patrón de inicialización lazy significa que el pool solo se crea cuando llega la primera consulta. La función shutdown asegura una desconexión limpia cuando el servidor se detiene.

Consejo

Usa un usuario de base de datos dedicado de solo lectura para tu servidor MCP. Incluso si los guardrails a nivel de aplicación fallan, el usuario de base de datos no puede ejecutar operaciones DDL o DML de escritura. Esto es defensa en profundidad.

Define la herramienta de consulta

La herramienta necesita un esquema Zod para que el agente sepa qué entradas acepta, y el handler necesita aplicar reglas de seguridad antes de ejecutar cualquier cosa.

Archivo: src/tools/query-database.ts

import { z } from "zod";
import { getPool } from "../utils/db.js";
import {
  isSelectOnly,
  enforceRowLimit,
  maskSensitiveFields,
  containsBlockedKeywords,
} from "../utils/sanitize.js";
import { validateInput, getAllowedTables } from "../middleware/validation.js";
import { queryError, validationError, timeoutError } from "../utils/errors.js";

export const QueryDatabaseInputSchema = z.object({
  query: z.string().min(1).describe("SQL SELECT query to execute"),
  params: z
    .array(z.unknown())
    .optional()
    .default([])
    .describe("Parameterized query values"),
});

export type QueryDatabaseInput = z.infer<typeof QueryDatabaseInputSchema>;

export const queryDatabaseTool = {
  name: "query_database",
  description:
    "Execute a read-only SQL query against the database. Only SELECT statements are allowed. Results from sensitive columns (email, SSN) are automatically masked. Queries are limited to a maximum number of rows.",
  inputSchema: {
    type: "object" as const,
    properties: {
      query: {
        type: "string",
        description: "SQL SELECT query to execute",
      },
      params: {
        type: "array",
        items: {},
        description: "Parameterized query values",
      },
    },
    required: ["query"],
  },
};

export async function handleQueryDatabase(args: unknown) {
  const input = validateInput(QueryDatabaseInputSchema, args);

  const blocked = containsBlockedKeywords(input.query);
  if (blocked) {
    throw validationError(
      `Query contains blocked keyword: ${blocked}. Only SELECT queries are allowed.`,
    );
  }

  if (!isSelectOnly(input.query)) {
    throw validationError(
      "Only SELECT queries are allowed. Write operations are not permitted.",
    );
  }

  const allowedTables = getAllowedTables();
  const tablePattern = /\bFROM\s+(\w+)|\bJOIN\s+(\w+)/gi;
  let tableMatch;
  while ((tableMatch = tablePattern.exec(input.query)) !== null) {
    const table = (tableMatch[1] || tableMatch[2]).toLowerCase();
    if (!allowedTables.includes(table)) {
      throw validationError(
        `Query references table '${table}' which is not in the allowed tables list. Allowed tables: ${allowedTables.join(", ")}.`,
      );
    }
  }

  const rowLimit = parseInt(process.env.ROW_LIMIT || "100", 10);
  const maxRowLimit = parseInt(process.env.MAX_ROW_LIMIT || "1000", 10);
  const limitedQuery = enforceRowLimit(input.query, rowLimit, maxRowLimit);

  const pool = getPool();

  try {
    const timeoutMs = 30000;
    const result = await Promise.race([
      pool.query(limitedQuery, input.params),
      new Promise<never>((_, reject) =>
        setTimeout(() => reject(timeoutError()), timeoutMs),
      ),
    ]);

    const maskedRows = maskSensitiveFields(
      result.rows as Record<string, unknown>[],
    );

    return {
      content: [
        {
          type: "text" as const,
          text: JSON.stringify(
            {
              rowCount: result.rowCount,
              rows: maskedRows,
              fields: result.fields.map((f) => ({
                name: f.name,
                dataType: f.dataTypeID,
              })),
            },
            null,
            2,
          ),
        },
      ],
    };
  } catch (error) {
    if (error instanceof Error && error.name === "McpToolError") throw error;
    const message = error instanceof Error ? error.message : "Unknown query error";
    throw queryError(`Query execution failed: ${message}`, true);
  }
}

Hay mucho sucediendo aquí, y todo es intencional. La verificación de BLOCKED_KEYWORDS captura SQL destructivo antes de que llegue a la base de datos. La allowlist de tablas (cargada de la variable de entorno ALLOWED_TABLES) asegura que los agentes solo pueden consultar tablas que has aprobado explícitamente. El límite de filas previene que un agente ejecute SELECT * FROM users en una tabla con diez millones de filas y explote la respuesta. Las consultas parametrizadas son soportadas a través del campo params para que los valores nunca se interpolen en la cadena SQL.

Los helpers de sanitización (containsBlockedKeywords, isSelectOnly, enforceRowLimit, maskSensitiveFields) viven en un módulo separado src/utils/sanitize.ts. Lo construirás en el Paso 5.

El tutorial completo continúa con pasos adicionales para herramientas de listado de tablas, recursos de esquema, autenticación, validación, manejo de errores, testing y containerización. Consulta el repositorio mcp-enterprise-starter para el código fuente completo.

Para contexto más profundo sobre por qué este tipo de filtrado de salida importa en sistemas de IA agéntica, consulta Secure Agentic AI Apps y API Security Best Practices.

Problemas Comunes de Configuración

El servidor inicia pero Claude Desktop no lo lista

Verifica que tu ruta cwd sea correcta en la configuración MCP y que node dist/server.js (o npx tsx src/server.ts) se ejecute sin errores cuando lo ejecutas manualmente en una terminal. Si el proceso sale inmediatamente, el cliente MCP silenciosamente descartará la conexión.

Errores de protocolo o salida corrupta

Algo está escribiendo a stdout. Busca sentencias console.log perdidas en tu código. Todo el logging debe ir a stderr. El protocolo MCP posee stdout completamente.

Conexión a base de datos rechazada

Si estás ejecutando PostgreSQL via Docker Compose pero conectándote desde un servidor de desarrollo local (no dentro de Docker), asegúrate de que tu DATABASE_URL apunte a localhost:5432, no postgres:5432. El hostname postgres solo se resuelve dentro de la red Docker.

Errores de autenticación en modo stdio

Para transporte stdio, la API key viene del bloque env en la configuración de tu cliente MCP. Asegúrate de que API_KEYS en tu entorno coincida con la clave que estás pasando. Si API_KEYS está vacío, ninguna solicitud se autenticará.

Los tests fallan con errores de importación

Asegúrate de que "type": "module" esté establecido en package.json y que tu tsconfig.json use "module": "NodeNext". El SDK de MCP usa ES modules, y mezclar CommonJS con ESM causa fallos de importación.

Conclusión

Ahora tienes un servidor MCP que va mucho más allá de las demos hello-world. Se conecta a una base de datos real con connection pooling, expone herramientas de consulta seguras con esquemas Zod, provee recursos de descubrimiento de esquemas, aplica autenticación con API key, valida todas las entradas, bloquea consultas destructivas, enmascara columnas sensibles, devuelve errores estructurados que los agentes pueden razonar, pasa una suite de tests y se ejecuta en Docker Compose. Para llevar esto a un despliegue de producción completo, añadirías transporte Streamable HTTP para acceso remoto, autorización OAuth 2.1 para escenarios multi-usuario, monitoreo y alertas, y un gestor de secretos para almacenamiento de credenciales.

Más importante, tienes un patrón. La misma arquitectura — herramientas con validación, recursos para descubrimiento, middleware de auth, errores estructurados y guardrails de seguridad — aplica ya sea que estés conectando agentes a una base de datos, una API interna, un sistema de tickets o un pipeline CI/CD. El repositorio mcp-enterprise-starter está diseñado para ser forkeado y adaptado.

Para la visión conceptual de cuándo y por qué construir servidores MCP personalizados, lee el artículo complementario: Building Custom MCP Servers. Para entender cómo MCP encaja en el panorama más amplio de agentes de codificación IA en 2026, empieza ahí. Para extender agentes existentes con herramientas MCP en lugar de construir tu propio servidor, consulta Extend GitHub Copilot with MCP Tools y Set Up MCP-Powered Coding Agents.