* feat(apps): render running MCP Apps end-to-end (#1370) Wire the v2 Apps screen so clicking Open App actually runs an MCP App. Previously App.tsx stubbed the three load-bearing pieces (sandbox URL, bridge factory, action handlers), so the iframe loaded about:blank and nothing crossed the bridge. - Add useSandboxUrl hook (core/react) to read sandboxUrl from GET /api/config and thread it down as sandboxPath; render an "MCP Apps unavailable" state instead of a blank iframe when absent. - Add createAppBridgeFactory: builds a host-side ext-apps AppBridge over the active client's SDK client, reads the tool's UI resource on the sandbox handshake and pushes it into the proxy, handles openLinks, and connects a PostMessageTransport. - AppRenderer: pass the tool to the bridge factory; buffer tool input/result and release them once the view signals `initialized` (input before result, latest-wins). - Move the AppRenderer handle ref to App.tsx so onOpenApp issues tools/call with the form values and delivers input + result to the running app via the bridge; onCloseApp tears the bridge/iframe down. - Stories: themed sandbox placeholder (visible in light/dark), an echo-bridge running story, and an unavailable story. New unit tests for the factory, the buffering, useSandboxUrl, and the empty state. The listChanged indicator badge wiring is tracked separately in #1402. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(apps): make MCP Apps render in dev — single bridge + result passthrough Two bugs kept apps (e.g. NightRider's MIDI Pattern Editor) stuck on an empty shell even though the host fetched the resource and called the tool correctly. 1. StrictMode double-mounted the bridge. The dev-only effect setup→cleanup→ setup built TWO host bridges/transports, double-loaded the sandbox, and raced the app's ui/initialize handshake (it timed out, so `initialized` never fired and buffered input/result were never flushed). AppRenderer now defers bridge disposal to a microtask and, when the effect re-runs synchronously with identical inputs, cancels the disposal and reuses the live bridge — so a single bridge survives the double-invoke. The pending input/result buffer is no longer cleared on a transient cleanup. 2. The SDK rejected the server's result. callTool() validates structuredContent against the tool's outputSchema and throws on a mismatch — but an MCP App is a passthrough: the host must forward whatever the server returned to the running view (which legacy hosts render fine). InspectorClient.callTool gains a `skipOutputValidation` option that routes through client.request() (no wrapper validation); onOpenApp uses it. Verified end-to-end against a real app via Playwright: the editor now renders its grid + pattern data identically to the v1 inspector. Tests: AppRenderer StrictMode single-bridge/no-dispose case; a get_temp_extra test-server fixture (returns an extra structuredContent property) with integration tests covering both the throwing default path and the skipOutputValidation passthrough. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps): warn (toast) when an app's result violates its outputSchema The skipOutputValidation passthrough keeps schema-mismatched apps rendering, but that silently hid a real spec problem from server developers. Now the host still renders, but surfaces the mismatch. - InspectorClient.callTool runs the SDK's own Ajv output validation on the skipOutputValidation path NON-fatally and returns the message as `outputValidationError` on the ToolCallInvocation (and the toolCallResultChange event); the result is still delivered. - App.tsx onOpenApp shows a yellow toast when outputValidationError is set, explaining strict MCP clients may not render the app. Tests: integration cases asserting outputValidationError is set for a schema-violating result (get_temp_extra) and unset for a valid one (get_temp). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps): summarize output-schema warning in toast + details modal The previous toast dumped the full (very long) validation error and overflowed. Now the toast shows a one-line summary with a "View validation details" link that opens a modal containing the full error in a read-only, scrollable textarea. - New OutputValidationModal group component (+ story + tests). - App.tsx: toast message is a summary + link; clicking it opens the modal populated with the tool name and the full outputValidationError. Verified via Playwright against the NightRider MIDI editor: toast is concise, link opens the modal with the scrollable details. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(apps): address PR review — theme stability, onError, validation polish Responds to the automated PR review on #1403. - Theme toggle no longer rebuilds a running app's bridge. createAppBridgeFactory reads the resolved theme from the DOM (Mantine's data-mantine-color-scheme) at bridge-build time instead of capturing isDark in the factory deps, so the factory identity is stable across theme flips. (#1) - Wire AppRenderer's onError through AppsScreen -> InspectorView -> App.tsx, surfacing bridge/runtime failures (no client, late rejection, failed tools/call) as a red toast instead of a silent blank iframe. (#2) - Extract validateToolOutput into a pure, unit-tested helper covering the matching / extra-property / missing-structuredContent / error / bad-schema cases (the no-structuredContent branch can't be produced via the SDK server, which rejects it before sending). (#4) - useSandboxUrl aborts its in-flight fetch on unmount (cancelled flag) to avoid setState after unmount. (#5) - Schema-mismatch toast: autoClose:false (details modal is one click away). (note) - AppRenderer: add a state-machine comment block documenting the bridge lifecycle refs. (#6) - inspectorClient: note why the `as CallToolResult` casts are safe. (note) Tests: AppsScreen onError surface; validateToolOutput unit suite; factory theme read from DOM. validate + test:storybook green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(apps): clarify theme attribute + useSandboxUrl fetch comments Re-review follow-ups (non-blocking): - createAppBridgeFactory: note that Mantine's data-mantine-color-scheme is only ever light/dark (auto is resolved before paint), so no `auto` branch is needed and matchMedia only covers an absent attribute. - useSandboxUrl: correct the doc to say it fetches on mount AND re-fetches on baseUrl/authToken change (idempotent), not "once on mount". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
440b194f64
commit
5fb2b503f4
24 changed files with 1683 additions and 66 deletions
|
|
@ -1,5 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
|
||||
import {
|
||||
Anchor,
|
||||
Stack,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import type {
|
||||
InitializeResult,
|
||||
|
|
@ -7,7 +13,6 @@ import type {
|
|||
LoggingMessageNotification,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
import type {
|
||||
|
|
@ -49,11 +54,13 @@ import { useManagedRequestorTasks } from "@inspector/core/react/useManagedReques
|
|||
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
|
||||
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
|
||||
import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js";
|
||||
import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js";
|
||||
import { InspectorView } from "./components/views/InspectorView/InspectorView";
|
||||
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
|
||||
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
|
||||
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
||||
import type { BridgeFactory } from "./components/elements/AppRenderer/AppRenderer";
|
||||
import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer";
|
||||
import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory";
|
||||
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
||||
import {
|
||||
ServerConfigModal,
|
||||
|
|
@ -61,6 +68,7 @@ import {
|
|||
} from "./components/groups/ServerConfigModal/ServerConfigModal";
|
||||
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
|
||||
import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal";
|
||||
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
|
||||
import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
|
||||
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
||||
|
|
@ -126,23 +134,6 @@ function getAuthToken(): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
// MCP Apps sandbox — the iframe URL the parent should embed, plus the
|
||||
// per-tool bridge factory. The dev backend serves `sandbox_proxy.html` on
|
||||
// the sandbox controller port; the factory will eventually wrap the SDK
|
||||
// client. For now neither is wired (the Apps tab uses these props but does
|
||||
// not yet round-trip tool input through a live bridge — that's a follow-up
|
||||
// alongside the AppRenderer integration). Keep these as stable references
|
||||
// so InspectorView's effect deps don't churn.
|
||||
const STUB_SANDBOX_PATH = "about:blank";
|
||||
const stubBridgeFactory: BridgeFactory = () =>
|
||||
({
|
||||
sendToolInput: async () => {},
|
||||
sendToolResult: async () => {},
|
||||
sendToolCancelled: async () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
}) as unknown as AppBridge;
|
||||
|
||||
// Derive `LogEntryData[]` from the MessageLog by filtering for the
|
||||
// `notifications/message` notifications the server emits in response to
|
||||
// `logging/setLevel`. The Logs screen renders these; we transform here
|
||||
|
|
@ -177,6 +168,26 @@ const EMPTY_SETTINGS: InspectorServerSettings = {
|
|||
requestTimeout: 0,
|
||||
};
|
||||
|
||||
// Body of the output-schema-mismatch warning toast: a one-line summary plus a
|
||||
// link that opens the full validation details in a modal (the raw error is far
|
||||
// too long for a toast).
|
||||
const OutputValidationToastMessage = ({
|
||||
onViewDetails,
|
||||
}: {
|
||||
onViewDetails: () => void;
|
||||
}) => (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
The tool result's structuredContent doesn't match the
|
||||
tool's outputSchema. The inspector renders it anyway, but strict MCP
|
||||
clients may not.
|
||||
</Text>
|
||||
<Anchor component="button" type="button" size="sm" onClick={onViewDetails}>
|
||||
View validation details
|
||||
</Anchor>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
function App() {
|
||||
// Theme toggle plumbing (preserved from the pre-wire placeholder).
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
|
|
@ -214,6 +225,11 @@ function App() {
|
|||
>(undefined);
|
||||
const [connectionInfoModalOpen, setConnectionInfoModalOpen] = useState(false);
|
||||
const [removeTarget, setRemoveTarget] = useState<ServerEntry | null>(null);
|
||||
// Details for the output-schema-mismatch modal opened from the warning toast.
|
||||
const [outputValidationDetails, setOutputValidationDetails] = useState<{
|
||||
toolName: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// The active connection target. `null` between sessions; set as soon as
|
||||
// the user toggles a server card on. Drives state-manager lifetime.
|
||||
|
|
@ -226,6 +242,33 @@ function App() {
|
|||
// next switch happens (or when the component unmounts).
|
||||
const [inspectorClient, setInspectorClient] =
|
||||
useState<InspectorClient | null>(null);
|
||||
|
||||
// MCP Apps runtime wiring. `sandboxUrl` is the inspector's sandbox-proxy page
|
||||
// (the trusted outer iframe); `appRendererRef` lets the app handlers push tool
|
||||
// input/result into the running app and tear it down. The bridge factory wraps
|
||||
// the active client's underlying SDK client so the running view can call the
|
||||
// server, and reads the tool's UI resource into the sandbox on handshake.
|
||||
const appRendererRef = useRef<AppRendererHandle>(null);
|
||||
const { sandboxUrl } = useSandboxUrl({
|
||||
baseUrl:
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost",
|
||||
authToken: getAuthToken(),
|
||||
});
|
||||
const sandboxBridgeFactory = useMemo(
|
||||
() =>
|
||||
createAppBridgeFactory({
|
||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||
readResource: async (uri) => {
|
||||
if (!inspectorClient) throw new Error("No MCP client connected.");
|
||||
const invocation = await inspectorClient.readResource(uri);
|
||||
return invocation.result;
|
||||
},
|
||||
}),
|
||||
[inspectorClient],
|
||||
);
|
||||
|
||||
const [managedToolsState, setManagedToolsState] =
|
||||
useState<ManagedToolsState | null>(null);
|
||||
const [managedPromptsState, setManagedPromptsState] =
|
||||
|
|
@ -875,6 +918,88 @@ function App() {
|
|||
setToolCallState(undefined);
|
||||
}, []);
|
||||
|
||||
// --- MCP Apps handlers. Unlike onCallTool (which feeds the Tools panel),
|
||||
// these route the tool input/result into the running app via the renderer's
|
||||
// imperative handle. ---
|
||||
|
||||
// Surfaces bridge/runtime failures (factory throw — e.g. no client after a
|
||||
// disconnect — late bridge rejection, or a failed tools/call) that would
|
||||
// otherwise leave a silently blank app iframe.
|
||||
const onAppError = useCallback((err: Error) => {
|
||||
notifications.show({
|
||||
title: "MCP App error",
|
||||
message: err.message,
|
||||
color: "red",
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Selection is owned by AppsScreen's local state; App.tsx has nothing to do
|
||||
// on select, but the prop is required so the screen stays prop-driven.
|
||||
const onSelectApp = useCallback(() => {}, []);
|
||||
|
||||
const onOpenApp = useCallback(
|
||||
async (name: string, args: Record<string, unknown>) => {
|
||||
if (!inspectorClient) return;
|
||||
const tool = tools.find((t: Tool) => t.name === name);
|
||||
if (!tool) return;
|
||||
// AppsScreen flips `running` -> mounts AppRenderer in the same tick it
|
||||
// calls this, so the renderer handle isn't wired yet. Yield one microtask
|
||||
// (after React commits the mount) before pushing input; the renderer then
|
||||
// buffers it until the view's `initialized` event, releasing input before
|
||||
// result.
|
||||
await Promise.resolve();
|
||||
void appRendererRef.current?.sendToolInput(args);
|
||||
try {
|
||||
// skipOutputValidation: the result is forwarded verbatim to the running
|
||||
// app (the real consumer), so the host must not reject it on its own
|
||||
// outputSchema validation — that would deny the app a result the server
|
||||
// actually returned and legacy hosts render fine.
|
||||
const invocation = await inspectorClient.callTool(
|
||||
tool,
|
||||
args as Record<string, JsonValue>,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ skipOutputValidation: true },
|
||||
);
|
||||
if (invocation.success && invocation.result) {
|
||||
void appRendererRef.current?.sendToolResult(invocation.result);
|
||||
}
|
||||
// Leniency above keeps the app rendering, but surface the schema
|
||||
// mismatch so a server developer knows strict MCP clients may refuse
|
||||
// to render this app. The full validation error is too long for a
|
||||
// toast, so summarize and link to a modal with the details.
|
||||
if (invocation.outputValidationError) {
|
||||
const details = {
|
||||
toolName: tool.name,
|
||||
message: invocation.outputValidationError,
|
||||
};
|
||||
notifications.show({
|
||||
// Don't auto-dismiss: the message is advisory and the details modal
|
||||
// is one click away — let the user close it when they've read it.
|
||||
autoClose: false,
|
||||
title: "App output doesn't match its schema",
|
||||
color: "yellow",
|
||||
message: (
|
||||
<OutputValidationToastMessage
|
||||
onViewDetails={() => setOutputValidationDetails(details)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Transport-level failure (the call never returned a result). Surface it
|
||||
// so the user isn't left staring at a blank/partial app frame.
|
||||
onAppError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
[inspectorClient, tools, onAppError],
|
||||
);
|
||||
|
||||
const onCloseApp = useCallback(() => {
|
||||
void appRendererRef.current?.teardown();
|
||||
}, []);
|
||||
|
||||
const onGetPrompt = useCallback(
|
||||
async (name: string, args: Record<string, string>) => {
|
||||
if (!inspectorClient) return;
|
||||
|
|
@ -1224,8 +1349,9 @@ function App() {
|
|||
getPromptState={getPromptState}
|
||||
readResourceState={effectiveReadResourceState}
|
||||
currentLogLevel={currentLogLevel}
|
||||
sandboxPath={STUB_SANDBOX_PATH}
|
||||
bridgeFactory={stubBridgeFactory}
|
||||
sandboxPath={sandboxUrl}
|
||||
bridgeFactory={sandboxBridgeFactory}
|
||||
appRendererRef={appRendererRef}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onToggleConnection={(id) => {
|
||||
void onToggleConnection(id);
|
||||
|
|
@ -1274,9 +1400,12 @@ function App() {
|
|||
onTogglePinHistory={todoNoop}
|
||||
onClearNetwork={onClearNetwork}
|
||||
onExportNetwork={onExportNetwork}
|
||||
onSelectApp={todoNoop}
|
||||
onOpenApp={todoNoop}
|
||||
onCloseApp={todoNoop}
|
||||
onSelectApp={onSelectApp}
|
||||
onOpenApp={(name, args) => {
|
||||
void onOpenApp(name, args);
|
||||
}}
|
||||
onCloseApp={onCloseApp}
|
||||
onAppError={onAppError}
|
||||
onRefreshApps={onRefreshTools}
|
||||
/>
|
||||
<ServerConfigModal
|
||||
|
|
@ -1310,6 +1439,12 @@ function App() {
|
|||
onCancel={() => setRemoveTarget(null)}
|
||||
onConfirm={onConfirmRemove}
|
||||
/>
|
||||
<OutputValidationModal
|
||||
opened={outputValidationDetails !== null}
|
||||
toolName={outputValidationDetails?.toolName}
|
||||
message={outputValidationDetails?.message}
|
||||
onClose={() => setOutputValidationDetails(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createRef } from "react";
|
||||
import { createRef, StrictMode } from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
|
|
@ -22,15 +22,33 @@ interface MockBridge {
|
|||
sendToolCancelled: ReturnType<typeof vi.fn>;
|
||||
teardownResource: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
removeEventListener: ReturnType<typeof vi.fn>;
|
||||
/** Test helper: dispatch a bridge event (e.g. "initialized") to listeners. */
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
}
|
||||
|
||||
function createMockBridge(): MockBridge {
|
||||
const listeners: Record<string, ((payload: unknown) => void)[]> = {};
|
||||
return {
|
||||
sendToolInput: vi.fn().mockResolvedValue(undefined),
|
||||
sendToolResult: vi.fn().mockResolvedValue(undefined),
|
||||
sendToolCancelled: vi.fn().mockResolvedValue(undefined),
|
||||
teardownResource: vi.fn().mockResolvedValue({}),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn((event: string, handler: (p: unknown) => void) => {
|
||||
(listeners[event] ??= []).push(handler);
|
||||
}),
|
||||
removeEventListener: vi.fn(
|
||||
(event: string, handler: (p: unknown) => void) => {
|
||||
listeners[event] = (listeners[event] ?? []).filter(
|
||||
(h) => h !== handler,
|
||||
);
|
||||
},
|
||||
),
|
||||
emit: (event: string, payload?: unknown) => {
|
||||
(listeners[event] ?? []).forEach((h) => h(payload));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -90,9 +108,10 @@ describe("AppRenderer", () => {
|
|||
await flushAsync();
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(factory.mock.calls[0]?.[0]).toBeInstanceOf(HTMLIFrameElement);
|
||||
expect(factory.mock.calls[0]?.[1]).toBe(tool);
|
||||
});
|
||||
|
||||
it("forwards sendToolInput through the bridge", async () => {
|
||||
it("forwards sendToolInput through the bridge once initialized", async () => {
|
||||
const bridge = createMockBridge();
|
||||
const ref = createRef<AppRendererHandle>();
|
||||
renderWithMantine(
|
||||
|
|
@ -106,13 +125,14 @@ describe("AppRenderer", () => {
|
|||
await flushAsync();
|
||||
await act(async () => {
|
||||
await ref.current?.sendToolInput({ city: "NYC" });
|
||||
bridge.emit("initialized");
|
||||
});
|
||||
expect(bridge.sendToolInput).toHaveBeenCalledWith({
|
||||
arguments: { city: "NYC" },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards sendToolResult through the bridge", async () => {
|
||||
it("forwards sendToolResult through the bridge once initialized", async () => {
|
||||
const bridge = createMockBridge();
|
||||
const ref = createRef<AppRendererHandle>();
|
||||
const result: CallToolResult = {
|
||||
|
|
@ -129,10 +149,67 @@ describe("AppRenderer", () => {
|
|||
await flushAsync();
|
||||
await act(async () => {
|
||||
await ref.current?.sendToolResult(result);
|
||||
bridge.emit("initialized");
|
||||
});
|
||||
expect(bridge.sendToolResult).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it("buffers tool input until the view is initialized, then flushes input before result", async () => {
|
||||
const bridge = createMockBridge();
|
||||
const ref = createRef<AppRendererHandle>();
|
||||
const result: CallToolResult = { content: [{ type: "text", text: "ok" }] };
|
||||
renderWithMantine(
|
||||
<AppRenderer
|
||||
ref={ref}
|
||||
sandboxPath="/sandbox.html"
|
||||
tool={tool}
|
||||
bridgeFactory={() => asBridge(bridge)}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Pushed before `initialized` — must not reach the bridge yet.
|
||||
await act(async () => {
|
||||
await ref.current?.sendToolInput({ city: "NYC" });
|
||||
await ref.current?.sendToolResult(result);
|
||||
});
|
||||
expect(bridge.sendToolInput).not.toHaveBeenCalled();
|
||||
expect(bridge.sendToolResult).not.toHaveBeenCalled();
|
||||
|
||||
// Initialization releases both, input first.
|
||||
await act(async () => {
|
||||
bridge.emit("initialized");
|
||||
});
|
||||
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.sendToolResult).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.sendToolInput.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
bridge.sendToolResult.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only the latest buffered input (latest-wins)", async () => {
|
||||
const bridge = createMockBridge();
|
||||
const ref = createRef<AppRendererHandle>();
|
||||
renderWithMantine(
|
||||
<AppRenderer
|
||||
ref={ref}
|
||||
sandboxPath="/sandbox.html"
|
||||
tool={tool}
|
||||
bridgeFactory={() => asBridge(bridge)}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
await act(async () => {
|
||||
await ref.current?.sendToolInput({ city: "NYC" });
|
||||
await ref.current?.sendToolInput({ city: "LA" });
|
||||
bridge.emit("initialized");
|
||||
});
|
||||
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.sendToolInput).toHaveBeenCalledWith({
|
||||
arguments: { city: "LA" },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards sendToolCancelled through the bridge", async () => {
|
||||
const bridge = createMockBridge();
|
||||
const ref = createRef<AppRendererHandle>();
|
||||
|
|
@ -153,6 +230,42 @@ describe("AppRenderer", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("builds a single bridge and does not dispose it under StrictMode double-invoke", async () => {
|
||||
// React StrictMode runs effects setup→cleanup→setup in dev. The bridge
|
||||
// (a stateful handshake) must survive that as ONE instance — rebuilding it
|
||||
// spins up a second transport that double-loads the sandbox and races the
|
||||
// app's ui/initialize handshake.
|
||||
const bridge = createMockBridge();
|
||||
const factory = vi.fn<BridgeFactory>(() => asBridge(bridge));
|
||||
const ref = createRef<AppRendererHandle>();
|
||||
renderWithMantine(
|
||||
<StrictMode>
|
||||
<AppRenderer
|
||||
ref={ref}
|
||||
sandboxPath="/sandbox.html"
|
||||
tool={tool}
|
||||
bridgeFactory={factory}
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// One build, and the bridge is NOT torn down by the synthetic remount.
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.teardownResource).not.toHaveBeenCalled();
|
||||
expect(bridge.close).not.toHaveBeenCalled();
|
||||
|
||||
// The reused bridge still delivers buffered input once initialized.
|
||||
await act(async () => {
|
||||
await ref.current?.sendToolInput({ city: "NYC" });
|
||||
bridge.emit("initialized");
|
||||
});
|
||||
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.sendToolInput).toHaveBeenCalledWith({
|
||||
arguments: { city: "NYC" },
|
||||
});
|
||||
});
|
||||
|
||||
it("teardown disposes the bridge once and is idempotent", async () => {
|
||||
const bridge = createMockBridge();
|
||||
const ref = createRef<AppRendererHandle>();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { Box } from "@mantine/core";
|
||||
import { useEffect, useImperativeHandle, useRef, type Ref } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
|
|
@ -11,6 +17,7 @@ import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|||
*/
|
||||
export type BridgeFactory = (
|
||||
iframe: HTMLIFrameElement,
|
||||
tool: Tool,
|
||||
) => AppBridge | Promise<AppBridge>;
|
||||
|
||||
export interface AppRendererHandle {
|
||||
|
|
@ -47,6 +54,26 @@ async function disposeBridge(bridge: AppBridge): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge lifecycle (the interlocking refs below):
|
||||
*
|
||||
* mount ─▶ build (buildId++) ─▶ factory(iframe,tool) ─async─▶ bridgeRef set
|
||||
* │ on "initialized"
|
||||
* ▼ → flushPending
|
||||
* cleanup ─▶ scheduleDispose() ──microtask──▶ dispose (unless cancelled)
|
||||
* ▲ │
|
||||
* └── re-setup with SAME inputs ─────┘ cancel + REUSE bridge
|
||||
*
|
||||
* - `buildId` (monotonic): a bridge resolved from an older build self-disposes.
|
||||
* - `disposeScheduled`: a dispose is queued (microtask); a synchronous re-setup
|
||||
* (StrictMode double-invoke, or a transient re-render) cancels it and reuses
|
||||
* the live bridge instead of rebuilding (rebuild double-loads the sandbox and
|
||||
* races the app handshake). A re-setup with CHANGED inputs disposes + rebuilds.
|
||||
* - `lastDeps`: distinguishes "same inputs → reuse" from "changed → rebuild".
|
||||
* - `initialized`: gates flushing buffered input/result until the view is ready.
|
||||
* - `pendingInput`/`pendingResult`: latest-wins buffer for host-initiated open.
|
||||
* - `teardownStarted`: makes the imperative teardown() idempotent vs unmount.
|
||||
*/
|
||||
export function AppRenderer({
|
||||
sandboxPath,
|
||||
tool,
|
||||
|
|
@ -56,61 +83,149 @@ export function AppRenderer({
|
|||
}: AppRendererProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const bridgeRef = useRef<AppBridge | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const pendingInputRef = useRef<Record<string, unknown> | null>(null);
|
||||
const pendingResultRef = useRef<CallToolResult | null>(null);
|
||||
const teardownStartedRef = useRef(false);
|
||||
// Bridge-lifecycle bookkeeping for the deferred-dispose / reuse dance that
|
||||
// keeps a single bridge alive across React StrictMode's dev-only
|
||||
// setup→cleanup→setup double-invoke (see the build effect below).
|
||||
const buildIdRef = useRef(0);
|
||||
const disposeScheduledRef = useRef(false);
|
||||
const lastDepsRef = useRef<{
|
||||
bridgeFactory: BridgeFactory;
|
||||
sandboxPath: string;
|
||||
tool: Tool;
|
||||
} | null>(null);
|
||||
const onErrorRef = useRef(onError);
|
||||
useEffect(() => {
|
||||
onErrorRef.current = onError;
|
||||
});
|
||||
|
||||
// Flush buffered tool input/result to the view, but only once the bridge
|
||||
// exists AND the view has signalled `initialized`. The spec requires tool
|
||||
// input/result to arrive after initialization, yet a host-initiated open
|
||||
// (the Open App click) fires before the iframe's app has loaded — so we
|
||||
// buffer the latest values and release them when the view is ready. Input is
|
||||
// always sent before result.
|
||||
const flushPending = useCallback(() => {
|
||||
const bridge = bridgeRef.current;
|
||||
if (!bridge || !initializedRef.current) return;
|
||||
if (pendingInputRef.current !== null) {
|
||||
const args = pendingInputRef.current;
|
||||
pendingInputRef.current = null;
|
||||
void bridge.sendToolInput({ arguments: args });
|
||||
}
|
||||
if (pendingResultRef.current !== null) {
|
||||
const result = pendingResultRef.current;
|
||||
pendingResultRef.current = null;
|
||||
void bridge.sendToolResult(result);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Dispose the live bridge, but deferred to a microtask. React StrictMode runs
|
||||
// effects setup→cleanup→setup synchronously in dev; deferring lets the
|
||||
// re-setup cancel the disposal and keep the SAME bridge, instead of tearing
|
||||
// it down and rebuilding. A rebuild here spins up a second transport that
|
||||
// re-posts sandbox-resource-ready (the sandbox loads the app twice) and
|
||||
// races the app's ui/initialize handshake — which is what left apps stuck on
|
||||
// an empty shell ("handshake timed out") in dev.
|
||||
const scheduleDispose = useCallback(() => {
|
||||
disposeScheduledRef.current = true;
|
||||
queueMicrotask(() => {
|
||||
if (!disposeScheduledRef.current) return; // cancelled by a re-setup
|
||||
disposeScheduledRef.current = false;
|
||||
// Invalidate any in-flight factory so a late-resolving bridge disposes
|
||||
// itself instead of attaching to a torn-down component.
|
||||
buildIdRef.current++;
|
||||
const bridge = bridgeRef.current;
|
||||
bridgeRef.current = null;
|
||||
initializedRef.current = false;
|
||||
lastDepsRef.current = null;
|
||||
if (bridge) void disposeBridge(bridge);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
let cancelled = false;
|
||||
const prev = lastDepsRef.current;
|
||||
const sameInputs =
|
||||
prev !== null &&
|
||||
prev.bridgeFactory === bridgeFactory &&
|
||||
prev.sandboxPath === sandboxPath &&
|
||||
prev.tool === tool;
|
||||
|
||||
// A disposal scheduled by the immediately-preceding cleanup means we are in
|
||||
// a synchronous re-setup. If the inputs are identical (StrictMode's
|
||||
// double-invoke, or a transient re-render) keep the live bridge: cancel the
|
||||
// disposal and re-deliver any buffered input/result to it.
|
||||
if (disposeScheduledRef.current && sameInputs) {
|
||||
disposeScheduledRef.current = false;
|
||||
flushPending();
|
||||
return scheduleDispose;
|
||||
}
|
||||
|
||||
// Otherwise this is a real (re)build. If a disposal was pending (inputs
|
||||
// changed), run it synchronously before building the replacement.
|
||||
if (disposeScheduledRef.current) {
|
||||
disposeScheduledRef.current = false;
|
||||
buildIdRef.current++;
|
||||
const old = bridgeRef.current;
|
||||
bridgeRef.current = null;
|
||||
initializedRef.current = false;
|
||||
if (old) void disposeBridge(old);
|
||||
}
|
||||
|
||||
lastDepsRef.current = { bridgeFactory, sandboxPath, tool };
|
||||
const buildId = ++buildIdRef.current;
|
||||
teardownStartedRef.current = false;
|
||||
initializedRef.current = false;
|
||||
|
||||
let pending: Promise<AppBridge>;
|
||||
try {
|
||||
pending = Promise.resolve(bridgeFactory(iframe));
|
||||
pending = Promise.resolve(bridgeFactory(iframe, tool));
|
||||
} catch (err) {
|
||||
onErrorRef.current?.(toError(err));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
return scheduleDispose;
|
||||
}
|
||||
|
||||
pending
|
||||
.then((bridge) => {
|
||||
if (cancelled) {
|
||||
if (buildIdRef.current !== buildId) {
|
||||
void disposeBridge(bridge);
|
||||
return;
|
||||
}
|
||||
bridgeRef.current = bridge;
|
||||
// Registered before the inner app can finish loading (which only
|
||||
// happens after the sandbox-resource-ready round-trip the factory
|
||||
// drives), so the view's `initialized` signal is never missed.
|
||||
bridge.addEventListener("initialized", () => {
|
||||
initializedRef.current = true;
|
||||
flushPending();
|
||||
});
|
||||
flushPending();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) onErrorRef.current?.(toError(err));
|
||||
if (buildIdRef.current === buildId) onErrorRef.current?.(toError(err));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const bridge = bridgeRef.current;
|
||||
bridgeRef.current = null;
|
||||
if (bridge) void disposeBridge(bridge);
|
||||
};
|
||||
}, [bridgeFactory, sandboxPath]);
|
||||
return scheduleDispose;
|
||||
}, [bridgeFactory, sandboxPath, tool, flushPending, scheduleDispose]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
async sendToolInput(args) {
|
||||
const bridge = bridgeRef.current;
|
||||
if (!bridge) return;
|
||||
await bridge.sendToolInput({ arguments: args });
|
||||
// Buffered (latest-wins) and released by flushPending once the view is
|
||||
// initialized — the handle may be invoked before the bridge resolves.
|
||||
pendingInputRef.current = args;
|
||||
flushPending();
|
||||
},
|
||||
async sendToolResult(result) {
|
||||
const bridge = bridgeRef.current;
|
||||
if (!bridge) return;
|
||||
await bridge.sendToolResult(result);
|
||||
pendingResultRef.current = result;
|
||||
flushPending();
|
||||
},
|
||||
async sendToolCancelled(reason) {
|
||||
const bridge = bridgeRef.current;
|
||||
|
|
@ -122,12 +237,21 @@ export function AppRenderer({
|
|||
if (!bridge || teardownStartedRef.current) return;
|
||||
teardownStartedRef.current = true;
|
||||
// Null the ref synchronously so a concurrent unmount cleanup cannot
|
||||
// see a still-live bridge and dispose it a second time.
|
||||
// see a still-live bridge and dispose it a second time. Bumping the
|
||||
// build id makes any in-flight factory self-dispose, and clearing the
|
||||
// pending-dispose flag/cached deps prevents the deferred dispose from
|
||||
// acting on an already torn-down bridge.
|
||||
buildIdRef.current++;
|
||||
disposeScheduledRef.current = false;
|
||||
lastDepsRef.current = null;
|
||||
bridgeRef.current = null;
|
||||
initializedRef.current = false;
|
||||
pendingInputRef.current = null;
|
||||
pendingResultRef.current = null;
|
||||
await disposeBridge(bridge);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[flushPending],
|
||||
);
|
||||
|
||||
// The iframe deliberately has no `sandbox` attribute: `sandboxPath` resolves
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* Tests for createAppBridgeFactory. The ext-apps AppBridge/PostMessageTransport
|
||||
* are mocked so we can assert the host-side wiring (construction args, the
|
||||
* sandboxready → resources/read → sendSandboxResourceReady round-trip, openLink
|
||||
* handling, connect) without a real iframe/postMessage environment. The real
|
||||
* end-to-end iframe round-trip is covered by the AppsScreen Storybook play test.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type {
|
||||
Tool,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
|
||||
// --- ext-apps mock -------------------------------------------------------
|
||||
const bridgeInstances: MockBridge[] = [];
|
||||
|
||||
interface MockBridge {
|
||||
ctorArgs: unknown[];
|
||||
listeners: Record<string, ((p: unknown) => void)[]>;
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
sendSandboxResourceReady: ReturnType<typeof vi.fn>;
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
onopenlink?: (params: { url: string }) => Promise<{ isError?: boolean }>;
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
}
|
||||
|
||||
vi.mock("@modelcontextprotocol/ext-apps/app-bridge", () => {
|
||||
class AppBridge {
|
||||
ctorArgs: unknown[];
|
||||
listeners: Record<string, ((p: unknown) => void)[]> = {};
|
||||
addEventListener = vi.fn((event: string, handler: (p: unknown) => void) => {
|
||||
(this.listeners[event] ??= []).push(handler);
|
||||
});
|
||||
sendSandboxResourceReady = vi.fn().mockResolvedValue(undefined);
|
||||
connect = vi.fn().mockResolvedValue(undefined);
|
||||
onopenlink?: (params: { url: string }) => Promise<{ isError?: boolean }>;
|
||||
emit = (event: string, payload?: unknown) => {
|
||||
(this.listeners[event] ?? []).forEach((h) => h(payload));
|
||||
};
|
||||
constructor(...args: unknown[]) {
|
||||
this.ctorArgs = args;
|
||||
bridgeInstances.push(this as unknown as MockBridge);
|
||||
}
|
||||
}
|
||||
class PostMessageTransport {
|
||||
target: unknown;
|
||||
source: unknown;
|
||||
constructor(target: unknown, source: unknown) {
|
||||
this.target = target;
|
||||
this.source = source;
|
||||
}
|
||||
}
|
||||
return {
|
||||
AppBridge,
|
||||
PostMessageTransport,
|
||||
getToolUiResourceUri: (tool: Partial<Tool>) =>
|
||||
(tool._meta as { ui?: { resourceUri?: string } } | undefined)?.ui
|
||||
?.resourceUri,
|
||||
};
|
||||
});
|
||||
|
||||
import { createAppBridgeFactory } from "./createAppBridgeFactory";
|
||||
|
||||
const tool: Tool = {
|
||||
name: "weather_app",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://weather/app.html" } },
|
||||
};
|
||||
|
||||
function makeIframe(hasWindow = true): HTMLIFrameElement {
|
||||
return {
|
||||
contentWindow: hasWindow ? ({} as Window) : null,
|
||||
} as unknown as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
const fakeClient = { name: "sdk-client" } as unknown as Client;
|
||||
|
||||
function uiResource(
|
||||
text: string | undefined,
|
||||
meta?: Record<string, unknown>,
|
||||
): ReadResourceResult {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: "ui://weather/app.html",
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(meta ? { _meta: meta } : {}),
|
||||
},
|
||||
],
|
||||
} as ReadResourceResult;
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("createAppBridgeFactory", () => {
|
||||
beforeEach(() => {
|
||||
bridgeInstances.length = 0;
|
||||
});
|
||||
|
||||
it("throws when no client is connected", async () => {
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => null,
|
||||
readResource: vi.fn(),
|
||||
});
|
||||
await expect(factory(makeIframe(), tool)).rejects.toThrow(
|
||||
/no connected MCP client/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the iframe has no contentWindow", async () => {
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi.fn(),
|
||||
});
|
||||
await expect(factory(makeIframe(false), tool)).rejects.toThrow(/no window/);
|
||||
});
|
||||
|
||||
it("constructs the bridge with the client, host info, capabilities and theme, then connects", async () => {
|
||||
// Theme is read from the DOM (Mantine's resolved color-scheme attribute).
|
||||
document.documentElement.setAttribute("data-mantine-color-scheme", "dark");
|
||||
try {
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi.fn().mockResolvedValue(uiResource("<h1>hi</h1>")),
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
expect(bridgeInstances).toHaveLength(1);
|
||||
const bridge = bridgeInstances[0];
|
||||
expect(bridge.ctorArgs[0]).toBe(fakeClient);
|
||||
expect(bridge.ctorArgs[1]).toMatchObject({ name: "MCP Inspector" });
|
||||
expect(bridge.ctorArgs[2]).toMatchObject({ serverTools: {} });
|
||||
expect(bridge.ctorArgs[3]).toEqual({ hostContext: { theme: "dark" } });
|
||||
expect(bridge.connect).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
document.documentElement.removeAttribute("data-mantine-color-scheme");
|
||||
}
|
||||
});
|
||||
|
||||
it("on sandboxready, reads the UI resource and pushes html + meta to the sandbox", async () => {
|
||||
const readResource = vi.fn().mockResolvedValue(
|
||||
uiResource("<h1>weather</h1>", {
|
||||
permissions: { geolocation: {} },
|
||||
csp: { connectSrc: ["https://api.example.com"] },
|
||||
}),
|
||||
);
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
const bridge = bridgeInstances[0];
|
||||
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
expect(readResource).toHaveBeenCalledWith("ui://weather/app.html");
|
||||
expect(bridge.sendSandboxResourceReady).toHaveBeenCalledWith({
|
||||
html: "<h1>weather</h1>",
|
||||
permissions: { geolocation: {} },
|
||||
csp: { connectSrc: ["https://api.example.com"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not push when the tool has no UI resource uri", async () => {
|
||||
const readResource = vi.fn();
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), {
|
||||
name: "plain",
|
||||
inputSchema: { type: "object" },
|
||||
});
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
expect(readResource).not.toHaveBeenCalled();
|
||||
expect(bridgeInstances[0].sendSandboxResourceReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows a resources/read failure without rejecting", async () => {
|
||||
const readResource = vi.fn().mockRejectedValue(new Error("read boom"));
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows a UI resource that has no text content", async () => {
|
||||
const readResource = vi.fn().mockResolvedValue(uiResource(undefined));
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens http(s) links in a new tab and reports non-http as error", async () => {
|
||||
const open = vi
|
||||
.spyOn(window, "open")
|
||||
.mockImplementation(() => null as unknown as Window);
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
const bridge = bridgeInstances[0];
|
||||
|
||||
await expect(
|
||||
bridge.onopenlink!({ url: "https://example.com" }),
|
||||
).resolves.toEqual({ isError: false });
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
|
||||
await expect(
|
||||
bridge.onopenlink!({ url: "javascript:alert(1)" }),
|
||||
).resolves.toEqual({ isError: true });
|
||||
|
||||
open.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import {
|
||||
AppBridge,
|
||||
PostMessageTransport,
|
||||
getToolUiResourceUri,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type {
|
||||
McpUiHostCapabilities,
|
||||
McpUiResourceMeta,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import type {
|
||||
Implementation,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { BridgeFactory } from "./AppRenderer";
|
||||
|
||||
/**
|
||||
* Host identity advertised to MCP Apps during the bridge handshake. Static —
|
||||
* the value is informational (shown by some apps), not a protocol version.
|
||||
*/
|
||||
export const HOST_INFO: Implementation = {
|
||||
name: "MCP Inspector",
|
||||
version: "2.0.0",
|
||||
};
|
||||
|
||||
/**
|
||||
* Capabilities the inspector host offers a running MCP App. Constructed WITH an
|
||||
* MCP client (see {@link createAppBridgeFactory}), so the bridge auto-forwards
|
||||
* tools/resources/prompts to the view; we only declare the host-side features
|
||||
* we actually back: external links (handled below), tool/resource list-change
|
||||
* forwarding, and logging passthrough.
|
||||
*/
|
||||
export const HOST_CAPABILITIES: McpUiHostCapabilities = {
|
||||
openLinks: {},
|
||||
serverTools: { listChanged: true },
|
||||
serverResources: { listChanged: true },
|
||||
logging: {},
|
||||
};
|
||||
|
||||
export interface AppBridgeFactoryDeps {
|
||||
/** The connected SDK client to back the bridge, or null when disconnected. */
|
||||
getClient: () => Client | null;
|
||||
/** Reads a UI resource (resources/read) and returns the SDK result. */
|
||||
readResource: (uri: string) => Promise<ReadResourceResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host theme from the DOM at bridge-build time. Mantine writes the
|
||||
* resolved color scheme to `<html data-mantine-color-scheme>`. Reading it here
|
||||
* (rather than capturing React state in the factory deps) keeps the factory's
|
||||
* identity stable across theme toggles — the AppRenderer treats a new factory
|
||||
* identity as "rebuild the bridge", which would reload a running app's iframe on
|
||||
* every theme flip. The theme is read once per bridge build (the value at open
|
||||
* time); pushing live theme updates to an already-open app would need an
|
||||
* AppBridge.setHostContext follow-up.
|
||||
*
|
||||
* The attribute is only ever `"light"` or `"dark"` — Mantine resolves
|
||||
* `defaultColorScheme="auto"` to the system value before paint and never writes
|
||||
* `"auto"` here, so no `auto` branch is needed. The matchMedia fallback only
|
||||
* covers the attribute being absent (e.g. a hydration race).
|
||||
*/
|
||||
function currentTheme(): "light" | "dark" {
|
||||
if (typeof document !== "undefined") {
|
||||
const attr = document.documentElement.getAttribute(
|
||||
"data-mantine-color-scheme",
|
||||
);
|
||||
if (attr === "dark" || attr === "light") return attr;
|
||||
}
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
) {
|
||||
return "dark";
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */
|
||||
function extractHtmlAndMeta(result: ReadResourceResult): {
|
||||
html: string;
|
||||
meta: McpUiResourceMeta | undefined;
|
||||
} {
|
||||
for (const content of result.contents) {
|
||||
const text = (content as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
return {
|
||||
html: text,
|
||||
meta: content._meta as McpUiResourceMeta | undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error("UI resource has no text (HTML) content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link BridgeFactory} the AppRenderer uses to bring a sandbox
|
||||
* iframe to life. For each mounted iframe + tool it:
|
||||
*
|
||||
* 1. constructs a host-side {@link AppBridge} over the SDK client (so the view
|
||||
* can call tools/resources/prompts directly),
|
||||
* 2. on the sandbox proxy's `sandboxready` signal, reads the tool's UI
|
||||
* resource and pushes its HTML + sandbox/permissions into the inner iframe,
|
||||
* 3. handles `openLinks` by opening http(s) URLs in a new tab,
|
||||
* 4. connects a {@link PostMessageTransport} to the iframe and returns the
|
||||
* live bridge.
|
||||
*
|
||||
* Host-initiated tool input/result are pushed separately through the renderer's
|
||||
* imperative handle (see `AppRenderer`), gated on the view's `initialized`
|
||||
* event. The factory throws when no client is connected; AppRenderer routes
|
||||
* that to its `onError` so the user sees a clear failure instead of a blank
|
||||
* frame.
|
||||
*/
|
||||
export function createAppBridgeFactory(
|
||||
deps: AppBridgeFactoryDeps,
|
||||
): BridgeFactory {
|
||||
return async (iframe, tool) => {
|
||||
const client = deps.getClient();
|
||||
if (!client) {
|
||||
throw new Error("Cannot render MCP App: no connected MCP client.");
|
||||
}
|
||||
const targetWindow = iframe.contentWindow;
|
||||
if (!targetWindow) {
|
||||
throw new Error("Cannot render MCP App: sandbox iframe has no window.");
|
||||
}
|
||||
|
||||
const bridge = new AppBridge(client, HOST_INFO, HOST_CAPABILITIES, {
|
||||
hostContext: { theme: currentTheme() },
|
||||
});
|
||||
|
||||
// The double-iframe proxy posts `sandboxready` once it can receive content.
|
||||
// Read the tool's UI resource and hand its HTML (plus any sandbox/permission
|
||||
// hints from the resource _meta) to the inner sandboxed iframe. Swallow
|
||||
// failures: a read error leaves an empty (but live) app frame rather than
|
||||
// tearing down the bridge mid-handshake.
|
||||
bridge.addEventListener("sandboxready", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const uri = getToolUiResourceUri(tool);
|
||||
if (!uri) return;
|
||||
const result = await deps.readResource(uri);
|
||||
const { html, meta } = extractHtmlAndMeta(result);
|
||||
await bridge.sendSandboxResourceReady({
|
||||
html,
|
||||
permissions: meta?.permissions,
|
||||
csp: meta?.csp,
|
||||
});
|
||||
} catch {
|
||||
/* read/post failed (or bridge closed) — leave the frame empty */
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
bridge.onopenlink = async ({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return { isError: false };
|
||||
}
|
||||
return { isError: true };
|
||||
};
|
||||
|
||||
const transport = new PostMessageTransport(targetWindow, targetWindow);
|
||||
await bridge.connect(transport);
|
||||
return bridge;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { AppShell } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import {
|
||||
OutputValidationModal,
|
||||
type OutputValidationModalProps,
|
||||
} from "./OutputValidationModal";
|
||||
|
||||
const SAMPLE_MESSAGE = [
|
||||
"data/samples/0 must NOT have additional properties",
|
||||
"data/samples/1 must NOT have additional properties",
|
||||
"data/samples/2 must NOT have additional properties",
|
||||
"data/samples/3 must NOT have additional properties",
|
||||
"data/samples/4 must NOT have additional properties",
|
||||
].join(", ");
|
||||
|
||||
function InteractiveRender(args: OutputValidationModalProps) {
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell.Main>
|
||||
<OutputValidationModal {...args} />
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof OutputValidationModal> = {
|
||||
title: "Groups/OutputValidationModal",
|
||||
component: OutputValidationModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: InteractiveRender,
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: fn(),
|
||||
toolName: "open_pattern_editor",
|
||||
message: SAMPLE_MESSAGE,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof OutputValidationModal>;
|
||||
|
||||
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 },
|
||||
};
|
||||
|
|
@ -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(
|
||||
<OutputValidationModal
|
||||
opened
|
||||
onClose={vi.fn()}
|
||||
toolName="open_pattern_editor"
|
||||
message={MESSAGE}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<OutputValidationModal opened onClose={vi.fn()} message={MESSAGE} />,
|
||||
);
|
||||
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(
|
||||
<OutputValidationModal opened onClose={onClose} message={MESSAGE} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing visible when closed", () => {
|
||||
renderWithMantine(
|
||||
<OutputValidationModal
|
||||
opened={false}
|
||||
onClose={vi.fn()}
|
||||
message={MESSAGE}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("Output schema validation"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Title order={4} flex={1}>
|
||||
Output schema validation
|
||||
</Title>
|
||||
<CloseButton aria-label="Close" onClick={onClose} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{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."}
|
||||
</Text>
|
||||
<Textarea
|
||||
aria-label="Validation details"
|
||||
readOnly
|
||||
autosize
|
||||
minRows={6}
|
||||
maxRows={18}
|
||||
value={message ?? ""}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,<title>Mock%20Sandbox</title>";
|
||||
// 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(
|
||||
`<!doctype html><html><head><meta name="color-scheme" content="light dark">` +
|
||||
`<style>html,body{height:100%;margin:0}` +
|
||||
`body{display:flex;align-items:center;justify-content:center;` +
|
||||
`font-family:system-ui,sans-serif;color:CanvasText;background:Canvas}</style>` +
|
||||
`</head><body><div>Mock app — running in sandbox</div></body></html>`,
|
||||
);
|
||||
|
||||
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<AppRendererHandle>(null);
|
||||
const [echo, setEcho] = useState<string | null>(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<string, unknown>;
|
||||
}) => {
|
||||
// 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<string, unknown>) => {
|
||||
// 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 (
|
||||
<Stack gap={0}>
|
||||
<Text data-testid="echo-status" p="xs">
|
||||
{echo ?? "no echo yet"}
|
||||
</Text>
|
||||
<AppsScreen
|
||||
{...args}
|
||||
rendererRef={ref}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onOpenApp={onOpenApp}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<AppsScreen {...buildProps({ sandboxPath: undefined })} />,
|
||||
);
|
||||
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(
|
||||
<AppsScreen
|
||||
{...buildProps({ bridgeFactory: throwingFactory, onError })}
|
||||
/>,
|
||||
);
|
||||
// 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(<AppsScreen {...buildProps()} />);
|
||||
|
|
|
|||
|
|
@ -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<AppRendererHandle>;
|
||||
onRefreshList: () => void;
|
||||
onSelectApp: (name: string) => void;
|
||||
onOpenApp: (name: string, args: Record<string, unknown>) => 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<string | undefined>(
|
||||
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 (
|
||||
<ScreenLayout>
|
||||
<ContentCard flex={1} h="100%">
|
||||
<EmptyState>
|
||||
MCP Apps are unavailable — the sandbox could not be reached.
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
{!maximized && (
|
||||
|
|
@ -247,6 +271,7 @@ export function AppsScreen({
|
|||
sandboxPath={sandboxPath}
|
||||
tool={selectedTool}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onError={onError}
|
||||
ref={rendererRef}
|
||||
/>
|
||||
</RendererFrame>
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ const meta: Meta<typeof InspectorView> = {
|
|||
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<typeof InspectorView> = {
|
|||
onSelectApp: fn(),
|
||||
onOpenApp: fn(),
|
||||
onCloseApp: fn(),
|
||||
onAppError: fn(),
|
||||
onRefreshApps: fn(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<AppRendererHandle>;
|
||||
|
||||
// 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<string, unknown>) => 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<string>(SERVERS_TAB);
|
||||
const appRendererRef = useRef<AppRendererHandle>(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}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Prompts"}>
|
||||
|
|
|
|||
90
clients/web/src/test/core/mcp/toolOutputValidation.test.ts
Normal file
90
clients/web/src/test/core/mcp/toolOutputValidation.test.ts
Normal file
|
|
@ -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>): 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();
|
||||
});
|
||||
});
|
||||
114
clients/web/src/test/core/react/useSandboxUrl.test.tsx
Normal file
114
clients/web/src/test/core/react/useSandboxUrl.test.tsx
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<string, string>,
|
||||
toolSpecificMetadata?: Record<string, string>,
|
||||
taskOptions?: { ttl?: number },
|
||||
options?: { skipOutputValidation?: boolean },
|
||||
): Promise<ToolCallInvocation> {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export interface InspectorClientEventMap {
|
|||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, string>;
|
||||
/** Non-fatal outputSchema mismatch detected on the skipOutputValidation path. */
|
||||
outputValidationError?: string;
|
||||
};
|
||||
resourceContentChange: {
|
||||
uri: string;
|
||||
|
|
|
|||
47
core/mcp/toolOutputValidation.ts
Normal file
47
core/mcp/toolOutputValidation.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue