;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ // Mantine renders the modal in a portal at document.body.
+ const body = within(canvasElement.ownerDocument.body);
+ await body.findByText("Output schema validation");
+ expect(body.getByText(/open_pattern_editor/)).toBeInTheDocument();
+ const details = body.getByLabelText(
+ "Validation details",
+ ) as HTMLTextAreaElement;
+ expect(details.value).toContain(
+ "data/samples/0 must NOT have additional properties",
+ );
+ expect(details.readOnly).toBe(true);
+ },
+};
+
+export const WithoutToolName: Story = {
+ args: { toolName: undefined },
+};
diff --git a/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.test.tsx b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.test.tsx
new file mode 100644
index 00000000..8681ec00
--- /dev/null
+++ b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.test.tsx
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithMantine, screen } from "../../../test/renderWithMantine";
+import { OutputValidationModal } from "./OutputValidationModal";
+
+const MESSAGE =
+ "data/samples/0 must NOT have additional properties, data/samples/1 must NOT have additional properties";
+
+describe("OutputValidationModal", () => {
+ it("renders the tool name and the full validation message in a read-only field", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText("Output schema validation")).toBeInTheDocument();
+ expect(screen.getByText(/open_pattern_editor/)).toBeInTheDocument();
+ const details = screen.getByLabelText(
+ "Validation details",
+ ) as HTMLTextAreaElement;
+ expect(details.value).toBe(MESSAGE);
+ expect(details.readOnly).toBe(true);
+ });
+
+ it("falls back to a generic message when no tool name is provided", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.getByText(/does not match the declared outputSchema/i),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onClose when the close button is clicked", async () => {
+ const onClose = vi.fn();
+ renderWithMantine(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders nothing visible when closed", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.queryByText("Output schema validation"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.tsx b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.tsx
new file mode 100644
index 00000000..5cc287fa
--- /dev/null
+++ b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.tsx
@@ -0,0 +1,64 @@
+import {
+ CloseButton,
+ Group,
+ Modal,
+ Stack,
+ Text,
+ Textarea,
+ Title,
+} from "@mantine/core";
+
+export interface OutputValidationModalProps {
+ opened: boolean;
+ onClose: () => void;
+ /** The app tool whose result failed output-schema validation. */
+ toolName?: string;
+ /** The full validation error message (one issue per line). */
+ message?: string;
+}
+
+/**
+ * Shows the full output-schema validation error for an MCP App tool result.
+ * The inspector still renders the app (the result is forwarded verbatim to the
+ * view), but the result violates the tool's declared `outputSchema`, so strict
+ * MCP clients may refuse to render it — this modal surfaces the details a
+ * server developer needs to fix it. Opened from the warning toast.
+ */
+export function OutputValidationModal({
+ opened,
+ onClose,
+ toolName,
+ message,
+}: OutputValidationModalProps) {
+ return (
+
+
+
+
+ Output schema validation
+
+
+
+
+ {toolName
+ ? `"${toolName}" returned structuredContent that does not match its declared outputSchema. The inspector renders the app anyway, but strict MCP clients may refuse to display it.`
+ : "The tool result's structuredContent does not match the declared outputSchema. The inspector renders the app anyway, but strict MCP clients may refuse to display it."}
+
+
+
+
+ );
+}
diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx
index e70575f7..6102cc72 100644
--- a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx
+++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx
@@ -1,8 +1,9 @@
-import { useRef } from "react";
+import { useCallback, useRef, useState } from "react";
+import { Stack, Text } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
-import type { Tool } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
-import { fn, userEvent, within } from "storybook/test";
+import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { AppsScreen, type AppsScreenProps } from "./AppsScreen";
import type {
AppRendererHandle,
@@ -10,7 +11,18 @@ import type {
} from "../../elements/AppRenderer/AppRenderer";
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
-const PLACEHOLDER_SANDBOX = "data:text/html,Mock%20Sandbox";
+// A self-contained, themed sandbox page so the running-state stories show
+// visible content in both light and dark mode (Canvas / CanvasText are CSS
+// system colors that follow `color-scheme`), instead of a blank/white frame.
+const PLACEHOLDER_SANDBOX =
+ "data:text/html," +
+ encodeURIComponent(
+ `` +
+ `` +
+ `Mock app — running in sandbox
`,
+ );
function createMockBridge(): AppBridge {
return {
@@ -19,6 +31,8 @@ function createMockBridge(): AppBridge {
sendToolCancelled: async () => {},
teardownResource: async () => ({}),
close: async () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
} as unknown as AppBridge;
}
@@ -149,3 +163,95 @@ export const WithListChanged: Story = {
export const Empty: Story = {
args: { tools: [] },
};
+
+// Drives the real running-state interaction with a fake bridge that echoes
+// tool input straight back as a tool result. Because the mock sandbox page
+// never completes the real handshake, the factory synthesizes the view's
+// `initialized` signal so AppRenderer flushes the buffered input. The echoed
+// result is surfaced in a status line so the round-trip is observable in
+// `npm run test:storybook`.
+export const EchoRunning: Story = {
+ args: { tools: [weatherApp] },
+ render: function EchoRender(args: AppsScreenProps) {
+ const ref = useRef(null);
+ const [echo, setEcho] = useState(null);
+
+ const bridgeFactory: BridgeFactory = useCallback(() => {
+ let onInitialized: (() => void) | undefined;
+ const bridge = {
+ addEventListener: (event: string, handler: () => void) => {
+ if (event === "initialized") onInitialized = handler;
+ },
+ removeEventListener: () => {},
+ sendToolInput: async (params: {
+ arguments?: Record;
+ }) => {
+ // Echo the input back as a result.
+ await bridge.sendToolResult({
+ content: [
+ {
+ type: "text",
+ text: `echo: ${JSON.stringify(params.arguments)}`,
+ },
+ ],
+ });
+ },
+ sendToolResult: async (result: CallToolResult) => {
+ const first = result.content[0];
+ setEcho(first?.type === "text" ? first.text : "");
+ },
+ sendToolCancelled: async () => {},
+ teardownResource: async () => ({}),
+ close: async () => {},
+ };
+ // Simulate the view finishing initialization shortly after AppRenderer
+ // registers its `initialized` listener; sendToolInput then flushes.
+ setTimeout(() => onInitialized?.(), 20);
+ return bridge as unknown as AppBridge;
+ }, []);
+
+ const onOpenApp = useCallback(
+ (_name: string, formArgs: Record) => {
+ // Mirror App.tsx: defer one microtask so the renderer's imperative
+ // handle (set during the commit's layout phase) is wired before
+ // pushing input. The renderer buffers it until the view initializes.
+ void Promise.resolve().then(() => ref.current?.sendToolInput(formArgs));
+ },
+ [],
+ );
+
+ return (
+
+
+ {echo ?? "no echo yet"}
+
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ await selectByLabel(canvasElement, "Weather Widget");
+ const canvas = within(canvasElement);
+ const cityField = await canvas.findByRole("textbox", { name: /city/i });
+ await userEvent.type(cityField, "Oslo");
+ await clickByName(canvasElement, /open app/i);
+ await waitFor(() =>
+ expect(canvas.getByTestId("echo-status")).toHaveTextContent(/echo:/),
+ );
+ },
+};
+
+// No sandbox URL available — the screen renders an unavailable state rather
+// than a blank iframe.
+export const Unavailable: Story = {
+ args: { tools: sampleApps, sandboxPath: undefined },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText(/MCP Apps are unavailable/i);
+ },
+};
diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
index 24bec8fe..f6190340 100644
--- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
+++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
@@ -85,6 +85,36 @@ describe("AppsScreen", () => {
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
});
+ it("renders the unavailable state when no sandbox path is provided", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText(/MCP Apps are unavailable/i)).toBeInTheDocument();
+ // The sidebar / app list is not rendered in the unavailable state.
+ expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
+ });
+
+ it("surfaces a bridge factory failure via onError", async () => {
+ const user = userEvent.setup();
+ const onError = vi.fn();
+ const throwingFactory: BridgeFactory = () => {
+ throw new Error("no connected MCP client");
+ };
+ renderWithMantine(
+ ,
+ );
+ // The no-fields app auto-launches on selection, mounting the renderer,
+ // whose effect invokes the factory (which throws → routes to onError).
+ await user.click(screen.getByText("Ops Dashboard"));
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
+ expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
+ expect((onError.mock.calls[0][0] as Error).message).toContain(
+ "no connected MCP client",
+ );
+ });
+
it("filters the list via the search input", async () => {
const user = userEvent.setup();
renderWithMantine();
diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
index 3105dd9d..ca4ededd 100644
--- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
+++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
@@ -29,13 +29,21 @@ import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils";
export interface AppsScreenProps {
tools: Tool[];
listChanged: boolean;
- sandboxPath: string;
+ /**
+ * URL of the inspector's sandbox proxy page (the trusted outer iframe). When
+ * undefined, MCP Apps cannot run (legacy backend, or a build without the
+ * sandbox controller) and the screen renders an unavailable state instead of
+ * a silently blank iframe.
+ */
+ sandboxPath?: string;
bridgeFactory: BridgeFactory;
rendererRef: Ref;
onRefreshList: () => void;
onSelectApp: (name: string) => void;
onOpenApp: (name: string, args: Record) => void;
onCloseApp: () => void;
+ /** Surfaces bridge/runtime failures from the renderer (e.g. no client). */
+ onError?: (err: Error) => void;
}
const ScreenLayout = Flex.withProps({
@@ -122,6 +130,7 @@ export function AppsScreen({
onSelectApp,
onOpenApp,
onCloseApp,
+ onError,
}: AppsScreenProps) {
const [selectedAppName, setSelectedAppName] = useState(
undefined,
@@ -172,6 +181,21 @@ export function AppsScreen({
setMaximized(false);
}
+ // No sandbox proxy URL means the host can't embed the trusted outer iframe
+ // the double-iframe sandbox depends on — surface that plainly instead of
+ // mounting an iframe that would render blank.
+ if (!sandboxPath) {
+ return (
+
+
+
+ MCP Apps are unavailable — the sandbox could not be reached.
+
+
+
+ );
+ }
+
return (
{!maximized && (
@@ -247,6 +271,7 @@ export function AppsScreen({
sandboxPath={sandboxPath}
tool={selectedTool}
bridgeFactory={bridgeFactory}
+ onError={onError}
ref={rendererRef}
/>
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
index 5daa6d12..d8898caa 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
@@ -319,6 +319,7 @@ const meta: Meta = {
currentLogLevel: "info",
sandboxPath: "about:blank",
bridgeFactory: noopBridgeFactory,
+ appRendererRef: { current: null },
// Callbacks — all wired to storybook spies so play functions can assert
// on dispatch. Real wiring routes these to InspectorClient methods (the
@@ -358,6 +359,7 @@ const meta: Meta = {
onSelectApp: fn(),
onOpenApp: fn(),
onCloseApp: fn(),
+ onAppError: fn(),
onRefreshApps: fn(),
},
};
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
index c836134f..3a4e0622 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
@@ -52,6 +52,7 @@ function makeProps(
currentLogLevel: "info",
sandboxPath: "about:blank",
bridgeFactory: noopBridgeFactory,
+ appRendererRef: { current: null },
onToggleTheme: vi.fn(),
onToggleConnection: vi.fn(),
onDisconnect: vi.fn(),
@@ -87,6 +88,7 @@ function makeProps(
onSelectApp: vi.fn(),
onOpenApp: vi.fn(),
onCloseApp: vi.fn(),
+ onAppError: vi.fn(),
onRefreshApps: vi.fn(),
...overrides,
};
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx
index 0d66f061..5461dbb6 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useRef, useState, type ReactNode } from "react";
+import { useMemo, useState, type ReactNode, type Ref } from "react";
import { AppShell, Box, Stack, Transition } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import type {
@@ -199,10 +199,13 @@ export interface InspectorViewProps {
// notification, so the parent keeps the optimistic current value.
currentLogLevel: LoggingLevel;
- // MCP Apps sandbox. The parent's web environment provides both the
- // sandbox iframe URL and the per-app bridge factory.
- sandboxPath: string;
+ // MCP Apps sandbox. The parent's web environment provides the sandbox iframe
+ // URL (undefined when the sandbox controller is unavailable), the per-app
+ // bridge factory, and the renderer handle the parent uses to push tool
+ // input/result into the running app and tear it down.
+ sandboxPath?: string;
bridgeFactory: BridgeFactory;
+ appRendererRef: Ref;
// History pinning. Optional because pin state isn't persisted yet (#1244
// is single-PR; persistence is a separate concern).
@@ -271,6 +274,7 @@ export interface InspectorViewProps {
onSelectApp: (name: string) => void;
onOpenApp: (name: string, args: Record) => void;
onCloseApp: () => void;
+ onAppError: (err: Error) => void;
onRefreshApps: () => void;
}
@@ -296,6 +300,7 @@ export function InspectorView({
currentLogLevel,
sandboxPath,
bridgeFactory,
+ appRendererRef,
pinnedHistoryIds,
onToggleTheme,
onToggleConnection,
@@ -337,13 +342,13 @@ export function InspectorView({
onSelectApp,
onOpenApp,
onCloseApp,
+ onAppError,
onRefreshApps,
}: InspectorViewProps) {
// UI-only state. Connection state, primitive lists, and all action
// dispatching live in the parent; this component only owns navigation
// (which tab is visible) and a couple of view-local toggles.
const [selectedTab, setSelectedTab] = useState(SERVERS_TAB);
- const appRendererRef = useRef(null);
const [logsSort, setLogsSort] = useSortDirection("logs");
const [historySort, setHistorySort] = useSortDirection("history");
@@ -479,6 +484,7 @@ export function InspectorView({
onSelectApp={onSelectApp}
onOpenApp={onOpenApp}
onCloseApp={onCloseApp}
+ onError={onAppError}
/>
diff --git a/clients/web/src/test/core/mcp/toolOutputValidation.test.ts b/clients/web/src/test/core/mcp/toolOutputValidation.test.ts
new file mode 100644
index 00000000..f89a8659
--- /dev/null
+++ b/clients/web/src/test/core/mcp/toolOutputValidation.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect } from "vitest";
+import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
+import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
+import { validateToolOutput } from "@inspector/core/mcp/toolOutputValidation";
+
+const provider = new AjvJsonSchemaValidator();
+
+const schemaTool: Tool = {
+ name: "get_temp",
+ inputSchema: { type: "object" },
+ outputSchema: {
+ type: "object",
+ properties: {
+ temperature: { type: "number" },
+ unit: { type: "string" },
+ },
+ required: ["temperature", "unit"],
+ additionalProperties: false,
+ },
+};
+
+const noSchemaTool: Tool = {
+ name: "echo",
+ inputSchema: { type: "object" },
+};
+
+function result(partial: Partial): CallToolResult {
+ return {
+ content: [{ type: "text", text: "ok" }],
+ ...partial,
+ } as CallToolResult;
+}
+
+describe("validateToolOutput", () => {
+ it("returns undefined when the tool has no output schema", () => {
+ expect(
+ validateToolOutput(
+ provider,
+ noSchemaTool,
+ result({ structuredContent: { x: 1 } }),
+ ),
+ ).toBeUndefined();
+ });
+
+ it("returns undefined when structuredContent matches the schema", () => {
+ expect(
+ validateToolOutput(
+ provider,
+ schemaTool,
+ result({ structuredContent: { temperature: 25, unit: "C" } }),
+ ),
+ ).toBeUndefined();
+ });
+
+ it("returns a message when structuredContent has undeclared properties", () => {
+ const message = validateToolOutput(
+ provider,
+ schemaTool,
+ result({ structuredContent: { temperature: 25, unit: "C", extra: "x" } }),
+ );
+ expect(message).toBeTruthy();
+ expect(message).toMatch(/additional propert/i);
+ });
+
+ it("returns a message when an output-schema tool returns no structuredContent", () => {
+ const message = validateToolOutput(provider, schemaTool, result({}));
+ expect(message).toBe(
+ 'Tool "get_temp" declares an output schema but returned no structured content',
+ );
+ });
+
+ it("returns undefined when an output-schema tool errors without structuredContent", () => {
+ expect(
+ validateToolOutput(provider, schemaTool, result({ isError: true })),
+ ).toBeUndefined();
+ });
+
+ it("swallows a schema that cannot be compiled", () => {
+ // `required` must be an array — Ajv throws while compiling the validator;
+ // the helper swallows it rather than producing a misleading warning.
+ const badTool = {
+ name: "bad",
+ inputSchema: { type: "object" },
+ outputSchema: { type: "object", required: "nope" },
+ } as unknown as Tool;
+ expect(
+ validateToolOutput(provider, badTool, result({ structuredContent: {} })),
+ ).toBeUndefined();
+ });
+});
diff --git a/clients/web/src/test/core/react/useSandboxUrl.test.tsx b/clients/web/src/test/core/react/useSandboxUrl.test.tsx
new file mode 100644
index 00000000..28a4f4b2
--- /dev/null
+++ b/clients/web/src/test/core/react/useSandboxUrl.test.tsx
@@ -0,0 +1,114 @@
+/**
+ * Tests for useSandboxUrl — runs in happy-dom under the unit project. Uses a
+ * controlled fake `fetch` so each branch (present / absent / non-string / HTTP
+ * error / network throw) and the auth header are asserted directly.
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl";
+
+function jsonResponse(body: unknown, ok = true): Response {
+ return {
+ ok,
+ status: ok ? 200 : 401,
+ json: async () => body,
+ } as unknown as Response;
+}
+
+describe("useSandboxUrl", () => {
+ it("starts loading, then resolves the sandboxUrl from the config payload", async () => {
+ const fetchFn = vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse({ sandboxUrl: "http://localhost:6299/sandbox" }),
+ );
+
+ const { result } = renderHook(() =>
+ useSandboxUrl({ baseUrl: "http://test.local", fetchFn }),
+ );
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.sandboxUrl).toBeUndefined();
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.sandboxUrl).toBe("http://localhost:6299/sandbox");
+ });
+
+ it("sends the bearer auth header when a token is provided", async () => {
+ const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
+
+ renderHook(() =>
+ useSandboxUrl({
+ baseUrl: "http://test.local/",
+ authToken: "secret-token",
+ fetchFn,
+ }),
+ );
+
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+ const [url, init] = fetchFn.mock.calls[0];
+ // Trailing slash on baseUrl is normalized away.
+ expect(url).toBe("http://test.local/api/config");
+ expect(init.method).toBe("GET");
+ expect(init.headers["x-mcp-remote-auth"]).toBe("Bearer secret-token");
+ });
+
+ it("omits the auth header when no token is provided", async () => {
+ const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
+
+ renderHook(() => useSandboxUrl({ baseUrl: "http://test.local", fetchFn }));
+
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+ const [, init] = fetchFn.mock.calls[0];
+ expect(init.headers["x-mcp-remote-auth"]).toBeUndefined();
+ });
+
+ it("leaves sandboxUrl undefined when the payload omits it", async () => {
+ const fetchFn = vi
+ .fn()
+ .mockResolvedValue(jsonResponse({ defaultEnvironment: {} }));
+
+ const { result } = renderHook(() =>
+ useSandboxUrl({ baseUrl: "http://test.local", fetchFn }),
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.sandboxUrl).toBeUndefined();
+ });
+
+ it("leaves sandboxUrl undefined when the field is not a usable string", async () => {
+ const fetchFn = vi.fn().mockResolvedValue(jsonResponse({ sandboxUrl: "" }));
+
+ const { result } = renderHook(() =>
+ useSandboxUrl({ baseUrl: "http://test.local", fetchFn }),
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.sandboxUrl).toBeUndefined();
+ });
+
+ it("leaves sandboxUrl undefined on a non-ok response", async () => {
+ const fetchFn = vi
+ .fn()
+ .mockResolvedValue(jsonResponse({ sandboxUrl: "x" }, false));
+
+ const { result } = renderHook(() =>
+ useSandboxUrl({ baseUrl: "http://test.local", fetchFn }),
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.sandboxUrl).toBeUndefined();
+ });
+
+ it("leaves sandboxUrl undefined when the fetch throws", async () => {
+ const fetchFn = vi.fn().mockRejectedValue(new Error("network down"));
+
+ const { result } = renderHook(() =>
+ useSandboxUrl({ baseUrl: "http://test.local", fetchFn }),
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.sandboxUrl).toBeUndefined();
+ });
+});
diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts
index 63adc5a3..a43f2346 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts
@@ -827,6 +827,62 @@ describe("InspectorClient", () => {
});
});
+ it("throws on output-schema validation when structuredContent has extra properties (get_temp_extra)", async () => {
+ // get_temp_extra returns an undeclared `extra` property. The SDK client
+ // validates structuredContent against the strict output schema and
+ // throws, so the default path delivers no result to the caller.
+ const tool = await getTool(client!, "get_temp_extra");
+ await expect(
+ client!.callTool(tool, { city: "Oslo", units: "C" }),
+ ).rejects.toThrow(/output schema|additional propert/i);
+ });
+
+ it("delivers the raw result when skipOutputValidation is set (get_temp_extra)", async () => {
+ // The MCP Apps passthrough path bypasses host-side output validation so a
+ // schema-violating-but-real result still reaches the app.
+ const tool = await getTool(client!, "get_temp_extra");
+ const result = await client!.callTool(
+ tool,
+ { city: "Oslo", units: "C" },
+ undefined,
+ undefined,
+ undefined,
+ { skipOutputValidation: true },
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.result).toBeDefined();
+ const structured = result.result!.structuredContent as Record<
+ string,
+ unknown
+ >;
+ expect(structured).toMatchObject({
+ temperature: 25,
+ unit: "C",
+ city: "Oslo",
+ extra: "undeclared",
+ });
+ // Non-fatal advisory: the result was delivered, but the mismatch is
+ // reported so callers can warn that strict clients would reject it.
+ expect(result.outputValidationError ?? "").toMatch(
+ /additional propert|output schema|schema/i,
+ );
+ });
+
+ it("does not set outputValidationError when the result matches the schema (get_temp)", async () => {
+ const tool = await getTool(client!, "get_temp");
+ const result = await client!.callTool(
+ tool,
+ { city: "Oslo", units: "C" },
+ undefined,
+ undefined,
+ undefined,
+ { skipOutputValidation: true },
+ );
+ expect(result.success).toBe(true);
+ expect(result.outputValidationError).toBeUndefined();
+ });
+
it("should handle tool not found", async () => {
const result = await client!.callTool(
minimalTool("nonexistent-tool"),
diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts
index 8775644a..b99cb586 100644
--- a/core/mcp/inspectorClient.ts
+++ b/core/mcp/inspectorClient.ts
@@ -95,6 +95,8 @@ import {
TaskStatusNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type { ClientResult } from "@modelcontextprotocol/sdk/types.js";
+import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
+import { validateToolOutput } from "./toolOutputValidation.js";
import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js";
import {
type JsonValue,
@@ -134,6 +136,9 @@ interface ReceiverTaskRecord {
export class InspectorClient extends InspectorClientEventTarget {
private client: Client | null = null;
private appRendererClientProxy: AppRendererClient | null = null;
+ // Lazily-built validator used only on the skipOutputValidation path to detect
+ // (non-fatally) when a delivered result violates the tool's outputSchema.
+ private outputValidator: AjvJsonSchemaValidator | null = null;
private transport: Transport | MessageTrackingTransport | null = null;
private baseTransport: Transport | null = null;
private pipeStderr: boolean;
@@ -1313,6 +1318,7 @@ export class InspectorClient extends InspectorClientEventTarget {
generalMetadata?: Record,
toolSpecificMetadata?: Record,
taskOptions?: { ttl?: number },
+ options?: { skipOutputValidation?: boolean },
): Promise {
if (!this.client) {
throw new Error("Client is not connected");
@@ -1362,11 +1368,32 @@ export class InspectorClient extends InspectorClientEventTarget {
callParams.task = { ttl: taskOptions.ttl };
}
- const result = await this.client.callTool(
- callParams,
- undefined,
- this.getRequestOptions(metadata?.progressToken),
- );
+ // MCP Apps forward the server's CallToolResult straight to the running
+ // view, which is the real consumer. The SDK's callTool() validates
+ // structuredContent against the tool's outputSchema and THROWS on a
+ // mismatch — which would deny the app a result the server actually
+ // returned (and that legacy hosts render fine). For those passthrough
+ // calls go through request() directly, which skips that host-side
+ // validation. Regular Tools-screen calls keep validating.
+ const requestOptions = this.getRequestOptions(metadata?.progressToken);
+ // Both branches yield a CallToolResult: request() parsed it with
+ // CallToolResultSchema above, callTool() returns the same shape — so the
+ // `as CallToolResult` casts below are safe.
+ const result = options?.skipOutputValidation
+ ? await this.client.request(
+ { method: "tools/call", params: callParams },
+ CallToolResultSchema,
+ requestOptions,
+ )
+ : await this.client.callTool(callParams, undefined, requestOptions);
+
+ // On the bypass path the result was delivered without the SDK's strict
+ // output validation. Run that check ourselves, non-fatally, so callers can
+ // warn that strict clients would reject this payload (the app still
+ // renders, but it may not in other hosts).
+ const outputValidationError = options?.skipOutputValidation
+ ? this.validateToolOutput(tool, result as CallToolResult)
+ : undefined;
const invocation: ToolCallInvocation = {
toolName: tool.name,
@@ -1375,6 +1402,7 @@ export class InspectorClient extends InspectorClientEventTarget {
timestamp,
success: true,
metadata,
+ outputValidationError,
};
this.dispatchTypedEvent("toolCallResultChange", {
@@ -1384,6 +1412,7 @@ export class InspectorClient extends InspectorClientEventTarget {
timestamp,
success: true,
metadata,
+ outputValidationError,
});
return invocation;
@@ -1421,6 +1450,20 @@ export class InspectorClient extends InspectorClientEventTarget {
}
}
+ /**
+ * Non-fatally validate a delivered tool result against the tool's outputSchema
+ * (used by the skipOutputValidation path). Delegates to the pure
+ * {@link validateToolOutput} helper with this client's lazily-built Ajv
+ * validator. Returns an advisory message, or undefined when valid.
+ */
+ private validateToolOutput(
+ tool: Tool,
+ result: CallToolResult,
+ ): string | undefined {
+ this.outputValidator ??= new AjvJsonSchemaValidator();
+ return validateToolOutput(this.outputValidator, tool, result);
+ }
+
/**
* Call a tool with task support (streaming).
* Caller must provide the Tool (e.g. from a state manager).
diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts
index 8e2c85cd..d45c1850 100644
--- a/core/mcp/inspectorClientEventTarget.ts
+++ b/core/mcp/inspectorClientEventTarget.ts
@@ -71,6 +71,8 @@ export interface InspectorClientEventMap {
success: boolean;
error?: string;
metadata?: Record;
+ /** Non-fatal outputSchema mismatch detected on the skipOutputValidation path. */
+ outputValidationError?: string;
};
resourceContentChange: {
uri: string;
diff --git a/core/mcp/toolOutputValidation.ts b/core/mcp/toolOutputValidation.ts
new file mode 100644
index 00000000..bf7e4752
--- /dev/null
+++ b/core/mcp/toolOutputValidation.ts
@@ -0,0 +1,47 @@
+import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
+import type {
+ JsonSchemaType,
+ jsonSchemaValidator,
+} from "@modelcontextprotocol/sdk/validation";
+
+/**
+ * Validate a delivered tool result's `structuredContent` against the tool's
+ * declared `outputSchema`, mirroring the SDK client's strict check but WITHOUT
+ * throwing. Returns a human-readable error message when a strict client would
+ * reject the payload, or `undefined` when it's valid (or there's nothing to
+ * check).
+ *
+ * Used by {@link InspectorClient.callTool}'s `skipOutputValidation` path: MCP
+ * Apps forward the result verbatim to the running view, so the host must not
+ * reject it — but it should still surface the mismatch as a non-fatal advisory.
+ *
+ * @param provider A JSON Schema validator provider (the SDK's Ajv provider).
+ * @param tool The tool that was called (carries the declared outputSchema).
+ * @param result The delivered CallToolResult.
+ */
+export function validateToolOutput(
+ provider: jsonSchemaValidator,
+ tool: Tool,
+ result: CallToolResult,
+): string | undefined {
+ if (!tool.outputSchema) return undefined;
+ const structured = result.structuredContent;
+ if (structured == null) {
+ // Strict clients reject "has outputSchema but no structuredContent" unless
+ // the result is itself an error.
+ return result.isError
+ ? undefined
+ : `Tool "${tool.name}" declares an output schema but returned no structured content`;
+ }
+ try {
+ const validate = provider.getValidator(
+ tool.outputSchema as unknown as JsonSchemaType,
+ );
+ const validation = validate(structured);
+ return validation.valid ? undefined : validation.errorMessage;
+ } catch {
+ // A malformed schema (validator compilation failure) shouldn't block the
+ // call or produce a misleading warning.
+ return undefined;
+ }
+}
diff --git a/core/mcp/types.ts b/core/mcp/types.ts
index f7e18b29..a44375a3 100644
--- a/core/mcp/types.ts
+++ b/core/mcp/types.ts
@@ -261,6 +261,14 @@ export interface ToolCallInvocation {
success: boolean;
error?: string;
metadata?: Record;
+ /**
+ * Set only on the `skipOutputValidation` path: present when the (delivered)
+ * result's structuredContent does NOT match the tool's declared outputSchema.
+ * The call still succeeds and the result is returned — this is a non-fatal
+ * advisory so callers can warn that strict MCP clients would reject the
+ * payload (and the app may not render in them).
+ */
+ outputValidationError?: string;
}
// v2-only wrapper types (no v1.5 equivalent)
diff --git a/core/react/useSandboxUrl.ts b/core/react/useSandboxUrl.ts
new file mode 100644
index 00000000..95d06807
--- /dev/null
+++ b/core/react/useSandboxUrl.ts
@@ -0,0 +1,88 @@
+/**
+ * React hook over the `GET /api/config` endpoint, used to recover the MCP Apps
+ * sandbox URL the backend serves alongside the SPA. The dev/prod web backends
+ * mount `sandbox_proxy.html` on a separate controller port and advertise the
+ * resulting URL as `sandboxUrl` on the config payload (see
+ * `clients/web/server/vite-hono-plugin.ts`). The Apps screen embeds that URL as
+ * the trusted outer iframe of the double-iframe sandbox; without it MCP Apps
+ * cannot run, so the screen renders an unavailable state instead.
+ *
+ * Fetches on mount, and re-fetches if `baseUrl` or `authToken` changes (rare —
+ * effectively a full reload; the GET is idempotent). `sandboxUrl` is `undefined`
+ * until the fetch resolves and stays `undefined` if the backend omits it (legacy
+ * backend, or a build without the sandbox controller) — callers should treat
+ * that as "Apps unavailable" rather than falling back to a blank iframe.
+ */
+
+import { useCallback, useEffect, useState } from "react";
+
+export interface UseSandboxUrlOptions {
+ /** Base URL of the remote server (typically `window.location.origin`). */
+ baseUrl: string;
+ /** Optional auth token for the `x-mcp-remote-auth` header. */
+ authToken?: string;
+ /** Fetch function to use (default: globalThis.fetch). Useful in tests. */
+ fetchFn?: typeof fetch;
+}
+
+export interface UseSandboxUrlResult {
+ /** The sandbox proxy URL, or undefined when unavailable / not yet loaded. */
+ sandboxUrl: string | undefined;
+ /** True while the initial fetch is in flight. */
+ loading: boolean;
+}
+
+/** Minimal shape we read from the `/api/config` payload. */
+interface ConfigPayload {
+ sandboxUrl?: unknown;
+}
+
+export function useSandboxUrl(opts: UseSandboxUrlOptions): UseSandboxUrlResult {
+ const { baseUrl, authToken, fetchFn } = opts;
+ const doFetch = fetchFn ?? globalThis.fetch;
+ const base = baseUrl.replace(/\/$/, "");
+
+ const [sandboxUrl, setSandboxUrl] = useState(undefined);
+ const [loading, setLoading] = useState(true);
+
+ // `isCancelled` lets the effect drop a response that resolves after unmount,
+ // avoiding a setState on a dead component (React 18 warns, doesn't throw).
+ const load = useCallback(
+ async (isCancelled: () => boolean): Promise => {
+ const headers: Record = {};
+ if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`;
+ try {
+ const res = await doFetch(`${base}/api/config`, {
+ method: "GET",
+ headers,
+ });
+ if (isCancelled() || !res.ok) return;
+ const body = (await res.json()) as ConfigPayload;
+ if (isCancelled()) return;
+ // Tolerate a missing/non-string field — anything but a usable URL means
+ // "unavailable", which the Apps screen surfaces as its own empty state.
+ setSandboxUrl(
+ typeof body.sandboxUrl === "string" && body.sandboxUrl
+ ? body.sandboxUrl
+ : undefined,
+ );
+ } catch {
+ // Network error / aborted fetch: leave sandboxUrl undefined. The Apps
+ // screen degrades to its unavailable state rather than a blank iframe.
+ } finally {
+ if (!isCancelled()) setLoading(false);
+ }
+ },
+ [base, authToken, doFetch],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ void load(() => cancelled);
+ return () => {
+ cancelled = true;
+ };
+ }, [load]);
+
+ return { sandboxUrl, loading };
+}
diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts
index 8ca515c5..5e2f3ea9 100644
--- a/test-servers/src/preset-registry.ts
+++ b/test-servers/src/preset-registry.ts
@@ -23,6 +23,7 @@ import {
createSendNotificationTool,
createGetAnnotatedMessageTool,
createGetTempTool,
+ createGetTempExtraTool,
createAddResourceTool,
createRemoveResourceTool,
createAddToolTool,
@@ -99,6 +100,8 @@ function resolveToolPreset(
return createGetAnnotatedMessageTool();
case "get_temp":
return createGetTempTool();
+ case "get_temp_extra":
+ return createGetTempExtraTool();
case "add_resource":
return createAddResourceTool();
case "remove_resource":
diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts
index da22760d..2762e0f0 100644
--- a/test-servers/src/test-server-fixtures.ts
+++ b/test-servers/src/test-server-fixtures.ts
@@ -682,6 +682,43 @@ export function createGetTempTool(): ToolDefinition {
};
}
+/**
+ * Create a "get_temp_extra" tool that declares the same output schema as
+ * get_temp but returns an EXTRA, undeclared property in structuredContent.
+ *
+ * The SDK server validates output via zod safeParse (which strips unknown keys
+ * and passes), then sends the ORIGINAL structuredContent — so the extra key
+ * reaches the wire. The SDK *client* validates that payload against the strict
+ * JSON schema derived from the output schema and rejects it
+ * ("must NOT have additional properties"). This mirrors real-world servers
+ * (e.g. MCP App tools) whose results legacy hosts render but whose strict
+ * client validation otherwise denies. Used to exercise
+ * InspectorClient.callTool's `skipOutputValidation` option.
+ */
+export function createGetTempExtraTool(): ToolDefinition {
+ return {
+ name: "get_temp_extra",
+ description:
+ "Like get_temp, but returns an extra structuredContent property that violates the output schema (for validation-bypass testing)",
+ inputSchema: {
+ city: z.string().describe("City name"),
+ units: z.enum(["C", "F"]).describe("Temperature units"),
+ },
+ outputSchema: GetTempOutputSchema,
+ handler: async (params: Record) => {
+ const city = (params.city as string) || "Unknown";
+ const unit = (params.units as "C" | "F") || "C";
+ const temperature = 25;
+ const text = `The temperature in ${city} is ${temperature} degrees ${unit}`;
+ return {
+ content: [{ type: "text" as const, text }],
+ // `extra` is not in GetTempOutputSchema → strict client validation fails.
+ structuredContent: { temperature, unit, city, extra: "undeclared" },
+ };
+ },
+ };
+}
+
/**
* Create a "simple_prompt" prompt definition
*/
@@ -1843,6 +1880,7 @@ export function getDefaultServerConfig(): ServerConfig {
createGetSumTool(),
createGetAnnotatedMessageTool(),
createGetTempTool(),
+ createGetTempExtraTool(),
createSendNotificationTool(),
createWriteToStderrTool(),
],