Servidor de herramientas para tu agente de IA (sin MCP)
Un cliente me escribió en enero. Llevaba tres semanas intentando montar un servidor de herramientas para que un agente consultara el stock de su ecommerce.
Tenía la API montada desde 2021. Endpoints limpios, autenticación, tests. Todo funcionando en producción con miles de peticiones al día. Pero estaba convencido de que para "conectarle IA" necesitaba reescribirlo todo como servidor de herramientas para agentes.
No necesitaba reescribir nada. Necesitaba un archivo de 40 líneas.
Ese es el malentendido más caro que veo ahora mismo entre developers senior: creer que las herramientas de un agente son una infraestructura nueva. No lo son. Tu API de negocio ya es el servidor de herramientas. Lo que falta es un adaptador delgado que traduzca entre el modelo y tus endpoints.
Y sí, existe MCP: un estándar para exponer herramientas de forma interoperable. Aquí vamos por debajo, a la mecánica cruda, para que veas exactamente qué pasa entre el modelo y tu servidor. Si después quieres estandarizar y exponer estas mismas herramientas para cualquier cliente, eso ya lo cubrí aquí.
Paso 1: el servidor de herramientas que no sabe nada de IA
Un servidor de herramientas para agentes de IA es una API HTTP normal cuyos endpoints se exponen al modelo mediante un adaptador que declara, para cada operación, su schema de entrada y cuándo debe invocarse. No es infraestructura nueva: es tu API de negocio más una capa de traducción.
Esta es la parte que la gente complica sin motivo. El servidor es una API HTTP normal. Sin SDK de IA. Sin dependencias raras. Sin una sola línea que mencione un modelo.
Un catálogo de productos con Hono:
npm install hono @hono/node-server
// server/index.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
type Producto = {
sku: string;
nombre: string;
categoria: "perifericos" | "monitores" | "audio";
precio: number;
stock: number;
};
const catalogo: Producto[] = [
{ sku: "TEC-65", nombre: "Teclado mecánico 65%", categoria: "perifericos", precio: 89.9, stock: 12 },
{ sku: "MON-27", nombre: "Monitor 27\" 144Hz", categoria: "monitores", precio: 279.0, stock: 3 },
{ sku: "AUD-XM", nombre: "Auriculares ANC", categoria: "audio", precio: 199.0, stock: 0 },
];
const app = new Hono();
app.get("/productos", (c) => {
const categoria = c.req.query("categoria");
const items = categoria
? catalogo.filter((p) => p.categoria === categoria)
: catalogo;
return c.json({ items });
});
app.get("/stock/:sku", (c) => {
const producto = catalogo.find((p) => p.sku === c.req.param("sku"));
if (!producto) return c.json({ error: "SKU no encontrado" }, 404);
return c.json({ sku: producto.sku, stock: producto.stock, precio: producto.precio });
});
app.post("/pedido", async (c) => {
const { sku, unidades } = await c.req.json<{ sku: string; unidades: number }>();
const producto = catalogo.find((p) => p.sku === sku);
if (!producto) return c.json({ error: "SKU no encontrado" }, 404);
if (producto.stock < unidades) return c.json({ error: "Stock insuficiente" }, 409);
producto.stock -= unidades;
return c.json({ pedidoId: crypto.randomUUID(), sku, unidades, total: producto.precio * unidades });
});
serve({ fetch: app.fetch, port: 3000 });
Léelo otra vez y busca la palabra "IA". No está.
Esto importa más de lo que parece. Cuando mezclas la lógica de negocio con la capa del modelo, acabas con endpoints que solo sirven para el agente, imposibles de testear en aislamiento y que se rompen cada vez que cambias de proveedor.
Manteniendo la separación, tu API sigue sirviendo a tu web, a tu app móvil y al agente. Tres consumidores, una fuente de verdad.
Paso 2: el adaptador que convierte tu API en servidor de herramientas
Ahora sí, la capa que traduce. Instalas el SDK:
npm install @anthropic-ai/sdk zod
Y defines las herramientas con betaZodTool, que te deja declarar el schema de entrada con Zod y la función que se ejecuta cuando el modelo pide esa herramienta:
// agent/tools.ts
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
const API = "http://localhost:3000";
export const buscarProductos = betaZodTool({
name: "buscar_productos",
description:
"Devuelve el catálogo de productos, opcionalmente filtrado por categoría. " +
"Llama a esto cuando el usuario pregunte qué productos hay disponibles, " +
"pida recomendaciones o mencione una categoría concreta.",
inputSchema: z.object({
categoria: z
.enum(["perifericos", "monitores", "audio"])
.optional()
.describe("Categoría por la que filtrar. Omítela para ver el catálogo completo."),
}),
run: async ({ categoria }) => {
const url = categoria ? `${API}/productos?categoria=${categoria}` : `${API}/productos`;
const res = await fetch(url);
return JSON.stringify(await res.json());
},
});
export const consultarStock = betaZodTool({
name: "consultar_stock",
description:
"Devuelve stock y precio actuales de un SKU. " +
"Llama a esto SIEMPRE antes de confirmar disponibilidad o precio a un usuario. " +
"Nunca respondas de memoria sobre stock o precios.",
inputSchema: z.object({
sku: z.string().describe("Identificador del producto, por ejemplo TEC-65"),
}),
run: async ({ sku }) => {
const res = await fetch(`${API}/stock/${sku}`);
if (!res.ok) return `No existe ningún producto con SKU ${sku}`;
return JSON.stringify(await res.json());
},
});
Fíjate en las descripciones. No dicen solo qué hace la herramienta: dicen cuándo llamarla.
Esto no es cosmética. Los modelos Opus recientes son conservadores pidiendo herramientas — si dudan, prefieren responder ellos. Una descripción prescriptiva del tipo "llama a esto siempre antes de confirmar precios" convierte una llamada probable en una llamada determinista. Una descripción como "consulta el stock" la deja al azar.
Y el enum en categoria hace algo que nadie agradece hasta que falla: elimina de raíz que el modelo invente "periféricos" con tilde, "peripherals" o "teclados". Si un parámetro tiene un conjunto cerrado de valores, dilo en el schema. Aquí es donde Zod deja de ser una librería de validación y se convierte en el contrato entre el modelo y tu API — si quieres exprimir esa parte, la trabajo a fondo en el curso de Zod.
Paso 3: el agente
Aquí viene lo que te ahorra casi todo el código que la gente escribe a mano.
crearPedido es la tercera herramienta y la dejo para el siguiente apartado, porque tiene truco:
// agent/index.ts
import Anthropic from "@anthropic-ai/sdk";
import { buscarProductos, consultarStock, crearPedido } from "./tools";
const client = new Anthropic(); // lee ANTHROPIC_API_KEY del entorno
const finalMessage = await client.beta.messages.toolRunner({
model: "claude-opus-4-8",
max_tokens: 16000,
tools: [buscarProductos, consultarStock, crearPedido],
messages: [
{
role: "user",
content: "¿Qué monitores tenéis? Si hay stock del de 27 pulgadas, pídeme dos.",
},
],
});
console.log(finalMessage.content.find((b) => b.type === "text")?.text);
Eso es el agente entero.
toolRunner ejecuta el bucle agéntico completo: llama a la API, detecta que la respuesta trae bloques tool_use, ejecuta tu función run, devuelve el resultado como tool_result, y repite hasta que el modelo deja de pedir herramientas. Es beta y vive bajo client.beta.messages. El comportamiento completo está documentado en la guía oficial de tool use.
Un detalle que se come a mucha gente: ese archivo usa await en el nivel superior, así que necesitas "type": "module" en tu package.json o no compila.
Y tres cambios de la API actual que rompen código copiado de tutoriales viejos:
| Parámetro | Estado en Opus 4.8 | Qué usar |
|---|---|---|
temperature, top_p, top_k |
Eliminados — devuelven 400 | Nada. Quítalos |
budget_tokens |
Ya no existe | thinking: { type: "adaptive" } |
| Control de esfuerzo | — | output_config: { effort: "high" } (low a max) |
Si arrastras un temperature: 0 de un proyecto de 2024, tu agente no arranca. Y con thinking: adaptive el modelo decide cuánto piensa según la dificultad, en lugar de gastarte un presupuesto fijo en preguntas triviales.
Si prefieres el bucle manual, puedes escribirlo, pero recuerda volcar el response.content completo al historial para preservar los bloques tool_use, y devolver cada tool_result con su tool_use_id. Los stop_reason que verás son end_turn, tool_use, max_tokens, pause_turn y refusal.
Las herramientas destructivas van gateadas
Una herramienta destructiva se gatea devolviendo el control al usuario dentro de la propia función run, antes del efecto secundario. No requiere bajar al bucle manual.
Esto es lo que separa una demo de algo que puedes poner delante de un usuario.
Tu herramienta crear_pedido cobra dinero. Enviar un email, borrar un registro o lanzar un despliegue son irreversibles. El error más común que veo es asumir que para meter aprobación humana hay que bajar al bucle manual.
No hace falta. El gate vive dentro de la propia función run:
// agent/tools.ts — mismo archivo, mismos imports
export const crearPedido = betaZodTool({
name: "crear_pedido",
description:
"Crea un pedido real y descuenta stock. Llama a esto solo cuando el usuario " +
"haya confirmado explícitamente sku y cantidad.",
inputSchema: z.object({
sku: z.string().describe("SKU del producto"),
unidades: z.number().int().positive().describe("Número de unidades"),
}),
run: async ({ sku, unidades }) => {
const aprobado = await pedirConfirmacion(
`¿Confirmas el pedido de ${unidades} x ${sku}?`
);
if (!aprobado) return "El usuario canceló el pedido. No se ha creado nada.";
const res = await fetch(`${API}/pedido`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sku, unidades }),
});
return JSON.stringify(await res.json());
},
});
pedirConfirmacion es tuya. En un CLI son cinco líneas:
import { createInterface } from "node:readline/promises";
async function pedirConfirmacion(pregunta: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const respuesta = await rl.question(`${pregunta} (s/n) `);
rl.close();
return respuesta.trim().toLowerCase().startsWith("s");
}
En una app real es un modal, un mensaje de Slack o una fila en una tabla de aprobaciones pendientes. Da igual cuál: el contrato es el mismo, la función run se queda esperando.
Y devolver "El usuario canceló el pedido" es una respuesta perfectamente válida para el modelo. La entiende, se detiene y se lo explica al usuario. No hace falta arquitectura extra.
Dos cosas más que conviene saber. El modelo puede pedir varias herramientas en el mismo turno, y sus resultados vuelven todos juntos en un único mensaje de usuario — así que tus funciones run deben ser seguras ejecutándose en paralelo.
Y si necesitas que el input valide exactamente contra tu schema, sin campos de más, existe strict: true a nivel de definición de herramienta. Ojo: opera sobre el JSON Schema final, que debe llevar additionalProperties: false y required bien puestos — si defines con Zod, revisa el schema que genera antes de activarlo.
Menos herramientas, mejor descritas
Cuando pasas de cinco herramientas, la calidad se desploma antes por descripciones vagas que por número.
Un agente con tres herramientas que dicen con precisión cuándo usarse rinde mejor que uno con quince que dicen qué hacen. Si tienes cuatro endpoints que devuelven variantes de lo mismo, agrúpalos en una herramienta con un parámetro enum. El modelo elige mucho mejor entre valores de un enum que entre nombres de herramientas parecidos.
Esto es diseño de interfaz, no prompting. Y como cualquier diseño de interfaz, se define antes de escribir el código — es exactamente el trabajo que describo en el libro de Spec-Driven Development: decidir el contrato antes que la implementación.
Qué hacer hoy
Abre tu API de siempre. Elige los tres endpoints que más consultas de usuario resolverían. Escribe un archivo tools.ts que los envuelva, con descripciones que digan cuándo llamarlos. Conéctalo al toolRunner.
Tienes un agente funcionando esta tarde, sin tocar una línea de tu backend.
Ese es el punto entero de este post: no construyes herramientas para IA, construyes una API normal y le pones un adaptador. Todo lo demás — el estándar, el transporte, el registro de herramientas — son decisiones que vienen después, cuando ya sabes qué herramientas necesitas de verdad.
Si aún estás decidiendo qué piezas montar alrededor del agente, el stack de IA agéntica que uso en 2026 cubre las decisiones de infraestructura que vienen justo después de este archivo.
Y si prefieres trabajarlo con otros developers que están en el mismo punto, en Dominicode Labs tenemos los proyectos y los patrones que usamos en producción.
Preguntas frecuentes
¿Necesito MCP para conectar un agente a mi API?
No. MCP es un estándar de interoperabilidad, útil cuando quieres que varios clientes distintos consuman tus mismas herramientas. Para un agente propio consumiendo tu propia API, un adaptador con betaZodTool y el tool runner del SDK es suficiente y tiene mucha menos superficie que mantener. Si tu caso sí es ese —varios clientes distintos consumiendo las mismas herramientas—, el montaje completo está en MCP Server en TypeScript.
¿Qué modelo debo usar para un agente con herramientas?
claude-opus-4-8 es el modelo actual y más capaz de la familia Opus. Evita los identificadores con sufijo de fecha de generaciones anteriores: están retirados o desactualizados y el código que los usa deja de funcionar.
¿Por qué me da error 400 al enviar temperature?
Porque en Opus 4.8 temperature, top_p y top_k están eliminados. Enviarlos devuelve un error. Para controlar el razonamiento usa thinking: { type: "adaptive" } y, si necesitas más esfuerzo, output_config: { effort: "high" }.
¿Cómo evito que el agente ejecute acciones destructivas sin permiso?
Metiendo el gate dentro de la función run de la herramienta: pides confirmación y, si el usuario dice que no, devuelves un string tipo "el usuario canceló". No necesitas bajar al bucle manual para tener aprobación humana, que es el error habitual.
¿El modelo puede llamar a varias herramientas a la vez?
Sí. Puede pedir varias en un mismo turno y sus resultados vuelven juntos en un único mensaje de usuario. Diseña tus funciones run para que sean seguras ejecutándose en paralelo.
¿Debo montar el servidor de herramientas aparte de mi API?
No hace falta. Tu API de negocio ya es el servidor; la capa de tools es un cliente HTTP delgado que vive en el proceso del agente. Mantener esa separación te permite servir a tu web, tu app y tu agente desde la misma fuente de verdad.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
