Si has llegado al punto de entender qué es un cliente MCP y quieres construir uno propio en TypeScript, este es el tutorial. Vamos a montar un cliente MCP funcional desde cero usando el SDK oficial, conectarlo a un servidor real, llamar tools, leer resources y manejar prompts. Después extenderemos a OAuth para servers que lo requieran.
Este post asume Node 20+, TypeScript 5+ y familiaridad básica con async/await. Si quieres entender la diferencia entre MCP y function calling o por qué importa, repasa antes esos posts; aquí entramos directo a código.
Por qué construir un cliente MCP propio
La mayoría de la gente usa Claude Desktop, Levante u otro cliente preexistente. Razones para escribir el tuyo:
Producto propio que necesita capacidades MCP integradas.
Backend agente: tu servicio Node llama a varios MCPs como herramientas.
Workflow automation: cron job que ejecuta agentes con conjunto fijo de MCPs.
Testing: cliente minimal para validar tu propio MCP server.
Setup inicial
mkdir mcp-client && cd mcp-client
npm init -y
npm i @modelcontextprotocol/sdk zod
npm i -D typescript @types/node tsx
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --strictCrea src/index.ts y vamos a ello.
Hola mundo: conectar a un MCP server local via stdio
El transporte más simple para empezar es stdio: el server se ejecuta como subproceso y os comunicáis por entrada/salida estándar.
Vamos a conectar al server oficial de filesystem (que cualquiera puede ejecutar):
// src/index.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
async function main() {
const transport = new StdioClientTransport({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", process.cwd()],
})
const client = new Client(
{ name: "tutorial-cliente", version: "0.1.0" },
{ capabilities: {} }
)
await client.connect(transport)
const tools = await client.listTools()
console.log("Tools disponibles:")
for (const t of tools.tools) {
console.log(` ${t.name} — ${t.description}`)
}
await client.close()
}
main().catch(console.error)Ejecutas npx tsx src/index.ts y verás algo como:
Tools disponibles:
read_file — Read the complete contents of a file...
write_file — Create a new file or overwrite...
list_directory — Get a detailed listing...
...Ya tienes un cliente MCP que enumera tools. Falta llamarlas.
Llamar una tool
const result = await client.callTool({
name: "list_directory",
arguments: { path: process.cwd() },
})
console.log("Contenido:", result.content)result.content es un array de bloques (text, image, resource). Para texto simple:
for (const block of result.content) {
if (block.type === "text") console.log(block.text)
}El SDK valida automáticamente argumentos contra el schema declarado por el server. Si te equivocas pasando un argumento, recibes error claro.
Leer resources
Los resources son piezas de contenido que el server expone para que el cliente las lea (archivos, registros DB, datos vivos). Lectura típica:
const resources = await client.listResources()
for (const r of resources.resources) {
console.log(`${r.uri}: ${r.name}`)
}
const content = await client.readResource({ uri: "file:///etc/hosts" })
console.log(content.contents)contents es un array de TextResourceContents o BlobResourceContents.
Usar prompts
Los prompts son plantillas que el server provee y que el cliente puede instanciar. Útil cuando el server "sabe" cómo formular bien una petición a un LLM y solo le pasas variables:
const prompts = await client.listPrompts()
const result = await client.getPrompt({
name: "summarize-file",
arguments: { path: "./README.md" }
})
console.log(result.messages)messages es el array [{role, content}] listo para mandar a tu LLM.
Transporte HTTP (Streamable HTTP)
Para servers remotos, Streamable HTTP es el transporte recomendado en 2026. SSE puro queda como fallback.
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
const transport = new StreamableHTTPClientTransport(
new URL("https://mi-mcp.example.com/mcp"),
{ headers: { "Authorization": "Bearer ..." } }
)
const client = new Client(
{ name: "tutorial-cliente", version: "0.1.0" },
{ capabilities: {} }
)
await client.connect(transport)El resto de la API (listTools, callTool, etc.) es idéntico.
OAuth para servers que lo requieran
Servers como GitHub, Slack o Notion requieren OAuth. El SDK incluye helpers para no escribirlo desde cero:
import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
class MyOAuthProvider implements OAuthClientProvider {
async clientInformation() {
return { client_id: process.env.OAUTH_CLIENT_ID! }
}
async tokens() {
return {
access_token: process.env.OAUTH_TOKEN!,
token_type: "Bearer" as const,
}
}
// ... otros métodos del provider
}
const transport = new StreamableHTTPClientTransport(
new URL("https://mcp.github.com/v1/"),
{ authProvider: new MyOAuthProvider() }
)Para flujos completos (PKCE, refresh tokens) consulta los ejemplos del repo oficial — hay implementaciones runnable de OAuth-enabled clients con polling y parallel.
Combinar varios servers
Un cliente real conecta a varios servers a la vez (filesystem + GitHub + tu API custom). El patrón:
type ServerConfig = {
name: string
transport: () => StdioClientTransport | StreamableHTTPClientTransport
}
const configs: ServerConfig[] = [
{
name: "fs",
transport: () => new StdioClientTransport({
command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
})
},
{
name: "github",
transport: () => new StreamableHTTPClientTransport(
new URL("https://mcp.github.com/v1/"),
{ authProvider: new MyOAuthProvider() }
)
}
]
const clients: Record<string, Client> = {}
for (const cfg of configs) {
const c = new Client({ name: "multi", version: "0.1.0" }, { capabilities: {} })
await c.connect(cfg.transport())
clients[cfg.name] = c
}Después namespaceas las tools (fs.read_file, github.create_issue) cuando se las pasas a tu LLM.
Integrar con tu LLM
El patrón típico para un agente:
import Anthropic from "@anthropic-ai/sdk"
const anthropic = new Anthropic()
const tools = (await client.listTools()).tools.map(t => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema,
}))
let messages: Anthropic.MessageParam[] = [
{ role: "user", content: "Lista los .ts del proyecto y resume el README" }
]
while (true) {
const response = await anthropic.messages.create({
model: "claude-opus-4-7",
max_tokens: 4096,
tools,
messages,
})
messages.push({ role: "assistant", content: response.content })
if (response.stop_reason === "tool_use") {
const toolUses = response.content.filter(c => c.type === "tool_use")
const toolResults: Anthropic.ToolResultBlockParam[] = []
for (const tu of toolUses) {
const result = await client.callTool({
name: tu.name,
arguments: tu.input as any,
})
toolResults.push({
type: "tool_result",
tool_use_id: tu.id,
content: result.content as any,
})
}
messages.push({ role: "user", content: toolResults })
} else {
break
}
}
console.log(messages.at(-1))Loop sencillo: pides al modelo, si pide tool use ejecutas vía MCP, devuelves resultado, continúas hasta que pare.
Errores y reintentos
Los servers MCP pueden fallar de muchas formas. Estrategias:
async function callWithRetry(name: string, args: any, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await client.callTool({ name, arguments: args })
} catch (err) {
if (i === retries - 1) throw err
await new Promise(r => setTimeout(r, 200 * Math.pow(2, i)))
}
}
}Para timeouts:
const result = await Promise.race([
client.callTool({ name, arguments: args }),
new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 30_000))
])Deploy
Node tradicional
tsx src/index.ts para desarrollo, tsc && node dist/index.js para producción. Empaqueta todo lo que el SDK necesita y va.
Edge (Cloudflare Workers, Vercel Edge)
Stdio no va; necesitas exclusivamente Streamable HTTP. El SDK funciona en runtime edge siempre que uses transportes HTTP. Para servers que requieren stdio, ejecuta el binario en otra parte (un Cloud Run o un VPS) y conecta via HTTP.
Docker
Imagen mínima:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist/ ./dist/
CMD ["node", "dist/index.js"]Ejemplo completo: agente que documenta el repo
Pegando piezas, un agente que recorre tu repo y genera README:
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import Anthropic from "@anthropic-ai/sdk"
async function main() {
const transport = new StdioClientTransport({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", process.cwd()]
})
const mcp = new Client(
{ name: "doc-agent", version: "0.1.0" },
{ capabilities: {} }
)
await mcp.connect(transport)
const tools = (await mcp.listTools()).tools.map(t => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema,
}))
const anthropic = new Anthropic()
let messages: Anthropic.MessageParam[] = [
{
role: "user",
content: "Lee los archivos .ts del directorio actual, " +
"y escribe un README.md que explique qué hace el proyecto. " +
"Guarda el README usando write_file."
}
]
for (let step = 0; step < 20; step++) {
const r = await anthropic.messages.create({
model: "claude-opus-4-7",
max_tokens: 4096,
tools,
messages,
})
messages.push({ role: "assistant", content: r.content })
if (r.stop_reason !== "tool_use") break
const toolUses = r.content.filter(c => c.type === "tool_use")
const toolResults: Anthropic.ToolResultBlockParam[] = []
for (const tu of toolUses) {
const result = await mcp.callTool({
name: tu.name, arguments: tu.input as any
})
toolResults.push({
type: "tool_result",
tool_use_id: tu.id,
content: result.content as any
})
}
messages.push({ role: "user", content: toolResults })
}
await mcp.close()
}
main().catch(console.error)20 pasos, lectura, escritura, listo.
Buenas prácticas
Cierra siempre el cliente:
await client.close()para que el subproceso libere recursos.Maneja
transport.onerrorpara logging robusto.No expongas tools sin filtrar: cuando das tools a un LLM, filtra las peligrosas (
delete_file,execute_command) o requiere confirmación humana.Cachea
listToolssi tu agente arranca y para a menudo.Versiona tu integración: el spec de MCP evoluciona; pinéa la versión del SDK.
Si vas a producción, monta logging estructurado (
pinou otro) y métricas (latencia por tool, error rate).
Recursos oficiales
Repo SDK: modelcontextprotocol/typescript-sdk
Docs SDK v2: ts.sdk.modelcontextprotocol.io/v2
Spec: modelcontextprotocol.io
Lista de servers oficiales: los mejores servidores MCP
MCP Store de Levante: catálogo curado con instalación en un click.
Conclusión
Construir un cliente MCP en TypeScript es mucho más sencillo de lo que parece gracias al SDK oficial. Lo difícil no es el código (que es 100 líneas) sino decidir el alcance: ¿multi-server? ¿OAuth? ¿edge? Empieza con stdio y un server local, valida que funciona, y escala desde ahí.
Si lo único que necesitas es un cliente MCP terminado con UI multi-modelo y MCP Store integrado, Levante ya hace todo este trabajo y te ahorra el mantenimiento. Para producto propio, el SDK es el camino.



