Conectar las herramientas MCP a una interfaz de chat de React con aprobación humana integrada
Aprenda cómo encaja el Model Context Protocol en una aplicación React: por qué el backend debe alojar MCP, cómo funciona un servidor de herramientas y cómo transmitir y aprobar las llamadas a herramientas en la interfaz de usuario.
Las funcionalidades de IA en una aplicación React suelen desarrollarse una integración a la vez, cada una con su propio SDK, sistema de autenticación, manejo de errores y mapeo de datos, todos estrechamente vinculados entre sí. El Protocolo de Contexto del Modelo (MCP), un protocolo abierto introducido por Anthropic y ahora ampliamente adoptado, reemplaza esa complejidad con una única interfaz estándar entre las aplicaciones de IA y las herramientas y datos que utilizan; la analogía común es un puerto USB-C para la IA. Este artículo explica cuál es el lugar del MCP dentro de una arquitectura React, muestra un pequeño servidor de herramientas de base de datos y un host backend, y luego construye la parte que corresponden a los desarrolladores de React: una interfaz de chat que transmite las llamadas a las herramientas y pide al usuario su aprobación.
Si desea primero un resumen a nivel de protocolo sobre el descubrimiento y la invocación, consulte cómo MCP permite a los agentes de IA descubrir y llamar herramientas. El enfoque aquí es el lado de la aplicación y la interfaz de usuario.
Qué estandariza MCP
MCP define cómo las aplicaciones suministran contexto y capacidades a los modelos de lenguaje grande. Separa tres roles: el host es su aplicación, un cliente es el conector dentro del host que mantiene una sesión con un servidor, y los servidores exponen las herramientas y fuentes de datos. Un host suele ejecutar un cliente por cada servidor al que se conecta.
Sin MCP, la lista de dependencias de una aplicación React con funcionalidades de IA suele verse más o menos así:
React App
├── OpenAI SDK (for chat)
├── Anthropic SDK (for reasoning)
├── LangChain (for RAG)
├── Custom API Client (for your database)
└── Custom API Client (for your CRM)
Cada entrada cuenta con su propia autenticación, manejo de errores y mapeo de esquemas; una nueva fuente de datos implica un nuevo endpoint además de un nuevo servicio frontend.
Con MCP, la integración se reduce a un único patrón:
React App (Host)
└── MCP Client
├── MCP Server: File System
├── MCP Server: PostgreSQL
├── MCP Server: Slack
├── MCP Server: Your Internal API
└── MCP Server: Any Future Tool
Cada servidor utiliza el mismo protocolo. El host no necesita entender PostgreSQL ni la estructura de la API de Slack; simplemente hace dos preguntas genéricas: “¿Qué herramientas están disponibles?” y “Ejecuta esta herramienta con estos argumentos”, y el protocolo se encarga del resto. Tenga en cuenta lo que MCP no hace: estandariza la conexión a las herramientas y los datos, pero no la elección del modelo. El host sigue comunicándose con el proveedor de LLM que usted utilice.
Las tres primitivas
Su aplicación React interactúa con tres tipos de capacidades del servidor, directamente o, más comúnmente, a través de su backend.
Herramientas
Las herramientas son funciones que un modelo puede invocar. Cada una tiene un nombre, una descripción y un JSON Schema que describe sus parámetros. Cuando un usuario pregunta cuántas cuentas se crearon ayer, el modelo no necesita adivinar: puede encontrar una herramienta como query_user_signups que acepta un date, llamarla y responder con el resultado. Las herramientas son lo que conecta la pregunta en lenguaje natural del usuario con los datos detrás de tu aplicación.
Recursos
Los recursos son fragmentos de contexto que el modelo puede leer, como un archivo, un registro de base de datos o un hilo de chat. Cada uno se identifica mediante una URI, por ejemplo file:///docs/spec.pdf o db://users/123, y leerlos permite al modelo basar sus respuestas en datos reales en lugar de en su entrenamiento.
Prompts
Los prompts son plantillas reutilizables publicadas por un servidor. Un servidor podría ofrecer un prompt code_review que acepta un argumento file_path; el servidor host carga la plantilla, rellena el argumento y envía el resultado al modelo.
Por qué el frontend es más que una pantalla de visualización
El frontend de paso directo
En muchas aplicaciones de IA, el cliente React envía el mensaje del usuario a un backend de Node o FastAPI, el cual lo transmite al proveedor del modelo, espera la respuesta y la envía de vuelta. Si el modelo necesita una herramienta, el backend también se encarga de eso. El frontend es un renderizador de texto pasivo: no tiene conocimiento sobre lo que está haciendo el modelo ni forma de intervenir.
El frontend como superficie de control
Con las llamadas a herramientas en estilo MCP, la interfaz de usuario puede mostrar esas llamadas a medida que ocurren y permitir al usuario aprobar o rechazar operaciones sensibles antes de que se ejecuten. Ya sea que el navegador mantenga las conexiones MCP por sí mismo o, lo que es más común, reciba un flujo estructurado desde un servidor backend, React se convierte en el lugar donde la orquestación es visible y controlable.
Los usuarios esperan cada vez más ese tipo de control: poder ver que el asistente está a punto de consultar sus datos y aprobarlo primero. Esa experiencia se implementa en React.
Dónde debe residir el host MCP
Existen dos arquitecturas viables. Elija entre ellas según sus requisitos de seguridad y latencia.
MCP mediado por backend, la opción predeterminada
En este patrón, la aplicación React solo se comunica con tu backend, que es el anfitrión MCP. Este mantiene abiertas las conexiones con los servidores MCP, se encarga de la autenticación y transmite la actividad de las herramientas al frontend:
React (Client) <--SSE/WS--> FastAPI/Node (MCP Host) <--stdio/SSE--> MCP Servers
Las ventajas son decisivas para la mayoría de los productos:
- Seguridad: las credenciales de los servidores MCP permanecen en el servidor y nunca llegan al navegador.
- Estatus: las sesiones persistentes en la base de datos y el sistema de archivos se almacenan en el servidor, donde deben estar.
- Auditoría: cada llamada a una herramienta puede registrarse, limitarse en cuanto a frecuencia y atribuirse a un usuario específico.
El frontend consume un flujo estructurado de eventos (trozos de texto, solicitudes de llamadas a herramientas, resultados de las herramientas y la respuesta final) y renderiza cada estado de forma deliberada.
Una nota sobre los transportes: el diagrama muestra stdio entre el servidor host y los servidores locales, así como SSE para los remotos. La especificación MCP ha ido evolucionando su transporte HTTP con el tiempo, por lo que consulte la especificación actual y la documentación del SDK para conocer el transporte remoto recomendado.
MCP nativo del navegador, para casos específicos
Otra opción es que la aplicación React se conecte directamente a servidores MCP remotos mediante transmisión basada en HTTP. Funciona, pero los equipos de producción rara vez lo eligen, ya que la capa de datos y sus credenciales quedan accesibles desde el navegador. Resérvelo para herramientas de desarrollo locales o aplicaciones de IA completamente client-side que no manejan datos sensibles.
Un servidor de herramientas de base de datos en TypeScript
Construir un servidor pequeño es la forma más rápida de comprender el protocolo. El ejemplo a continuación expone una tabla users de PostgreSQL mediante dos herramientas usando el SDK oficial de TypeScript. Se puede leer en tres partes: primero, se crea un pool de conexiones y un Server MCP que anuncia la capacidad de las tools; segundo, el manejador ListToolsRequestSchema describe cada herramienta con un nombre, una descripción y un inputSchema, que es lo que el modelo ve al decidir qué llamar; tercero, el manejador CallToolRequestSchema envía la solicitud según el nombre de la herramienta, ejecuta una consulta parametrizada y devuelve las filas como contenido de texto, o devuelve isError: true junto con un mensaje cuando ocurre algún error. Finalmente, el servidor se conecta a través de stdio, por lo que un host puede ejecutarlo como proceso hijo.
// mcp-servers/database-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";
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const server = new Server(
{
name: "postgres-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "query_users",
description: "Query the users table with filters",
inputSchema: {
type: "object",
properties: {
limit: { type: "number", description: "Max results" },
status: { type: "string", enum: ["active", "inactive"] },
},
required: ["limit"],
},
},
{
name: "get_user_by_email",
description: "Find a user by their email address",
inputSchema: {
type: "object",
properties: {
email: { type: "string" },
},
required: ["email"],
},
},
],
};
});
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === "query_users") {
const result = await pool.query(
"SELECT id, email, status, created_at FROM users WHERE status = $1 LIMIT $2",
[args.status || "active", args.limit]
);
return {
content: [
{
type: "text",
text: JSON.stringify(result.rows, null, 2),
},
],
};
}
if (name === "get_user_by_email") {
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
[args.email]
);
return {
content: [
{
type: "text",
text: JSON.stringify(result.rows[0] || null, null, 2),
},
],
};
}
throw new Error(`Unknown tool: ${name}`);
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error.message}`,
},
],
isError: true,
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
Fíjese en lo que evita el diseño: el modelo nunca envía SQL en formato raw. Solo puede elegir entre operaciones específicas y nombradas, y las consultas utilizan marcadores de posición ($1, $2) para que los argumentos no puedan inyectar SQL. Al devolver errores como contenido mediante isError, el modelo puede detectar y explicar el fallo en lugar de que toda la sesión se colapse.
Antes de usar algo así en la práctica, hay que ajustar algunos aspectos. El JSON Schema describe las entradas, pero el manejador aún debe validar los args en sí (por ejemplo, con una biblioteca de esquemas), ya que podrían faltar o estar mal formados; también se debe establecer un límite para args.limit. La función get_user_by_email ejecuta SELECT *, lo que enviaría todas las columnas al modelo, incluyendo información sensible como los hashes de contraseñas; por lo tanto, seleccione solo las columnas necesarias. Además, en TypeScript estricto, error en el bloque catch tiene el tipo unknown, así que hágalo antes de leer .message.
Un servidor backend en Python
La aplicación React nunca se comunica directamente con ese servidor; lo hace el backend. A continuación se muestra una clase de anfitrión mínima que utiliza el SDK MCP de Python. connect() describe cómo iniciar el proceso del servidor (command, args y un entorno que pasa DATABASE_URL), abre un cliente stdio, inicia una ClientSession, realiza el intercambio de protocolo con initialize() y luego llama a list_tools() para descubrir qué ofrece el servidor. execute_tool() envía el nombre de la herramienta y los argumentos, devuelve el primer elemento de texto del resultado, y close() finaliza la sesión y el proceso.
# backend/mcp_host.py
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
import asyncio
import json
class MCPHost:
def __init__(self):
self.session = None
self.tools = []
async def connect(self):
server_params = StdioServerParameters(
command="node",
args=["mcp-servers/database-server.ts"],
env={"DATABASE_URL": os.getenv("DATABASE_URL")}
)
self._client = stdio_client(server_params)
self._read, self._write = await self._client.__aenter__()
self.session = await ClientSession(self._read, self._write).__aenter__()
await self.session.initialize()
# Discover available tools
tools_result = await self.session.list_tools()
self.tools = [tool.name for tool in tools_result.tools]
async def execute_tool(self, tool_name: str, arguments: dict):
result = await self.session.call_tool(tool_name, arguments)
return result.content[0].text if result.content else None
async def close(self):
await self.session.__aexit__(None, None, None)
await self._client.__aexit__(None, None, None)
El fragmento necesita algunas correcciones antes de poder ejecutarse. Utiliza os.getenv sin importar os. Lanza node sobre un archivo .ts, lo cual solo funciona si la versión de Node puede ejecutar TypeScript directamente; de lo contrario, compila primero el servidor a JavaScript o utiliza un ejecutor que entienda TypeScript. Llamar manualmente a __aenter__ y __aexit__ funciona, pero los bloques async with o un AsyncExitStack son más seguros porque garantizan la limpieza en caso de errores. También hay que tener en cuenta que el entorno pasado al proceso hijo reemplaza al del padre, por lo que se debe incluir cualquier otro elemento necesario para el servidor, como PATH.
El lado de React: transmisión en flujo y aprobación de llamadas a herramientas
Aquí es donde los desarrolladores de React realizan su trabajo más distintivo. El backend transmite eventos que contienen no solo texto, sino también solicitudes de llamadas a herramientas y resultados de dichas herramientas, y la interfaz de usuario los convierte en elementos interactivos.
El componente ChatInterface que se muestra a continuación mantiene una lista de mensajes, cada uno de los cuales puede contener toolCalls con un estado de pending, approved, rejected o completed. Cuando el usuario envía un mensaje, se agrega la entrada del usuario, se abre un EventSource hacia /api/chat y se va construyendo un mensaje del asistente a medida que llegan los eventos. Un evento text se agrega al contenido, un evento tool_call añade una llamada a herramienta pendiente, y un evento tool_result registra el resultado y marca la llamada correspondiente como completed. Después de cada evento, se reemplaza el mensaje del asistente en el estado por una copia nueva, lo que hace que React vuelva a renderizar. approveToolCall envía la decisión a /api/chat/approve-tool y, de forma optimista, cambia el estado de la llamada a approved.
// components/ChatInterface.tsx
"use client";
import { useState, useRef, useCallback } from "react";
import { ToolCallCard } from "./ToolCallCard";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
}
interface ToolCall {
id: string;
name: string;
arguments: Record<string, any>;
status: "pending" | "approved" | "rejected" | "completed";
}
export function ChatInterface() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const eventSourceRef = useRef<EventSource | null>(null);
const sendMessage = useCallback(async (content: string) => {
// Add user message
const userMsg: Message = {
id: `user-${Date.now()}`,
role: "user",
content,
};
setMessages((prev) => [...prev, userMsg]);
// Open SSE connection to backend
const es = new EventSource(
`/api/chat?message=${encodeURIComponent(content)}`
);
eventSourceRef.current = es;
let assistantMsg: Message = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: "",
toolCalls: [],
};
es.onmessage = (event) => {
const chunk = JSON.parse(event.data);
switch (chunk.type) {
case "text":
assistantMsg.content += chunk.text;
setMessages((prev) => {
const filtered = prev.filter((m) => m.id !== assistantMsg.id);
return [...filtered, { ...assistantMsg }];
});
break;
case "tool_call":
// Model wants to call a tool
assistantMsg.toolCalls = [
...(assistantMsg.toolCalls || []),
{
id: chunk.tool_call_id,
name: chunk.name,
arguments: chunk.arguments,
status: "pending",
},
];
setMessages((prev) => {
const filtered = prev.filter((m) => m.id !== assistantMsg.id);
return [...filtered, { ...assistantMsg }];
});
break;
case "tool_result":
// Tool execution completed
assistantMsg.toolResults = [
...(assistantMsg.toolResults || []),
{
toolCallId: chunk.tool_call_id,
result: chunk.result,
},
];
// Update the specific tool call status
assistantMsg.toolCalls = assistantMsg.toolCalls?.map((tc) =>
tc.id === chunk.tool_call_id
? { ...tc, status: "completed" }
: tc
);
setMessages((prev) => {
const filtered = prev.filter((m) => m.id !== assistantMsg.id);
return [...filtered, { ...assistantMsg }];
});
break;
}
};
es.onerror = () => {
es.close();
};
}, []);
const approveToolCall = useCallback(
async (messageId: string, toolCallId: string) => {
// Send approval to backend
await fetch("/api/chat/approve-tool", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messageId, toolCallId }),
});
// Optimistically update UI
setMessages((prev) =>
prev.map((msg) => {
if (msg.id !== messageId) return msg;
return {
...msg,
toolCalls: msg.toolCalls?.map((tc) =>
tc.id === toolCallId ? { ...tc, status: "approved" } : tc
),
};
})
);
},
[]
);
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${
msg.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] rounded-lg p-4 ${
msg.role === "user"
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-900"
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
{msg.toolCalls?.map((tool) => (
<ToolCallCard
key={tool.id}
tool={tool}
onApprove={() => approveToolCall(msg.id, tool.id)}
/>
))}
</div>
</div>
))}
</div>
<div className="border-t p-4">
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
setInput("");
}}
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about your data..."
className="w-full rounded-lg border px-4 py-2"
/>
</form>
</div>
</div>
);
}
Cada llamada a una herramienta es mostrada por un pequeño componente de presentación que muestra el nombre de la herramienta, su estado, los argumentos en formato JSON y, mientras la llamada está pendiente, los botones Aprobar y Rechazar:
// components/ToolCallCard.tsx
interface ToolCallCardProps {
tool: {
name: string;
arguments: Record<string, any>;
status: string;
};
onApprove: () => void;
}
export function ToolCallCard({ tool, onApprove }: ToolCallCardProps) {
return (
<div className="mt-3 rounded border border-yellow-300 bg-yellow-50 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-yellow-800">
🔧 Tool Request: {tool.name}
</span>
<span className="text-xs text-yellow-600 uppercase">
{tool.status}
</span>
</div>
<pre className="mt-2 text-xs bg-white p-2 rounded overflow-x-auto">
{JSON.stringify(tool.arguments, null, 2)}
</pre>
{tool.status === "pending" && (
<div className="mt-3 flex gap-2">
<button
onClick={onApprove}
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"
>
Approve
</button>
<button className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
Reject
</button>
</div>
)}
</div>
);
}
Aquí es donde la abstracción resulta útil. La interfaz de usuario no tiene idea de que query_users se ejecuta contra PostgreSQL, y no necesitará cambiar cuando aparezca la herramienta search_slack mañana. Solo sabe que hay una llamada a herramienta en espera, qué argumentos lleva y que alguien debe tomar una decisión al respecto.
Deficiencias por resolver antes de la producción
El ejemplo ilustra la estructura de la interfaz de usuario, pero hay algunas deficiencias que conviene corregir:
- La aprobación debe aplicarse en el servidor. El backend debe retener la llamada al herramienta hasta recibir una aprobación para ese ID de llamada específico, vinculado al usuario autenticado. El cambio de estado optimista en la interfaz de usuario es solo una indicación; tal como está escrito, nada impide que llegue un
tool_resultindependientemente del botón utilizado. - El botón “Rechazar” no tiene un manejo correspondiente. Conéctelo a un endpoint que indique al servidor cancelar la llamada y permita que el modelo continúe sin el resultado.
EventSourcesolo realiza solicitudes GET, por lo que el mensaje del usuario se envía en la cadena de consulta, donde está sujeto a los límites de longitud de la URL y puede terminar en los registros del servidor y del proxy. Una solicitud POST que lee el cuerpo de la respuesta en streaming confetchevita ambos problemas.
onerror al usuario en lugar de cerrarla en silencio.Lista de verificación para la integración de MCP en equipos React
Resuelva estas cuestiones arquitectónicas antes de integrar MCP:
¿Quién es el responsable del cliente MCP?
En entornos de producción, el backend. Los servidores MCP suelen requerir credenciales, conexiones persistentes y sesiones con estado. La aplicación React debe recibir un flujo de eventos estructurado diseñado para la interfaz de usuario, y no mensajes de protocolo en bruto.
¿Cómo se aprueban las llamadas a las herramientas?
Nunca permita que el modelo ejecute herramientas destructivas sin una confirmación explícita. Si solicita algo como delete_user, la interfaz debe mostrar un paso de confirmación. Esto se refiere tanto a la confianza del usuario como a su seguridad. Diseñe el chat de modo que la transmisión en tiempo real se pause cuando llegue una llamada a una herramienta y solo se reanude después de que el usuario lo apruebe, y, como se mencionó anteriormente, haga cumplir esa pausa en el servidor.
¿Cómo se transmiten los estados parciales?
Utilice SSE o WebSockets. Una sola respuesta pasa por varias fases: el modelo razona, solicita una herramienta, espera su respuesta y luego continúa. La interfaz de usuario debe representar claramente cada fase con un indicador de progreso, tarjetas de llamadas a herramientas y resultados de las mismas mostrados como datos estructurados en lugar de texto sin diferenciar.
¿Cómo se muestran los errores?
Los servidores MCP fallan: las conexiones a la base de datos se interrumpen y los servidores del sistema de archivos presentan errores de permisos. El frontend debe recibir estos como eventos de error estructurados y mostrarlos como problemas resolubles, nunca como una pantalla rota.
¿Cómo se descubren las herramientas?
La aplicación debe adaptarse a las herramientas que estén disponibles. Cuando el host se conecta a un nuevo servidor, debe pasar la lista actualizada de herramientas al frontend, el cual podrá luego mostrar a los usuarios las capacidades actuales, como buscar cuentas, buscar documentos o ejecutar consultas analíticas.
¿Qué cambios trae MCP para el trabajo en el frontend?
Sin un protocolo compartido, cada fuente de datos, integración de modelos y herramienta necesita su propio mecanismo de conexión, como un cajón lleno de cargadores incompatibles. MCP estandariza la conexión: los datos se convierten en herramientas descritas mediante esquemas, los hosts las consumen a través de una única interfaz y la UI presenta cada llamada como un elemento interactivo. En la práctica:
- Se pueden añadir nuevas capacidades sin cambios en el frontend. Basta conectar un nuevo servidor MCP al host para que una UI genérica de llamadas a herramientas pueda mostrarlas de inmediato.
- La UI está desacoplada del modelo. Dado que muestra un flujo de eventos estable, cambiar los proveedores de modelos es una cuestión del backend; MCP mantiene constante la parte relacionada con las herramientas, mientras que el host se encarga de las llamadas al modelo específicas de cada proveedor.
- Un componente de chat que comprende las llamadas a herramientas y las aprobaciones hace mucho más que uno que solo muestra texto en formato Markdown.
Puntos clave
- Hospedar MCP en el backend, donde se encuentran las credenciales, conexiones y registros de auditoría, y transmitir eventos estructurados a React.
- Construir servidores de herramientas a partir de operaciones estrechas, validadas y parametrizadas que devuelven únicamente lo que necesita el modelo.
- Aplicar la aprobación en el servidor; el estado de la interfaz es una retroalimentación, no el mecanismo de control.
- Modelar el chat en torno a estados explícitos (texto, llamada pendiente, resultado, error) para que las nuevas herramientas no requieran código de interfaz adicional.
Lecturas relacionadas
- Fortaleciendo un agente Python LangChain con siete middlewares incorporados — Aprenda cómo los middlewares de LangChain 1.0 añaden funcionalidades como resumen, límites de llamadas, reintentos, fallback de modelo, eliminación de PII y aprobación humana a un agente Gemini sin modificar su lógica principal.