Cargando la bóveda…
Cargando la bóveda…
Construí un MCP server desde cero en TypeScript que expone tools custom a Claude Code. Caso real: un server que consulta tu base de datos interna y devuelve los últimos pedidos. Incluye autenticación, schema validation y deploy.
Un MCP server (Model Context Protocol) le da a Claude tools que tu equipo controla. La diferencia con los hooks: los hooks son acciones automáticas; las tools de un MCP son funciones que Claude decide invocar según el contexto.
Casos donde un MCP propio gana:
Si tu necesidad se resuelve con un Bash command o un curl, no necesitás MCP. MCP brilla cuando hay state, schemas, y autenticación que querés centralizar.
mkdir mcp-pedidos && cd mcp-pedidos
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --initEditá tsconfig.json para que apunte a ESM moderno:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}Y en package.json, agregale "type": "module".
src/index.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'
import { z } from 'zod'
const server = new Server(
{ name: 'mcp-pedidos', version: '0.1.0' },
{ capabilities: { tools: {} } }
)
// Schema de inputs validado con Zod
const ListarPedidosInput = z.object({
desde: z.string().describe('Fecha ISO. Ejemplo: 2026-05-01'),
estado: z.enum(['pendiente', 'pagado', 'enviado', 'cancelado']).optional(),
limite: z.number().int().min(1).max(100).default(20),
})
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'listar_pedidos',
description:
'Lista los pedidos desde una fecha. Útil para consultar actividad reciente del cliente.',
inputSchema: {
type: 'object',
properties: {
desde: { type: 'string', description: 'Fecha ISO' },
estado: { type: 'string', enum: ['pendiente', 'pagado', 'enviado', 'cancelado'] },
limite: { type: 'number', default: 20 },
},
required: ['desde'],
},
},
],
}))
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === 'listar_pedidos') {
const args = ListarPedidosInput.parse(req.params.arguments)
const pedidos = await consultarBD(args)
return {
content: [{ type: 'text', text: JSON.stringify(pedidos, null, 2) }],
}
}
throw new Error(`Tool desconocida: ${req.params.name}`)
})
async function consultarBD(args: z.infer<typeof ListarPedidosInput>) {
// Acá conectás a tu DB real (Postgres, MySQL, lo que sea)
// Por ahora, mock:
return [
{ id: 1, cliente: 'Acme SA', estado: 'pagado', monto: 4500, fecha: '2026-05-16' },
{ id: 2, cliente: 'Beta SRL', estado: 'pendiente', monto: 1200, fecha: '2026-05-17' },
]
}
const transport = new StdioServerTransport()
await server.connect(transport)Agregalo a tu .claude/settings.json del proyecto donde lo querés usar:
{
"mcpServers": {
"pedidos": {
"command": "node",
"args": ["/ruta/absoluta/a/mcp-pedidos/dist/index.js"],
"env": {
"DATABASE_URL": "postgres://..."
}
}
}
}Compilá y probá:
npx tsc && claudeAdentro de Claude, escribí:
> ¿Qué pedidos pendientes tenemos desde el 1 de mayo?Claude va a detectar el tool listar_pedidos, invocarlo y resumirte la respuesta.
El problema de devolver "lo que vino de la DB" es que Claude puede recibir basura si la query falla. Patrón que recomiendo:
server.setRequestHandler(CallToolRequestSchema, async (req) => {
try {
const args = ListarPedidosInput.parse(req.params.arguments)
const pedidos = await consultarBD(args)
return {
content: [
{
type: 'text',
text: pedidos.length === 0
? 'No se encontraron pedidos con esos filtros.'
: JSON.stringify(pedidos, null, 2),
},
],
}
} catch (err) {
return {
isError: true,
content: [
{ type: 'text', text: `Error consultando pedidos: ${(err as Error).message}` },
],
}
}
})Con isError: true, Claude sabe que hubo un problema y puede decidir reintentar, pedirte info o cambiar de approach.
Si tu MCP server toca datos sensibles, no confíes en el caller. Validá un token en cada request:
const REQUIRED_TOKEN = process.env.MCP_TOKEN
if (!REQUIRED_TOKEN) {
console.error('MCP_TOKEN no seteado. Abortando.')
process.exit(1)
}
// En cada handler:
const auth = req.params.arguments?._auth as string | undefined
if (auth !== REQUIRED_TOKEN) {
return {
isError: true,
content: [{ type: 'text', text: 'Acceso denegado: token inválido.' }],
}
}Pasale el token desde settings.json vía env. Esto evita que un proceso curioso en tu máquina invoque tu MCP server.
El transport StdioServerTransport solo escucha en el stdin del proceso. No abre puertos. Es seguro por default — el riesgo solo aparece si exponés el proceso por red (con --transport http, por ejemplo).
Para que tu equipo lo use:
settings.json de cada dev:{
"mcpServers": {
"pedidos": {
"command": "npx",
"args": ["-y", "@miempresa/mcp-pedidos"],
"env": { "MCP_TOKEN": "${MCP_TOKEN}" }
}
}
}Con npx -y, npm baja el paquete la primera vez y lo cachea. Versionado, distribuido, sin instalación manual.