MCP Model Context Protocol en TypeScript: diseñá tools portables entre Claude, GPT y modelos locales

typescript dev.to

MCP Model Context Protocol en TypeScript: diseñá tools portables entre Claude, GPT y modelos locales

La mayoría de los tutoriales de MCP empiezan con npm install @anthropic-ai/sdk y en el tercer bloque de código ya tienen lógica de negocio acoplada al cliente de Anthropic. Sí, leíste bien: te enseñan el protocolo de portabilidad usando código que no es portable. Y eso cambia completamente cómo terminás diseñando tus tools cuando necesitás moverlas.

Mi tesis es simple y la defiendo desde el diseño: el error central al implementar MCP tools no es sintáctico ni de configuración — es de acoplamiento. Metés lógica dentro del handler del SDK, y lo que debería ser un contrato universal se convierte en código que solo funciona con un proveedor. La MCP Specification oficial describe un protocolo agnóstico al modelo. Casi nadie lo diseña así desde el día uno.


Qué dice la MCP spec y qué deliberadamente no dice

Antes de cualquier código, vale la pena leer la spec como lo que es: un contrato de comunicación, no un framework de implementación.

MCP define tres primitivas fundamentales (según la documentación oficial):

  • Tools: funciones que el modelo puede invocar con parámetros estructurados
  • Resources: datos que el servidor expone para que el modelo los lea
  • Prompts: templates reutilizables con argumentos

Lo que la spec no define es cómo implementás la lógica interna de una tool. No dice que tenés que usar el SDK de Anthropic. No dice que el handler tiene que conocer qué modelo lo llamó. No dice que la respuesta tiene que tener formato propietario.

Una tool en MCP tiene esta forma lógica:

// Contrato mínimo que define la spec — agnóstico al proveedor
interface MCPTool {
  name: string;           // identificador único de la tool
  description: "string;    // qué hace, para que el modelo entienda cuándo usarla"
  inputSchema: {          // JSON Schema estricto del input
    type: "object";
    properties: Record<string, unknown>;
    required: string[];
  };
}

// El handler recibe input validado y devuelve contenido estructurado
type ToolHandler = (input: Record<string, unknown>) => Promise<{
  content: Array<{ type: "text"; text: string }>;
  isError?: boolean;
}>;
Enter fullscreen mode Exit fullscreen mode

Eso es todo lo que el protocolo garantiza. El content es un array tipado, isError es opcional. Si diseñás dentro de esos límites, la tool es portable.


El contrato de input/output que hace o rompe la portabilidad

Acá está la fricción real. Cuando un desarrollador arranca con el ejemplo del SDK oficial de Anthropic (@anthropic-ai/sdk), el código de ejemplo suele verse así:

// ❌ Patrón acoplado: la lógica vive dentro del flujo del SDK
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

// La tool se define inline, el handler conoce el cliente
const tools: Anthropic.Tool[] = [
  {
    name: "obtener_clima",
    description: "Obtiene el clima actual de una ciudad",
    input_schema: {
      type: "object" as const,
      properties: {
        ciudad: { type: "string", description: "Nombre de la ciudad" },
      },
      required: ["ciudad"],
    },
  },
];

