# Building a Low-Code MCP Dashboard with React and Tailwind
*How to turn a stream of MCP JSON-RPC notifications into a real-time, human-checkpointed control room — without leaving the React ecosystem.*

## 1. Why Your Agent Needs a Dashboard It Never Asked For
The first time you watch a production agent run unattended, you’ll notice something uncomfortable: it’s a black box with a pulse. It plans, calls tools, mutates rows in your ERP, and reports back in terse JSON that no human reads until something breaks. That’s the fundamental trust problem with agentic systems — not model quality, but *observability of intent*. A plan/act/reflect loop that touches a production database needs a human who can answer one question at any moment: *what is the agent doing right now, and should it be allowed to finish?*
This is the human-in-the-loop gap. Model Context Protocol (MCP) gives you typed tools, declarative resources, and auditable transports, but it doesn’t hand you a UI. The transport is a contract, not a cockpit. If you’re running MCP servers in front of Oracle, MS SQL, Snowflake, or a CRM, an autonomous loop that can issue writes deserves a control surface between the LLM and your business logic — a surface that visualizes each tool call as it happens, makes the agent’s decision tree legible, and inserts a hard checkpoint before high-impact mutations.
Think about what actually goes wrong in production. An agent tasked with reconciling invoices chains five tools: a read query, a fuzzy match, a write to the accounting system, a notification to finance, and a retry path when a row is locked. Each step is individually reasonable; the *combination* is where risk hides. Without a live view, your only signals are a completed run or a timeout — you find out an hour later that the agent emailed 40 customers with a miscomputed total because a JOIN dropped a filter. A dashboard doesn’t prevent the bad idea; it compresses the time between the bad idea and a human seeing it, from hours to seconds. That compression is the entire value proposition of human-in-the-loop tooling.
The fastest way to build that surface, without dragging in a heavy BI stack, is a single-page dashboard: React for state and component modeling, Tailwind for the visual layer, and a thin adapter that bridges MCP’s streamable transports into React state. Low-code here doesn’t mean “no code.” It means the *dashboard* logic stays small enough that a team can ship it in a sprint, because the hard protocol work is already done by MCP. You’re not building an agent platform; you’re building a window onto one.
## 2. Dashboard Architecture: From MCP Transport to React State
MCP servers expose tools over two mainstream transports: the original SSE-based transport and the newer streamable HTTP transport. From a dashboard’s perspective, both collapse into the same shape: a client-to-server JSON-RPC channel for requests, and a server-to-client event stream for notifications. The notifications you care about are the ones that make agent behavior visible: `notifications/progress` for tool execution progress, tool call lifecycle messages, and server requests that need human input (which MCP models as a `sampling/createMessage` request).
The architectural rule that keeps this dashboard “low-code” is **never let a UI component talk to the wire**. Every transport event funnels through one place — a reducer that normalizes JSON-RPC into a flat, indexable domain model. Everything downstream (progress bars, decision trees, modals, the inspector) is a pure function of that state. That single choke point is also where you attach audit logging, which matters when the dashboard is your compliance story for “who approved this tool call.”
“`tsx
// bridge.ts — normalize raw MCP notifications into app events.
// One adapter per transport; the reducer never sees JSON-RPC.
export type McpEvent =
| { kind: “tool:started”; callId: string; tool: string;
parentCallId: string | null; args: unknown }
| { kind: “progress”; callId: string; pct: number | null; message?: string }
| { kind: “tool:finished”; callId: string; result: unknown }
| { kind: “checkpoint:requested”; callId: string; tool: string };
export function fromJsonRpc(msg: any): McpEvent | null {
if (msg.method === “notifications/progress”) {
const p = msg.params;
return {
kind: “progress”,
callId: p.progressToken, // our correlation id
pct: p.total ? Math.round((p.progress / p.total) * 100) : null,
message: p.message,
};
}
// … map tool:started / finished / sampling requests the same way
return null;
}
“`
With an `EventSource` (SSE) or a fetch-based reader (streamable HTTP) feeding `dispatch(fromJsonRpc(raw))`, React becomes a pure projection of the agent’s runtime state — the same trick that makes Redux DevTools useful, applied to your agent. A `parentCallId` field is the seed of your decision tree: because MCP tool calls nest (a plan tool calls a query tool), retaining the parent link on every event lets you render the agent’s reasoning structure for free.
## 3. Real-Time UI: Progress Bars and Decision Trees in Tailwind
Once events are normalized, the UI is almost mechanical. Tool-call progress is a per-call percentage plus a status enum — `queued`, `running`, `awaiting-approval`, `done`, `error`. A progress bar in Tailwind is a borderless rounded track with a gradient fill whose width is driven by a style binding, animated purely by `transition-[width]`. The status enum drives color: emerald for healthy execution, amber when the tool is parked at a human checkpoint, rose for failures.
“`tsx
// ToolProgress.tsx — one running tool call
export function ToolProgress({ tool }: { tool: ToolState }) {
return (
{tool.pct === null ? “running…” : `${tool.pct}%`}
);
}
“`
The decision tree is where a dashboard earns its keep. A flat log of fifty tool calls is unreadable; a tree that shows the plan spawning lookups, which spawn a write, which now waits on a human — that’s a story. Because every call carries `parentCallId`, rendering the tree is a recursive component: each node is a status dot plus a mono-spaced tool name, with children indented under a hairline left border. Recursion depth rarely exceeds four or five levels, so even naive recursion is fine.
“`tsx
// DecisionTree.tsx — recursive render of nested tool calls
export function DecisionTree({ nodes, rootId }: { nodes: Map
const root = nodes.get(rootId);
if (!root) return null;
const children = […nodes.values()].filter((n) => n.parentCallId === rootId);
return (
{root.tool}
{latencyMs(root)}
{children.map((c) =>
);
}
“`
On a dark control-room palette — slate panels, one accent gradient, mono for tool names — this reads like an observability product in an afternoon. Tailwind’s utility model keeps the whole UI in one file of small components rather than a design-system project. Two details matter for polish. First, keep bars *indeterminate* (a sliding pulse) when a call reports no total; a static 4% bar for a twenty-minute export reads as a hang. Second, animate tree edges only when a node *enters* — flashing the whole subtree on every status change is visual noise. Small motion rules like these are what separate a dashboard operators trust from one they ignore.
## 4. User Interaction: Human Checkpoints for High-Impact Tools
Visualization is half the loop; the other half is the ability to *stop*. In MCP terms, when an agent wants to call something risky, the server issues a `sampling/createMessage` request back to the client — the protocol’s native human-input channel. Your dashboard answers that request with a modal instead of letting the client auto-reply. That turns MCP’s sampling hook into a proper approval gate: the tool call parks in `awaiting-approval`, the decision tree goes amber, and a modal renders the args, the call path that led here, and Approve / Reject actions.
The trick is to make approval *policy-driven*, not ad hoc. A small declarative map decides which tools trigger a checkpoint: `create_refund` and `send_email` confirm; `read_only_query` auto-runs; destructive ERP calls confirm with a typed acknowledgment. Keeping policy in one object means the dashboard stays low-code while the blast-radius logic lives somewhere an architect can review it — a stance OnlineCloud takes seriously when wiring MCP servers to real databases with real auth boundaries.
“`tsx
// CheckpointModal.tsx — rendered when a call parks at a human gate
export function CheckpointModal({ pending, onApprove, onReject }:
{ pending: ToolState; onApprove: () => void; onReject: () => void }) {
return (
Approval required
The agent wants to call {pending.tool}
{” “}with the arguments below. Review before it proceeds.
{JSON.stringify(pending.args, null, 2)}
);
}
“`
Every decision — approve, reject, or the timeout that defaults to reject — should append an audit record to the same event log the dashboard renders. Now your “human checkpoints” feature doubles as an audit trail, which is precisely the difference between an agent demo and a system a compliance team signs off on. A useful pattern is to log the *reason* an operator gives when rejecting — a required text field on the reject path — so the agent’s error-correcting loop has something to learn from, and auditors have a record that the rejection was deliberate rather than a misclick.
## 5. Code Walkthrough: The MCP Tool Inspector
The centerpiece of a dashboard for engineers is a **Tool Inspector**: pick a server, browse its declared tools, inspect schemas, fire a call with custom arguments, and watch the live result — including the decision-tree context around it. It’s the MCP equivalent of a database query tool, and it’s where all the earlier pieces converge.
State lives in three places: the tool list (fetched once from the server’s `tools/list`), the invocation log (a `useReducer` over the event bridge — the same reducer powering the tree), and UI micro-state for the active tool, the argument draft, and the selected call. The schema panel needs no custom form generator: MCP tool inputs are JSON Schema, so render required fields, types, and descriptions from `inputSchema`, and let engineers edit raw arguments in a textarea with live JSON validation. That’s what keeps it low-code — you’re *reflecting* the server’s metadata, not maintaining a parallel form definition.
“`tsx
// ToolInspector.tsx — the working core
export function ToolInspector({ serverUrl }: { serverUrl: string }) {
const [tools, setTools] = useState
const [active, setActive] = useState
const [argsDraft, setArgsDraft] = useState(“{}”);
const [argsError, setArgsError] = useState
const [log, dispatch] = useReducer(dashboardReducer, {});
const [selected, setSelected] = useState
useEffect(() => {
fetch(`${serverUrl}/tools/list`).then((r) => r.json())
.then((res) => setTools(res.tools));
}, [serverUrl]);
const invoke = useCallback(async () => {
if (!active) return;
let parsed: unknown;
try { parsed = JSON.parse(argsDraft); }
catch { setArgsError(“Arguments are not valid JSON”); return; }
// Ask the server to emit progress under a token we control,
// then correlate incoming notifications with this invocation.
const token = crypto.randomUUID();
dispatch({ kind: “tool:started”, callId: token, tool: active.name,
parentCallId: null, args: parsed });
setSelected(token);
const res = await callTool(serverUrl, active.name, parsed, token);
dispatch(res.ok
? { kind: “tool:finished”, callId: token, result: res.value }
: { kind: “tool:failed”, callId: token, error: res.value });
}, [active, argsDraft, serverUrl]);
const activeCall = selected ? log[selected] : null;
return (
{/* Column 2: schema + raw args editor */}
{active && (
<>
{argsError}
}
>
)}
{/* Column 3: live call + decision tree */}
?
:
Invoke a tool to inspect its run…
}
);
}
“`
Note what’s deliberate here: `useCallback` on `invoke` so the button never re-renders on log churn, `useReducer` so progress notifications trigger narrow updates instead of whole-tree re-renders, and a correlation token that lets one inspector session drive many concurrent calls. The same component, pointed at a different MCP server URL, inspects a database connector, a CRM wrapper, or a payment tool — that reusability is the payoff of building on the protocol instead of per-server glue.
## 6. Performance: Surviving Large-Scale Decision Logs
A long-running agent session can emit thousands of events. Progress notifications are the worst offender — an LLM streaming through a file export can push dozens of updates per second, and if each one re-renders the whole decision tree, your “real-time” dashboard becomes a slideshow. Before reaching for tools, set a latency budget: the dashboard should reflect a state change within 100ms of the transport delivering it, and it should hold 60fps while doing so. Everything below is aimed at that budget. The fixes, in order of impact:
**Coalesce high-frequency events.** Progress is ephemeral — only the latest percentage per call matters. Instead of dispatching every notification straight into the reducer, buffer them in a ref and flush once per animation frame. You trade a few milliseconds of displayed staleness for a hard cap on renders per second.
“`ts
// rAF coalescing: at most one state flush per frame, no matter the spam
const buffer = useRef(new Map
useEffect(() => {
let raf = 0;
const flush = () => {
if (buffer.current.size) {
buffer.current.forEach((pct, callId) =>
dispatch({ kind: “progress”, callId, pct }));
buffer.current.clear();
}
raf = 0;
};
return () => cancelAnimationFrame(raf);
}, []);
// On each SSE notification: buffer.current.set(callId, pct); raf ||= requestAnimationFrame(flush);
“`
**Memoize leaf components and virtualize the log.** Wrap the log row in `memo` so an update to one call only re-renders that row. Then cap the visible DOM: with thousands of completed calls, render only the viewport window with `react-window`’s `FixedSizeList`. Virtualization turns a 5,000-row log into ~20 mounted rows, and the decision tree’s depth naturally bounds its cost. One caveat: keep the visible row height constant (hence `FixedSizeList`, not the variable-size variant) — monospaced, single-line rows make that trivial and measurable.
“`tsx
const LogRow = memo(function LogRow({ call }: { call: ToolState }) {
return (
{call.tool}
{latencyMs(call)}
);
});
“`
**Keep the store flat and indexed.** Store calls in a `Record
Follow the same discipline OnlineCloud applies to production MCP systems: measure latency of the dashboard itself ($/request and render time are both line items), keep a rollback path, and let the eval — in this case, “can an operator understand and stop the agent in under five seconds” — define done.
## Ship the Cockpit, Not Just the Agent
The pattern is portable: a normalizing bridge, a reducer that turns MCP notifications into a flat state model, Tailwind components that project that state into progress bars and decision trees, and a policy map that decides when a human must step in. That’s a production-grade human-in-the-loop surface with maybe a few hundred lines of React — because the protocol did the heavy lifting.
When you’re ready to go deeper — checkpoint policies wired to your actual ERP schema, audit-grade approval logs, streamable HTTP tuned for low-latency long-running calls — that’s exactly the territory OnlineCloud works in. We’re a US-based engineering studio building MCP-native, evaluated, observable systems that plug into Oracle, MS SQL, your CRM, and the tools your team uses daily. Senior engineers only, no offshore handoffs, production in 6–10 weeks.
**[Get in touch →](mailto:contact@onlinecloud.dev)** — free initial consultation, response within 24 hours. Or see more at **[onlinecloud.dev](https://www.onlinecloud.dev/)**.
Leave a Reply