---
title: Testing ggui Integrations
description: Mock the MCP transport for agent-side unit tests and assert the tool calls your agent makes, without a real LLM round-trip.
---

ggui's agent surface has a dedicated mock layer so you never need a live `mcp.ggui.ai` (or self-hosted `ggui serve`) connection in unit tests:

- **Agent side** — mock the MCP transport (not a wrapper SDK) and assert the tool calls your agent makes.

Integration tests against a live endpoint belong in a separate tier — see [Self-Hosted Reference Deploys](/self-hosted/reference-deploys/) for spinning up a throwaway stack.

## Agent side: mock the MCP transport

ggui is consumed over standard MCP, so the right place to draw the test boundary is at the **MCP transport**, not at a wrapper SDK. Stub the tool-call responses your agent expects and assert against the call log — your agent code stays unmodified between test and production.

### With `@modelcontextprotocol/sdk` — stub `Client.callTool`

If your agent talks to ggui through the canonical MCP `Client`, stub `callTool` and seed the responses keyed by tool name. A unit-test fixture only needs the fields your agent actually reads, so each builder returns a `Pick<>` of the real protocol type (`GguiHandshakeOutput`, `GguiRenderOutput`, `GguiConsumeOutput` from `@ggui-ai/protocol`) — keeping the field names and shapes wire-faithful without hand-rolling every required field of the live envelope.

