Author: sandeepsingh

  • Scaling MCP Servers with Serverless AWS Lambda and SSE

    # Scaling MCP Servers with Serverless AWS Lambda and SSE

    *Deep-dive for cloud architects who are tired of babysitting a Kubernetes cluster so a handful of agent tools can talk to the CRM.*

    ![OnlineCloud — Agentic AI & MCP Engineering Studio](https://www.onlinecloud.dev/assets/hero-company-V8Xps1Ae.png)

    —

    ## 1. Introduction: From Container Fleets to Event-Driven Tools

    Most MCP (Model Context Protocol) servers shipped to production last year followed one template: take the SDK of your choice — FastMCP, the TypeScript or Python reference servers, a Go SDK — wrap it in Docker, and park it on ECS or EKS behind an ALB. One container per tool domain, a couple of replicas for “HA,” an autoscaler, and a promise that the cluster will figure itself out.

    That template was the only thing the original MCP SDKs supported: a long-lived HTTP process speaking SSE. But it imports every operational cost of the container era into a workload that is fundamentally **event-shaped**. An agent issues a `tools/call`, waits seconds for a JSON-RPC result, and moves on. Between calls your deployment does nothing — yet you pay for idle pods, node pools that must never drop below two, and an EKS control plane that bills $73/month whether you execute one tool call or a million.

    The architectural reframe: stop modeling MCP as a *service* and model it as a *stream of events*.

    | Container mental model | Serverless mental model |
    |—|—|
    | Deploy N replicas of each MCP server | One function per transport concern |
    | HPA scales pods with minutes of lag | Every event is an isolated invocation |
    | Pay for idle capacity 24/7 | Pay only for GB-seconds actually executed |
    | Session state lives in process memory | Session state lives in DynamoDB |
    | “HA” means running 2+ of everything | HA is the platform’s default posture |

    In this model, a client’s MCP session is a durable conversation record, a `tools/call` is a Lambda invocation, and the SSE channel the agent listens on is a thin, replaceable relay. This post walks that architecture end to end: the POST-to-SSE bridge on Lambda URLs with DynamoDB as the backbone, cold-start tactics for first-token latency, concurrency at thousands of parallel calls, a CDK implementation, and an honest ROI comparison against the EKS floor.

    —

    ## 2. Lambda for MCP: The POST-to-SSE Bridge with Lambda URLs and DynamoDB

    MCP’s classic HTTP transport has two asymmetric legs: the client holds open a **GET /sse** stream for server-pushed responses and notifications, and it **POSTs** client-to-server messages (`initialize`, `tools/call`) to a messages endpoint. The naive Lambda port — one function owning a session, handling both legs, keeping the SSE stream open in memory — collapses under its own weight: a held-open execution is billed for the whole session, and tool calls that could run in parallel get serialized behind one instance.

    The production pattern splits the legs into independent functions joined by a durable bridge:

    – **`mcp-session` (SSE relay).** A Lambda Function URL with response streaming answers `GET /sse?sessionId=…`, streams `text/event-stream` frames, emits heartbeats, and **drains** the session’s results from DynamoDB.
    – **`mcp-executor` (worker).** A second Function URL serves `POST /messages`: authenticate, parse the JSON-RPC envelope, execute the tool, and **append** the result to the session’s outbox in DynamoDB.

    DynamoDB is the bridge. Two tables carry the system: `mcp_sessions` (keyed by `sessionId`, holding negotiated capabilities, an activity flag, and a TTL so orphaned sessions self-destruct) and `mcp_event_outbox` (keyed by `(sessionId, seq)` with a monotonically increasing sort key — the executor writes one item per response, the relay queries forward from its last-read cursor). Because JSON-RPC responses correlate by request `id`, strict ordering across *different* tool calls is unnecessary — which is what makes a horizontally scaled executor safe: any warm instance picks up any POST, executes, and appends, while the single relay streams results in order.

    “`
    Agent mcp-session (relay) DynamoDB mcp-executor (workers)
    │ GET /sse?sessionId=… │ │ │
    │◄────────────────────────────┤ │ │
    │ POST tools/call ───────────┼──────────────────────┼───────────────────────►│
    │ │ │◄── append result ──────┤
    │ │ drain cursor ──────►│ │
    │◄──── SSE: jsonrpc result ───┤◄─────────────────────┤ │
    “`

    The relay polls the outbox every 300–500 ms only while the session has an in-flight request — tracked by an activity flag the executor sets with a short TTL — and backs off to heartbeat cadence when idle. That converts a naive always-on poller (~$1.30/month in reads per continuously connected session) into a cost that ticks only while an agent is actually waiting on a tool.

    Design around one hard constraint from day one: **Lambda executions cap at 15 minutes**, so a relay holding a stream that long will be terminated mid-session. Treat the relay as a replaceable view, not a stateful process: SSE clients reconnect with `Last-Event-ID`, rejoin the same `sessionId`, pick up the cursor from DynamoDB, and never notice the handoff. The session outlived the relay — that is the entire point of the bridge.

    —

    ## 3. Cold Start Optimization: Managing First-Token Latency

    The objection every architect raises here is legitimate: *”My agent is waiting on this tool — I can’t afford a cold start in the critical path.”* Manage it; don’t pray about it. First-token latency decomposes into: TLS to the Function URL (~20–50 ms region-co-located), executor cold start when one occurs, DynamoDB append + relay drain (single-digit ms), and the poll interval. Only the cold start is under your control — and it is smaller than container folklore suggests. A trimmed Node.js or Python executor with the MCP SDK initializes in **200–600 ms** at 512 MB; Go and Rust MCP servers boot in **under 50 ms** (attractive, since an MCP server is a thin JSON-RPC layer); Java 11+ gets **SnapStart**, cutting startup to hundreds of milliseconds.

    The structural mitigations matter more than the runtime choice:

    – **Keep the executor out of a VPC unless the tools force it.** ENI attachment is the largest cold-start multiplier, historically adding seconds. If tools must reach internal databases, isolate narrowly — RDS Proxy, or one VPC-pinned function behind the hot path.
    – **Exploit burst warmth.** Agent loops are self-warming: ten sequential tool calls keep one instance warm for the burst, so only the *first* call of an idle window absorbs startup cost. Warm-up pings are justified only during known business windows; otherwise you are buying capacity for traffic that never comes.
    – **Provisioned concurrency is a scalpel, not a default.** Pin it on the relay tier only if always-connected agents need instant reconnect, and accept idle billing there. Never provision the executor — that tier must track demand, not forecasts.
    – **Measure the real metric.** Response streaming opens the SSE connection immediately; only the result-bearing frame is latency-sensitive. That also gives you room to stream `notifications/progress` frames during long tools — exactly what a multi-minute Oracle export needs to keep the agent harness from timing out.

    Budget honestly: the warm-path overhead of this stack is roughly **50–150 ms** of platform time beyond the tool’s own execution. A cold call adds a few hundred milliseconds once per idle window — a 15% tax on the first call of a burst when the tool itself runs two seconds, and 0% on the rest. Contrast that with a container deployment that scaled to zero: minutes to recover. Serverless recovers from zero in milliseconds; that asymmetry is the whole game.

    —

    ## 4. Concurrency: Thousands of Parallel Tool Calls, Zero Infrastructure

    Here the event-driven model stops being merely cheaper and becomes categorically more capable. Containers scale tool execution by scaling *whole replicas*: 2,000 concurrent calls means enough aggregate pod capacity, an autoscaler with 1–2 minutes of reaction lag, and headroom burned on that lag — or over-provisioning.

    Lambda scales per invocation. The executor is invoked once per tool call and scheduled onto warm instances up to your account’s soft concurrency limit (1,000 concurrent executions per region by default, with 500–3,000 executions/minute of burst headroom depending on region — both raiseable by ticket in hours, and per-function caps are manageable via reserved concurrency). The ceiling stops being “how many pods can the cluster schedule?” and becomes “how many requests can the downstream API tolerate?” — the right problem to have.

    Two properties fall out of the split design:

    – **Relay and worker scale independently.** A thousand agents connected but idle cost a thousand tiny parked relays at 128 MB (~$0.0075/hour each, and sessions typically last minutes, not days). A thousand agents *actively calling tools* cost a thousand executor invocations that finish in seconds and release capacity. You never provision for the product of both.
    – **Parallel tool calls are free.** Three independent `tools/call` requests in one planning turn become three invocations on whatever instances are warm — the executor acts as a shared elastic pool across every session in the account.

    The failure modes move from infrastructure to application: enforce **concurrency budgets** per downstream system (your CRM’s rate limits are the real bottleneck — use per-tool throttles), carry **idempotency keys** on mutating tools (retries are cheap now, so make them safe), and add **dead-letter handling** on the outbox so a poisoned event can’t stall a session’s stream. These are the disciplines of any event-driven backend; MCP is no longer special.

    —

    ## 5. Implementation: Serverless MCP Infrastructure in AWS CDK

    A minimal but production-shaped CDK stack (TypeScript): two functions behind two Function URLs, DynamoDB as session store and outbox, TTL cleanup, pay-per-request billing.

    “`typescript
    import * as cdk from “aws-cdk-lib”;
    import { Construct } from “constructs”;
    import * as dynamodb from “aws-cdk-lib/aws-dynamodb”;
    import * as lambda from “aws-cdk-lib/aws-lambda”;
    import * as nodejs from “aws-cdk-lib/aws-lambda-nodejs”;

    export class McpServerlessStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Session registry + event outbox (the “bridge”)
    const sessions = new dynamodb.Table(this, “McpSessions”, {
    partitionKey: { name: “sessionId”, type: dynamodb.AttributeType.STRING },
    billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    timeToLiveAttribute: “ttl”,
    });
    const outbox = new dynamodb.Table(this, “McpEventOutbox”, {
    partitionKey: { name: “sessionId”, type: dynamodb.AttributeType.STRING },
    sortKey: { name: “seq”, type: dynamodb.AttributeType.NUMBER },
    billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    timeToLiveAttribute: “ttl”,
    });

    // Leg 1: SSE relay — GET /sse, streams events, drains the outbox
    const relay = new nodejs.NodejsFunction(this, “McpSseRelay”, {
    entry: “lambdas/relay.ts”,
    memorySize: 128,
    timeout: cdk.Duration.minutes(15),
    environment: {
    SESSIONS_TABLE: sessions.tableName,
    OUTBOX_TABLE: outbox.tableName,
    },
    });
    outbox.grantReadData(relay);
    sessions.grantReadWriteData(relay);

    const relayUrl = relay.addFunctionUrl({
    authType: lambda.FunctionUrlAuthType.NONE, // front with CloudFront/JWT in prod
    invokeMode: lambda.InvokeMode.RESPONSE_STREAM, // SSE needs streaming
    });

    // Leg 2: executor — POST /messages, runs the tool, appends the result
    const executor = new nodejs.NodejsFunction(this, “McpExecutor”, {
    entry: “lambdas/executor.ts”,
    memorySize: 512,
    timeout: cdk.Duration.minutes(5),
    environment: {
    SESSIONS_TABLE: sessions.tableName,
    OUTBOX_TABLE: outbox.tableName,
    },
    });
    outbox.grantWriteData(executor);
    sessions.grantReadWriteData(executor);

    const executorUrl = executor.addFunctionUrl({
    authType: lambda.FunctionUrlAuthType.NONE, // enforce auth before this
    cors: { allowedOrigins: [“*”], allowedMethods: [lambda.HttpMethod.POST] },
    });

    new cdk.CfnOutput(this, “RelayUrl”, { value: relayUrl.url });
    new cdk.CfnOutput(this, “ExecutorUrl”, { value: executorUrl.url });
    }
    }
    “`

    The executor’s hot path is a registry lookup plus an atomic outbox append:

    “`typescript
    const seq = await outbox.updateItem({ // atomic next-seq
    TableName: OUTBOX_TABLE,
    Key: { sessionId: { S: sessionId } },
    UpdateExpression: “ADD seq :one”,
    ExpressionAttributeValues: { “:one”: { N: “1” } },
    ReturnValues: “UPDATED_NEW”,
    });
    await outbox.putItem({
    TableName: OUTBOX_TABLE,
    Item: {
    sessionId: { S: sessionId },
    seq: seq.Attributes!.seq,
    event: { S: JSON.stringify({ jsonrpc: “2.0”, id, result }) },
    ttl: { N: String(Math.floor(Date.now() / 1000) + 300) },
    },
    });
    “`

    The relay opens the streamed response and loops: `Query` the outbox from its cursor, write frames as SSE `data:` lines, emit a `: ping` comment every 15 seconds, and exit cleanly when the session completes or the 15-minute ceiling approaches — the client’s SSE reconnect with `Last-Event-ID` rejoins the session from DynamoDB. Streamed responses cap at 20 MB; anything larger should return a presigned reference anyway. Two production notes the skeleton omits: Function URLs support only `NONE` or `AWS_IAM` auth, so put a CloudFront or API Gateway JWT authorizer in front of the executor; and use the `@modelcontextprotocol/sdk` server classes inside the executor rather than hand-rolling JSON-RPC.

    —

    ## 6. ROI: EKS Node Costs vs. Lambda Execution Costs for Spiky AI Workloads

    The ROI argument is not “serverless is cheaper than containers” — that claim is false in general, and be suspicious of anyone making it flatly. The correct claim is about **cost shape**. MCP tool traffic is bursty: business-hours peaks, nightly batch jobs, campaign spikes, with peak-to-average ratios routinely above 10:1. That is precisely the regime where a fixed infrastructure floor loses.

    A minimum viable HA MCP estate on EKS (us-east-1, on-demand, three `m6i.large` across AZs so the HA story is real):

    | Item | Monthly cost |
    |—|—|
    | EKS control plane ($0.10/hr × 730) | $73 |
    | 3 × `m6i.large` ($0.0965/hr × 730) | ~$211 |
    | ALB + NAT gateway + egress (approx.) | ~$60–90 |
    | **Floor before a single tool call executes** | **~$350–375** |

    That floor is paid on nights, weekends, and holidays whether the estate executes five calls or five million. The same workload on Lambda, assuming 512 MB average memory and ~1.2 s average execution per call (generous for CRUD-style tools, which usually finish in hundreds of milliseconds):

    | Monthly tool calls | Lambda compute + requests |
    |—|—|
    | 0 (quiet month) | **$0.00** |
    | 1,000,000 | ~$10.20 (600k GB-s × $0.0000167 + $0.20/M requests) |
    | 10,000,000 | ~$102 |
    | Crossover vs. the EKS floor | ~20–35M calls/month |

    The crossover sits at 20–35 million calls per month — beyond most enterprises’ volume, and if reached, reached in spikes that container autoscaling would choke on anyway. Hidden costs widen the gap further: cluster upgrades, node patching, autoscaler tuning, per-service version sprawl, and the engineering hours spent explaining why an agent timed out because a node drained at the wrong moment.

    The honest caveats cut the other way, and an architect should state them. If traffic is genuinely flat — a 24/7 baseline of millions of calls with no seasonality — a Savings Plan on ECS or EKS beats Lambda on unit economics. Don’t hold thousands of SSE connections open indefinitely on Lambda; reap idle sessions and let reconnect rebuild them. And downstream data costs are identical in both architectures — exclude them from the comparison. Choose based on your actual traffic distribution, not ideology.

    For spiky AI workloads — agent fleets, RAG backfills, support-bot surges — the decision is rarely close. Lambda turns a $350/month always-on floor into a cost curve that starts at zero, tracks demand to the cent, and reaches thousands of concurrent calls without a single infrastructure ticket. Your workload is event-shaped. Stop forcing it into a pod.

    —

    ## Conclusion

    Container-based MCP servers were the correct first move — they let teams adopt the protocol with the SDKs as written. But they import an always-on cost and operations model into a naturally event-driven workload. Splitting the transport into an SSE relay and a stateless executor, bridged by DynamoDB behind Lambda Function URLs, yields a system that recovers from zero in milliseconds, scales to thousands of concurrent tool calls without provisioning, and bills only for what executes. The cold-start tax is a few hundred milliseconds on the first call of a burst; the EKS tax is hundreds of dollars every month even when nothing runs.

    Designing this split well — session lifecycle across the 15-minute relay ceiling, outbox polling that tracks activity, honest concurrency budgets for downstream systems, an ROI model that matches your real traffic shape — is exactly the architecture work that looks simple on a whiteboard and gets subtle in production. That is what OnlineCloud does all day: MCP-native architectures, audited end to end, built by senior US-based engineers and shipped in weeks, not quarters. If you are moving your MCP tool surface off containers — or designing a multi-agent estate that must scale past what your cluster tolerates — **talk to the OnlineCloud architects** at [contact@onlinecloud.dev](mailto:contact@onlinecloud.dev) or [onlinecloud.dev](https://www.onlinecloud.dev/). We would rather argue about your cost curve than sell you a deck.

  • Building a Low-Code MCP Dashboard with React and Tailwind

    # 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 && (
    <>