Building a Low-Code MCP Dashboard with React and Tailwind

Written by

in

# 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.*

![OnlineCloud AI engineering solutions](https://www.onlinecloud.dev/assets/hero-company-V8Xps1Ae.png)

## 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.tool}

{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; rootId: string }) {
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(null);
const [argsDraft, setArgsDraft] = useState(“{}”);
const [argsError, setArgsError] = useState(null);
const [log, dispatch] = useReducer(dashboardReducer, {});
const [selected, setSelected] = useState(null);

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 1: server tools */}

{/* Column 2: schema + raw args editor */}


{active && (
<>