This article is published in English.
Wiring MCP Tools Into a React Chat UI With Human Approval Built In
Learn how the Model Context Protocol fits a React app: why the backend should host MCP, how a tool server works, and how to stream and approve tool calls in the UI.
AI features in a React application tend to grow one bespoke integration at a time, each with its own SDK, authentication, error handling and data mapping, all tightly coupled. The Model Context Protocol (MCP), an open protocol introduced by Anthropic and now adopted widely, replaces that tangle with one standard interface between AI applications and the tools and data they use; the common analogy is a USB-C port for AI. This article explains where MCP sits in a React architecture, walks through a small database tool server and a backend host, and then builds the part React developers own: a chat interface that streams tool calls and asks the user to approve them.
If you want a protocol-level primer on discovery and invocation first, see how MCP lets AI agents discover and call tools. The focus here is the application and UI side.
What MCP standardizes
MCP defines how applications supply context and capabilities to large language models. It separates three roles: the host is your application, a client is the connector inside the host that maintains a session with one server, and servers expose the tools and data sources. A host typically runs one client per server it connects to.
Without MCP, the dependency list of an AI-enabled React app often looks something like this:
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)
Each entry carries its own auth, error handling and schema mapping; a new data source means a new endpoint plus a new frontend service.
With MCP, the integration side collapses into a single pattern:
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
Every server speaks the same protocol. The host does not need to understand PostgreSQL or the shape of Slack's API; it asks two generic questions, "which tools are available?" and "run this tool with these arguments", and the protocol carries the rest. Note what MCP does not do: it standardizes the connection to tools and data, not the choice of model. The host still talks to whichever LLM provider you use.
The three primitives
Your React app interacts with three kinds of server capability, directly or, more commonly, through your backend.
Tools
Tools are functions a model can invoke. Each has a name, a description and a JSON Schema describing its parameters. When a user asks how many accounts were created yesterday, the model does not have to guess: it can see a tool such as query_user_signups that accepts a date, call it, and answer from the result. Tools are what connect a user's plain-language question to the data behind your app.
Resources
Resources are pieces of context the model can read, such as a file, a database record or a chat thread. Each is addressed by a URI, for example file:///docs/spec.pdf or db://users/123, and reading them lets the model ground its answers in real data rather than its training.
Prompts
Prompts are reusable templates published by a server. A server might offer a code_review prompt that takes a file_path argument; the host fetches the template, fills in the argument and sends the result to the model.
Why the frontend is more than a display
The pass-through frontend
In many AI apps, the React client posts the user's message to a Node or FastAPI backend, which forwards it to the model provider, waits for the response and relays it back. If the model needs a tool, the backend handles that as well. The frontend is a passive text renderer: it has no insight into what the model is doing and no way to intervene.
The frontend as the control surface
With MCP-style tool calling, the UI can show tool calls as they happen and let the user approve or reject sensitive operations before they run. Whether the browser holds the MCP connections itself or, more commonly, receives a structured stream from a backend host, React becomes the place where orchestration is visible and steerable.
Users increasingly expect that control: to see that the assistant is about to query their data and to approve it first. That experience is built in React.
Where the MCP host should live
There are two workable architectures. Choose between them based on your security and latency requirements.
Backend-mediated MCP, the default choice
In this pattern the React app talks only to your backend, and the backend is the MCP host. It keeps connections to MCP servers open, handles authentication and relays tool activity to the frontend:
React (Client) <--SSE/WS--> FastAPI/Node (MCP Host) <--stdio/SSE--> MCP Servers
The advantages are decisive for most products:
- Security: credentials for MCP servers stay on the server and never reach the browser.
- State: persistent database and file system sessions are kept on the server, where they belong.
- Auditability: each tool call can be logged, rate-limited and attributed to a specific user.
The frontend consumes a structured stream of events (text chunks, tool call requests, tool results and the final answer) and renders each state deliberately.
A note on transports: the diagram shows stdio between host and local servers and SSE for remote ones. The MCP specification has evolved its HTTP transport over time, so check the current spec and SDK docs for the recommended remote transport.
Browser-native MCP, for narrow cases
Alternatively, the React app can connect straight to remote MCP servers over HTTP-based streaming. It works, but production teams rarely choose it, since the data layer and its credentials end up reachable from the browser. Reserve it for local developer tools or fully client-side AI apps that handle no sensitive data.
A database tool server in TypeScript
Building a small server is the fastest route to understanding the protocol. The example below exposes a PostgreSQL users table as two tools using the official TypeScript SDK. Read it in three parts. First, it creates a connection pool and an MCP Server that advertises the tools capability. Second, the ListToolsRequestSchema handler describes each tool with a name, a description and an inputSchema, which is what the model sees when deciding what to call. Third, the CallToolRequestSchema handler dispatches by tool name, runs a parameterized query and returns the rows as text content, or returns isError: true with a message when something fails. Finally, the server connects over stdio, so a host can launch it as a child process.
// 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);
Notice what the design avoids: the model never sends raw SQL. It can only choose between narrow, named operations, and the queries use placeholders ($1, $2) so arguments cannot inject SQL. Returning errors as content with isError lets the model see and explain the failure instead of the whole session crashing.
Before using something like this for real, tighten a few things. The JSON Schema describes the inputs, but the handler should still validate args itself (for example with a schema library), since it may be missing or malformed; args.limit should also be capped. get_user_by_email runs SELECT *, which would hand every column to the model, including anything sensitive such as password hashes, so select explicit columns instead. And in strict TypeScript, error in the catch block is unknown, so check it before reading .message.
A backend host in Python
The React app never talks to that server directly; the backend does. Here is a minimal host class using the Python MCP SDK. connect() describes how to launch the server process (command, args and an environment that passes DATABASE_URL), opens a stdio client, starts a ClientSession, performs the protocol handshake with initialize() and then calls list_tools() to discover what the server offers. execute_tool() forwards a tool name and arguments and returns the first text item from the result, and close() tears the session and process down.
# 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)
The snippet needs some repair before it runs. It uses os.getenv without importing os. It launches node on a .ts file, which only works if your Node version can execute TypeScript directly; otherwise compile the server to JavaScript first or use a TypeScript-aware runner. Calling __aenter__ and __aexit__ by hand works, but async with blocks or an AsyncExitStack are safer because they guarantee cleanup on errors. Also note that the environment passed to the child process replaces the parent's, so include anything else the server needs, such as PATH.
The React side: streaming and approving tool calls
This is where React developers do their most distinctive work. The backend streams events that contain not only text but tool call requests and tool results, and the UI turns them into interactive elements.
The ChatInterface component below keeps a list of messages, each of which may carry toolCalls with a status of pending, approved, rejected or completed. When the user sends a message, it appends the user's entry, opens an EventSource to /api/chat and builds up an assistant message as events arrive. A text event appends to the content, a tool_call event adds a pending tool call, and a tool_result event records the result and marks the matching call completed. After each event it replaces the assistant message in state with a fresh copy, so React re-renders. approveToolCall posts the decision to /api/chat/approve-tool and optimistically flips the call's status to 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>
);
}
Each tool call is rendered by a small presentational component that shows the tool name, its status, the arguments as formatted JSON and, while the call is pending, Approve and Reject buttons:
// 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>
);
}
This is where the abstraction pays off. The UI has no idea that query_users runs against PostgreSQL, and it will not need to change when a search_slack tool appears tomorrow. It only knows a tool call is waiting, what arguments it carries and that a human has to rule on it.
Gaps to close before production
The example illustrates the shape of the UI, but a few gaps are worth closing:
- The approval must be enforced on the server. The backend has to hold the tool call until it receives an approval for that exact call ID, tied to the authenticated user. The UI's optimistic status change is only feedback; as written, nothing stops a
tool_resultfrom arriving regardless of the button. - The Reject button has no handler. Wire it to an endpoint that tells the host to cancel the call and lets the model continue without the result.
EventSourceonly makes GET requests, so the user's message travels in the query string, where it is subject to URL length limits and can end up in server and proxy logs. A POST request that reads a streamed response body withfetchavoids both issues.- The stream should end explicitly. Close the connection on a final "done" event, close it when the component unmounts, and surface
onerrorto the user instead of silently closing.
An MCP integration checklist for React teams
Settle these architectural questions before integrating MCP:
Who owns the MCP client?
In production, the backend. MCP servers commonly need credentials, persistent connections and stateful sessions. The React app should receive a structured event stream designed for the UI, not raw protocol messages.
How are tool calls approved?
Never let the model run destructive tools without explicit confirmation. If it requests something like delete_user, the interface must show a confirmation step. This is about user trust as much as safety. Design the chat so that streaming pauses when a tool call arrives and resumes only after the user approves, and, as noted above, enforce that pause on the server.
How are partial states streamed?
Use SSE or WebSockets. A single answer moves through several phases: the model reasons, requests a tool, waits for it and then continues. The UI should represent each phase clearly with a progress indicator, tool call cards, and tool results rendered as structured data rather than undifferentiated text.
How are errors surfaced?
MCP servers fail: database connections drop and file system servers hit permission errors. The frontend should receive these as structured error events and present them as recoverable problems, never as a broken screen.
How are tools discovered?
The app should adapt to whichever tools are available. When the host connects to a new server, it should pass the updated tool list to the frontend, which can then list the current capabilities for users, such as looking up accounts, searching documents or running analytics queries.
What MCP changes for frontend work
Without a shared protocol, every data source, model integration and tool needs its own glue, like a drawer full of mismatched chargers. MCP standardizes the connection: data becomes schema-described tools, hosts consume them through one interface, and the UI presents each call as an interactive element. In practice:
- New capabilities can arrive without frontend changes. Connect a new MCP server to the host, and a generic tool-call UI can present its tools immediately.
- The UI is decoupled from the model. Because it renders a stable event stream, switching model providers is a backend concern; MCP keeps the tool side constant, while the host handles the provider-specific model calls.
- A chat component that understands tool calls and approvals does far more than one that renders markdown.
Key takeaways
- Host MCP on the backend, where credentials, connections and audit logs belong, and stream structured events to React.
- Build tool servers from narrow, validated, parameterized operations that return only what the model needs.
- Enforce approval on the server; the UI status is feedback, not the gate.
- Model the chat around explicit states (text, pending call, result, error) so new tools need no new UI code.