```typescript
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type {
  GguiHandshakeOutput,
  GguiRenderOutput,
  GguiConsumeOutput,
  ConsumeEventEntry,
} from "@ggui-ai/protocol";

function makeMockMcpClient() {
  const callLog: Array<{ name: string; arguments: unknown }> = [];
  const responses = new Map<string, unknown>();
  let lastSessionId: string | null = null;
  const renderEvents = new Map<string, ConsumeEventEntry[]>();
  const renderStatus = new Map<string, "active" | "expired">();

  responses.set(
    "ggui_handshake",
    (): Pick<GguiHandshakeOutput, "handshakeId" | "action"> => ({
      handshakeId: `h_${Date.now()}`,
      action: "create",
    })
  );

  responses.set(
    "ggui_render",
    (): Pick<GguiRenderOutput, "sessionId" | "resourceUri" | "action"> => {
      const sessionId = `rnd_${Date.now()}`;
      lastSessionId = sessionId;
      renderEvents.set(sessionId, []);
      renderStatus.set(sessionId, "active");
      return {
        sessionId,
        // Spec-canonical MCP-Apps entry point. There is NO clickable
        // `url` on the wire — the host mounts the `ui://ggui/render/{id}`
        // iframe resource. (A dead `url` had the model hallucinating
        // links that resolve nowhere, so it was removed.)
        resourceUri: `ui://ggui/render/${sessionId}`,
        action: "create",
      };
    }
  );

  responses.set("ggui_consume", (args: { sessionId: string }): GguiConsumeOutput => {
    const events = renderEvents.get(args.sessionId) ?? [];
    renderEvents.set(args.sessionId, []);
    return {
      events,
      status: renderStatus.get(args.sessionId) ?? "active",
    };
  });

  const client = {
    callTool: vi.fn(async ({ name, arguments: args }) => {
      callLog.push({ name, arguments: args });
      const handler = responses.get(name);
      if (!handler) {
        // JSON-RPC -32601: Method not found — matches the live server.
        throw new Error(`MCP tool not found: ${name}`);
      }
      const result = typeof handler === "function" ? handler(args) : handler;
      // MCP wraps tool output in { content: [...], structuredContent: ... }.
      return {
        content: [{ type: "text", text: JSON.stringify(result) }],
        structuredContent: result,
      };
    }),
  } as unknown as Client;

  return {
    client,
    callLog,
    // Simulate a user gesture appearing on the consume pipe.
    simulateSubmit(sessionId: string, data: ConsumeEventEntry["actionData"]) {
      const events = renderEvents.get(sessionId) ?? [];
      events.push({
        type: "action",
        sessionId,
        intent: "submit",
        actionData: data,
        uiContext: {},
        actionId: "mockactn",
        firedAt: new Date().toISOString(),
      });
      renderEvents.set(sessionId, events);
    },
    // Status semantics match the canonical protocol: `active` = more events
    // may arrive; `expired` = TTL elapsed. Flip terminal state explicitly.
    simulateExpire(sessionId: string) {
      renderStatus.set(sessionId, "expired");
    },
  };
}
```

### Driving the mock from a test

```typescript
describe("feedback agent", () => {
  let mock: ReturnType<typeof makeMockMcpClient>;

  beforeEach(() => {
    mock = makeMockMcpClient();
  });

  it("collects user feedback end-to-end", async () => {
    const handshake = await mock.client.callTool({
      name: "ggui_handshake",
      arguments: { intent: "Collect feedback" },
    });
    const { handshakeId } = handshake.structuredContent as GguiHandshakeOutput;

    const render = await mock.client.callTool({
      name: "ggui_render",
      // `props` is REQUIRED on ggui_render — pass `{}` when the agreed
      // contract declares no propsSpec.
      arguments: { handshakeId, props: {} },
    });
    const { sessionId, resourceUri } = render.structuredContent as GguiRenderOutput;
    // The render's entry point is the spec-canonical MCP-Apps resource
    // URI the host mounts — not a clickable link.
    expect(resourceUri).toMatch(/^ui:\/\/ggui\/render\//);

    // Pretend the user filled in the form.
    mock.simulateSubmit(sessionId, { rating: 5, comments: "Great!" });

    const consume = await mock.client.callTool({
      name: "ggui_consume",
      arguments: { sessionId },
    });
    const { events, status } = consume.structuredContent as GguiConsumeOutput;
    expect(status).toBe("active");
    expect(events[0].actionData).toEqual({ rating: 5, comments: "Great!" });

    // Assert the agent issued exactly the expected tool-call sequence.
    expect(mock.callLog.map((c) => c.name)).toEqual([
      "ggui_handshake",
      "ggui_render",
      "ggui_consume",
    ]);
  });
});
```

:::caution[`actionData`, not `payload`]
`ConsumeEventEntry` carries gesture data on `actionData` (the consume pipe is the agent-facing view of an `ActionEnvelope` from the live channel). `payload` is the live-channel wire field on the raw envelope. Asserting against `events[0].payload` will silently return `undefined`.
:::

### With Claude Agent SDK — stub `mcpServers`

Consumers using `@anthropic-ai/claude-agent-sdk` typically pass an `mcpServers` config to `query()`. For unit tests, supply an in-memory server entry that returns canned tool responses instead of dialling `mcp.ggui.ai`. Use the SDK's `createSdkMcpServer` (or the SDK-specific test helper) and register tools that produce the same structured payloads shown above.

The principle is identical: stub at the MCP transport surface so your agent prompt, tool-loop logic, and consume-pipe handling exercise unchanged.

### With raw HTTP — stub `fetch`

If your agent talks to a self-hosted `ggui serve` over plain HTTP (`http://127.0.0.1:6781/mcp`), `vi.spyOn(global, "fetch")` (or `msw`) is the right boundary. Assert request URL + JSON-RPC method, and return the matching `structuredContent` payload.

### Asserting errors

ggui surfaces failures at protocol level — there are no SDK-specific error classes to import. Assert against **JSON-RPC error codes** (`-32601` method-not-found, `-32602` invalid-params, etc.) or **HTTP status codes** (`401 Unauthorized`, `408 Request Timeout`, `429 Too Many Requests`), depending on your transport.

```typescript
it("surfaces auth errors", async () => {
  const failingClient = {
    callTool: async () => {
      // Mirror the wire shape: an HTTP-401 surface from the gateway becomes a
      // JSON-RPC error on the MCP client.
      const err = new Error("Unauthorized") as Error & { code?: number };
      err.code = -32000; // JSON-RPC server-error range; httpStatus 401 upstream.
      throw err;
    },
  };

  await expect(
    failingClient.callTool({ name: "ggui_handshake", arguments: {} })
  ).rejects.toMatchObject({ code: -32000 });
});
```

See [Error Handling](/cookbook/error-handling/) for retry, fallback, and graceful-degradation patterns built on these protocol-level signals.

:::tip[Self-hosted parity]
The transport-mock boundary is endpoint-agnostic — the same canned responses serve whether your agent points at a self-hosted `ggui serve` (`http://127.0.0.1:6781/mcp`, WebSocket `ws://127.0.0.1:6781/ws`) or the hosted `mcp.ggui.ai` endpoint (coming soon). Mock the transport, not the endpoint.
:::

---

## What not to test

LLM-generated component code is non-deterministic — assert **behavior**, not DOM structure. Pin contracts (via `defineContract` + `useContract`) and test your agent's tool-call sequence. Leave the visual layer to live snapshots or a separate generation-quality tier.

See also: [@ggui-ai/react](/sdk/react/) · [MCP Protocol](/api/mcp-protocol/) · [Troubleshooting](/troubleshooting/).