// El procesamiento está mezclado con el loop de mensajes del proveedor
async function procesarRespuesta(response: Anthropic.Message) {
  if (response.stop_reason === "tool_use") {
    const toolUse = response.content.find(
      (block) => block.type === "tool_use"
    ) as Anthropic.ToolUseBlock;

    // ⚠️ Acá empieza el problema: lógica de negocio dentro del handler de Anthropic
    if (toolUse.name === "obtener_clima") {
      const ciudad = (toolUse.input as { ciudad: string }).ciudad;
      // fetch, lógica, transformación... todo mezclado con el SDK
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

¿Ves dónde se rompe? El tipo Anthropic.ToolUseBlock, el campo stop_reason, el campo input_schema con snake_case — todo eso es el dialecto de Anthropic. Si mañana querés usar OpenRouter con un modelo local, tenés que reescribir el handler completo porque el contrato quedó enterrado en tipos del proveedor.

El patrón portable separa tres capas:

// ✅ Patrón portable: tres capas con responsabilidades distintas

// --- Capa 1: Definición del schema (independiente del proveedor) ---
import { z } from "zod"; // Zod para validación en runtime

const climaInputSchema = z.object({
  ciudad: z.string().min(1).describe("Nombre de la ciudad"),
  unidad: z.enum(["celsius", "fahrenheit"]).default("celsius"),
});

type ClimaInput = z.infer<typeof climaInputSchema>;

// --- Capa 2: Handler puro (no sabe de SDKs) ---
async function obtenerClimaHandler(rawInput: unknown): Promise<{
  content: Array<{ type: "text"; text: string }>;
  isError?: boolean;
}> {
  // Validamos el input con Zod antes de usarlo
  const parsed = climaInputSchema.safeParse(rawInput);
  if (!parsed.success) {
    return {
      content: [{ type: "text", text: `Input inválido: ${parsed.error.message}` }],
      isError: true,
    };
  }

  const { ciudad, unidad } = parsed.data;

  // Lógica de negocio — no sabe qué modelo la llamó
  const resultado = await fetchClimaExterno(ciudad, unidad);

  return {
    content: [{ type: "text", text: JSON.stringify(resultado) }],
  };
}

// --- Capa 3: Adaptadores por proveedor (finitos y delgados) ---
// El adaptador traduce entre el dialecto del SDK y el handler puro
function toAnthropicTool(): Anthropic.Tool {
  return {
    name: "obtener_clima",
    description: "Obtiene el clima actual de una ciudad",
    input_schema: {
      type: "object" as const,
      properties: {
        ciudad: { type: "string" },
        unidad: { type: "string", enum: ["celsius", "fahrenheit"] },
      },
      required: ["ciudad"],
    },
  };
}

// Para un proveedor compatible con OpenAI spec (OpenRouter, GPT, etc.)
function toOpenAITool(): { type: "function"; function: object } {
  return {
    type: "function",
    function: {
      name: "obtener_clima",
      description: "Obtiene el clima actual de una ciudad",
      parameters: {
        type: "object",
        properties: {
          ciudad: { type: "string" },
          unidad: { type: "string", enum: ["celsius", "fahrenheit"] },
        },
        required: ["ciudad"],
      },
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

El handler puro es el mismo en los dos casos. Solo los adaptadores cambian. Esa es la portabilidad real.


Los tres gotchas que nadie menciona en los tutoriales

1. input_schema vs parameters: no son intercambiables

Anthropic usa input_schema con snake_case. La spec OpenAI (y los proveedores compatibles como OpenRouter) usa parameters. No hay auto-conversión. Si no tenés una capa adaptadora, el primer cambio de proveedor te explota en runtime sin un error claro — simplemente el modelo no encuentra la tool o la llama mal.

2. El campo isError: true no detiene la ejecución del agente

Esto es sutil. Cuando devolvés isError: true en la respuesta de una tool, la spec MCP indica que eso no interrumpe el flujo del agente — le señala al modelo que hubo un error en la tool, pero el modelo puede seguir razonando. Eso significa que tu handler tiene que devolver un mensaje de error legible para el modelo, no solo para vos. Un stacktrace crudo no ayuda; un texto como "No se encontró la ciudad 'Baires'. Verificá el nombre exacto." sí.

3. Zod en runtime vs JSON Schema en la definición

Zod es excelente para validar en runtime dentro del handler. Pero el inputSchema que registrás en el servidor MCP tiene que ser JSON Schema puro — no podés pasar un ZodSchema directamente. Hay librerías como zod-to-json-schema que hacen la conversión, pero la dependencia extra tiene un costo. En proyectos pequeños, a veces es más simple mantener los dos en sync manualmente. En proyectos más grandes, automatizar la conversión vale la pena.

// Conversión con zod-to-json-schema (si la querés automatizar)
import { zodToJsonSchema } from "zod-to-json-schema";

const jsonSchema = zodToJsonSchema(climaInputSchema, {
  $refStrategy: "none", // evitá $ref en schemas MCP — algunos clientes no los resuelven
});
Enter fullscreen mode Exit fullscreen mode

Checklist de diseño: antes de escribir el handler

Antes de tocar el SDK de cualquier proveedor, pasá por esto:

Pregunta Señal verde Señal roja
¿El handler recibe unknown y valida internamente? Sí, con Zod o schema propio No, recibe tipos del SDK directamente
¿El handler devuelve { content, isError? } puro? No, devuelve tipos del proveedor
¿La definición de tool tiene adaptador por proveedor? Sí, capa separada No, está hardcodeada al SDK
¿El mensaje de error es legible para el modelo? Sí, texto descriptivo No, stacktrace o código crudo
¿El schema usa $ref? No, está inlineado Sí — verificar compatibilidad del cliente
¿La lógica de negocio importa algo del SDK? No Sí — acoplamiento

Si todo verde, la tool sobrevive un cambio de proveedor sin tocar el handler. Si hay señales rojas, el costo de migrar va a caer sobre lógica de negocio, que es donde duele.


Lo que esta guía no puede concluirte

Acá los límites claros, porque no quiero venderte certeza que no tengo:

  • Rendimiento de portabilidad: No tengo benchmarks propios comparando latencia de la capa adaptadora vs handler directo. Es una capa delgada de traducción de tipos — en la práctica debería ser negligible, pero sin medición en producción propia no lo afirmo como hecho.

  • Comportamiento de modelos locales (Ollama, LM Studio): La compatibilidad con tool calling en modelos locales varía mucho por modelo y por versión. Algunos interpretan el schema correctamente, otros ignoran campos. Eso no es un problema de diseño de la tool — es una limitación del modelo. Esta arquitectura te da la estructura correcta; no garantiza que el modelo del otro lado la use bien.

  • MCP sobre HTTP vs stdio: La spec soporta los dos transportes. Los ejemplos acá son agnósticos al transporte, pero hay diferencias en cómo se maneja el ciclo de vida del servidor. Si usás stdio, el proceso es efímero. Si usás HTTP, el servidor es persistente. Eso afecta el diseño de estado de la tool, algo que merece su propio post.


FAQ

¿MCP es solo para Claude o funciona con cualquier modelo?

El protocolo MCP es agnóstico al modelo. Cualquier cliente que implemente el protocolo puede usarlo — Claude, GPT-4o vía OpenRouter, modelos locales a través de clientes compatibles. Lo que varía es la calidad del tool calling de cada modelo, no el protocolo en sí. La spec oficial no menciona a ningún modelo específico en su definición de primitivas.

¿Necesito @anthropic-ai/sdk para implementar MCP tools?

No necesariamente. Necesitás el SDK de Anthropic si tu cliente (el que llama al modelo) es Claude. Pero el servidor MCP — donde viven las tools — puede implementarse con cualquier librería compatible o incluso desde cero si seguís el protocolo de transporte. El SDK oficial tiene helpers de MCP, pero son opcionales para el lado servidor.

¿Zod es obligatorio o es una preferencia?

Es una preferencia fuerte, no un requisito de la spec. MCP define el schema como JSON Schema. Zod es útil porque te da validación en runtime + inferencia de tipos TypeScript desde la misma definición. Podés usar ajv, validación manual o cualquier otra librería. Lo que sí es importante — y esto sí es estructural — es validar el rawInput dentro del handler antes de usarlo, sin importar cómo.

¿Cómo manejo autenticación en una tool MCP?

La spec no define autenticación dentro del contrato de tool. Si la tool necesita credenciales (un API key, un token), esas tienen que llegar por contexto de inicialización del servidor, no por parámetros del input de la tool. Pasar secrets como input expone esa información al modelo y potencialmente al log de conversación.

¿Puedo tener estado entre llamadas a tools en la misma conversación?

Depende del transporte. Con stdio (proceso por conversación), podés mantener estado en memoria del proceso. Con HTTP (servidor persistente), necesitás correlacionar por sesión explícitamente. Por defecto, diseñá las tools como funciones puras sin estado — es el patrón más seguro y portable.

¿Esta arquitectura escala a docenas de tools?

El patrón de tres capas escala bien porque cada tool es un módulo independiente: schema + handler + adaptadores. Lo que no escala sin disciplina es el registro: si tenés 30 tools y cada adaptador está duplicado, el mantenimiento se complica. Una solución común es un registry central que mapea toolName → handler y genera los adaptadores por proveedor automáticamente desde el schema.


Conclusión: la spec te da el contrato, vos elegís si lo respetás

Diseñé tools MCP en proyectos con Claude y OpenRouter. Lo que aprendí es que la portabilidad no es un beneficio automático del protocolo — es una decisión de diseño que tomás o no tomás en las primeras horas.

La MCP spec te da el contrato: nombre, description, input schema, content de respuesta. Si metés lógica de negocio dentro de tipos del SDK, rompés ese contrato sin que nadie te avise. El error es silencioso — la tool funciona perfectamente con un proveedor y falla o requiere reescritura total con otro.

Mi postura: tres capas siempre. Schema con Zod, handler puro, adaptador delgado por proveedor. El costo inicial es un poco más de estructura. El retorno es no tener que reescribir handlers cuando cambiás de modelo o proveedor, algo que en un ecosistema que se mueve tan rápido como el de agentes, va a pasar más seguido de lo que pensás.

Si venís de leer el post sobre system prompts para agentes en producción, este es el siguiente paso natural: una vez que el agente sabe qué hacer, las tools tienen que estar diseñadas para no acoplarse a quién lo ejecuta.

Y si estás empezando a pensar en rate limiting para estas tools expuestas como endpoints, este análisis de qué proteger primero tiene criterios que aplican directamente.


Fuentes originales:


Este artículo fue publicado originalmente en juanchi.dev

Source: dev.to

arrow_back Back to Tutorials