diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx
index cf1133d4..19d43ae4 100644
--- a/clients/web/src/App.test.tsx
+++ b/clients/web/src/App.test.tsx
@@ -32,6 +32,8 @@ vi.mock("@inspector/core/mcp/index.js", () => {
.mockResolvedValue({ result: { contents: [] }, timestamp: 1 });
setLoggingLevel = vi.fn().mockResolvedValue(undefined);
getOAuthState = vi.fn().mockReturnValue(undefined);
+ getPendingSamples = vi.fn().mockReturnValue([]);
+ getPendingElicitations = vi.fn().mockReturnValue([]);
}
const instances: FakeInspectorClient[] = [];
return {
@@ -255,3 +257,58 @@ describe("App session-scoped state reset on disconnect", () => {
expect(screen.getByTestId("log-level")).toHaveTextContent("info");
});
});
+
+describe("App pending server-initiated request modal", () => {
+ beforeEach(() => {
+ clientInstances.length = 0;
+ });
+
+ it("opens the modal on a pending sample, resolves it, and closes", async () => {
+ const user = userEvent.setup();
+ renderWithMantine();
+
+ await user.click(screen.getByText("connect"));
+ await waitFor(() => expect(clientInstances).toHaveLength(1));
+
+ // The usePendingClientRequests hook subscribes to the live client's
+ // `pendingSamplesChange` event; firing it drives the App-owned modal that
+ // InspectorView does not render. Mirrors how the client enqueues a
+ // server-initiated sampling request mid tool-call.
+ const respond = vi.fn().mockResolvedValue(undefined);
+ const sample = {
+ id: "sample-1",
+ request: {
+ params: {
+ messages: [{ role: "user", content: { type: "text", text: "Hi" } }],
+ maxTokens: 256,
+ },
+ },
+ respond,
+ reject: vi.fn(),
+ };
+ act(() => {
+ clientInstances[0].dispatchEvent(
+ new CustomEvent("pendingSamplesChange", { detail: [sample] }),
+ );
+ });
+
+ await waitFor(() =>
+ expect(screen.getByText("Sampling Request")).toBeInTheDocument(),
+ );
+
+ // Resolving via the modal calls the queued request's respond() — this is
+ // what unblocks the originating call (the "spinner clears" criterion).
+ await user.click(screen.getByRole("button", { name: "Auto-respond" }));
+ expect(respond).toHaveBeenCalledTimes(1);
+
+ // The client clearing its queue (empty event) closes the modal.
+ act(() => {
+ clientInstances[0].dispatchEvent(
+ new CustomEvent("pendingSamplesChange", { detail: [] }),
+ );
+ });
+ await waitFor(() =>
+ expect(screen.queryByText("Sampling Request")).not.toBeInTheDocument(),
+ );
+ });
+});
diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx
index 1305acd4..fac21758 100644
--- a/clients/web/src/App.tsx
+++ b/clients/web/src/App.tsx
@@ -8,6 +8,8 @@ import {
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import type {
+ CreateMessageResult,
+ ElicitResult,
InitializeResult,
LoggingLevel,
LoggingMessageNotification,
@@ -55,6 +57,7 @@ import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubsc
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 { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
import { InspectorView } from "./components/views/InspectorView/InspectorView";
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
@@ -71,6 +74,10 @@ import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/Con
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
+import {
+ PendingClientRequestModal,
+ type PendingClientRequestContent,
+} from "./components/groups/PendingClientRequestModal/PendingClientRequestModal";
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
import { createWebEnvironment } from "./lib/environmentFactory";
import {
@@ -353,6 +360,10 @@ function App() {
);
const { messages } = useMessageLog(messageLogState);
const { fetchRequests } = useFetchRequestLog(fetchRequestLogState);
+ // Server-initiated sampling / elicitation requests. These arrive while a call
+ // (e.g. a tool execution) is in flight and block it until the user responds.
+ const { pendingSamples, pendingElicitations } =
+ usePendingClientRequests(inspectorClient);
// Capture observed handshake latency at the connecting → connected edge.
// Reset when the status leaves "connected" so the next connect starts
@@ -1328,6 +1339,71 @@ function App() {
return { ...readResourceState, isSubscribed };
}, [readResourceState, subscriptions]);
+ // Surface one pending server-initiated request at a time in the modal,
+ // sampling-first. Responding (below) removes it from the client's queue,
+ // which re-renders this with the next request or closes the modal.
+ const totalPendingRequests =
+ pendingSamples.length + pendingElicitations.length;
+
+ // Derive the head request inside the memo (depending on the source arrays)
+ // so the memo actually caches — an inline `activeElicitation` would have a
+ // fresh identity every render and defeat it.
+ const pendingRequestContent =
+ useMemo(() => {
+ const activeSample = pendingSamples[0];
+ if (activeSample) {
+ return {
+ kind: "sampling",
+ id: activeSample.id,
+ request: activeSample.request.params,
+ };
+ }
+ const activeElicitation = pendingElicitations[0];
+ if (activeElicitation) {
+ const params = activeElicitation.request.params;
+ if ("url" in params) {
+ return {
+ kind: "elicitation-url",
+ id: activeElicitation.id,
+ message: params.message,
+ url: params.url,
+ };
+ }
+ return {
+ kind: "elicitation-form",
+ id: activeElicitation.id,
+ request: params,
+ };
+ }
+ return null;
+ }, [pendingSamples, pendingElicitations]);
+
+ // A remaining-count hint shown only when more than the displayed head is
+ // queued. The modal always shows the head, so a "1 of N" position would be
+ // misleading — the leading "1" never changes.
+ const queueLabel =
+ totalPendingRequests > 1 ? `${totalPendingRequests} pending` : "";
+
+ const onSamplingRespond = useCallback(
+ (result: CreateMessageResult) => {
+ void pendingSamples[0]?.respond(result);
+ },
+ [pendingSamples],
+ );
+
+ const onSamplingReject = useCallback(() => {
+ void pendingSamples[0]?.reject(
+ new Error("Sampling request rejected by user."),
+ );
+ }, [pendingSamples]);
+
+ const onElicitationRespond = useCallback(
+ (result: ElicitResult) => {
+ void pendingElicitations[0]?.respond(result);
+ },
+ [pendingElicitations],
+ );
+
return (
<>
setOutputValidationDetails(null)}
/>
+
>
);
}
diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx
index d9fa3490..40e56a6c 100644
--- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx
+++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx
@@ -1,8 +1,9 @@
-import { Button, Divider, Stack, Text } from "@mantine/core";
+import { Button, Divider, ScrollArea, Stack, Text } from "@mantine/core";
import { MdPlayArrow } from "react-icons/md";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { SchemaForm } from "../SchemaForm/SchemaForm";
import { hasInputFields } from "../../../utils/toolUtils";
+import { hasMissingRequiredFields } from "../../../utils/jsonUtils";
export interface AppDetailPanelProps {
tool: Tool;
@@ -17,16 +18,22 @@ const DescriptionText = Text.withProps({
c: "dimmed",
});
-function hasMissingRequiredFields(
- schema: Tool["inputSchema"],
- values: Record,
-): boolean {
- const required = schema.required ?? [];
- return required.some((field) => {
- const value = values[field];
- return value === undefined || value === null || value === "";
- });
-}
+// Fills the available height inside AppsScreen's full-height card and scrolls
+// the form (description + fields + Open App) when it would overflow, instead of
+// bleeding past the viewport. `mih: 0` lets it shrink within the flex parent;
+// standalone (no flex parent) it just sizes to content.
+const PanelScroll = ScrollArea.withProps({
+ flex: 1,
+ mih: 0,
+ type: "auto",
+ scrollbars: "y",
+ offsetScrollbars: true,
+});
+
+const PanelStack = Stack.withProps({
+ gap: "md",
+ miw: 0,
+});
export function AppDetailPanel({
tool,
@@ -41,31 +48,33 @@ export function AppDetailPanel({
const hasFields = hasInputFields(tool);
return (
-
- {description && {description}}
+
+
+ {description && {description}}
- {hasFields && }
+ {hasFields && }
- {/* Form stays editable while validation fails so users can finish
- filling required fields. The disabled-when-incomplete gate is on
- the Open App button below, not on the form itself. */}
-
+ {/* Form stays editable while validation fails so users can finish
+ filling required fields. The disabled-when-incomplete gate is on
+ the Open App button below, not on the form itself. */}
+
- }
- onClick={onOpenApp}
- disabled={disabled}
- loading={isOpening}
- >
- Open App
-
-
+ }
+ onClick={onOpenApp}
+ disabled={disabled}
+ loading={isOpening}
+ >
+ Open App
+
+
+
);
}
diff --git a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.stories.tsx b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.stories.tsx
index 78475a0d..d7d77997 100644
--- a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.stories.tsx
+++ b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.stories.tsx
@@ -9,6 +9,7 @@ const meta: Meta = {
args: {
onChange: fn(),
onSubmit: fn(),
+ onDecline: fn(),
onCancel: fn(),
serverName: "postgres-server",
values: {},
diff --git a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.test.tsx b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.test.tsx
index 7d74253f..7155650a 100644
--- a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.test.tsx
+++ b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.test.tsx
@@ -15,12 +15,22 @@ const dbRequest: ElicitRequestFormParams = {
},
};
+const requiredRequest: ElicitRequestFormParams = {
+ message: "Please provide your name.",
+ requestedSchema: {
+ type: "object" as const,
+ properties: { name: { type: "string" as const, title: "Name" } },
+ required: ["name"],
+ },
+};
+
const baseProps = {
request: dbRequest,
serverName: "postgres-server",
values: {},
onChange: vi.fn(),
onSubmit: vi.fn(),
+ onDecline: vi.fn(),
onCancel: vi.fn(),
};
@@ -44,9 +54,10 @@ describe("ElicitationFormPanel", () => {
expect(screen.getByLabelText("Port")).toBeInTheDocument();
});
- it("renders Submit and Cancel buttons", () => {
+ it("renders Submit, Decline, and Cancel buttons", () => {
renderWithMantine();
expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
@@ -60,6 +71,16 @@ describe("ElicitationFormPanel", () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
+ it("invokes onDecline when Decline is clicked", async () => {
+ const user = userEvent.setup();
+ const onDecline = vi.fn();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Decline" }));
+ expect(onDecline).toHaveBeenCalledTimes(1);
+ });
+
it("invokes onCancel when Cancel is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
@@ -70,6 +91,32 @@ describe("ElicitationFormPanel", () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
+ it("disables Submit until required fields are filled", () => {
+ const { rerender } = renderWithMantine(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
+ });
+
+ it("locks all actions when busy", () => {
+ renderWithMantine();
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Decline" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
+ });
+
it("invokes onChange when a schema field is updated", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
diff --git a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx
index fbe6e4f4..092e9eb6 100644
--- a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx
+++ b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx
@@ -1,6 +1,9 @@
import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core";
import type { ElicitRequestFormParams } from "@modelcontextprotocol/sdk/types.js";
-import type { JsonSchemaType } from "../../../utils/jsonUtils";
+import {
+ hasMissingRequiredFields,
+ type JsonSchemaType,
+} from "../../../utils/jsonUtils";
import { SchemaForm } from "../SchemaForm/SchemaForm";
export interface ElicitationFormPanelProps {
@@ -9,7 +12,15 @@ export interface ElicitationFormPanelProps {
values: Record;
onChange: (values: Record) => void;
onSubmit: () => void;
+ /** Explicit refusal to provide the data (maps to the spec's `decline`). */
+ onDecline: () => void;
+ /** Dismissal without an explicit choice (maps to the spec's `cancel`). */
onCancel: () => void;
+ /**
+ * A response has been dispatched; lock the actions so a second click can't
+ * resolve the request twice (the underlying handler throws if called again).
+ */
+ busy?: boolean;
}
const QuotedMessage = Text.withProps({
@@ -31,25 +42,36 @@ export function ElicitationFormPanel({
values,
onChange,
onSubmit,
+ onDecline,
onCancel,
+ busy = false,
}: ElicitationFormPanelProps) {
+ const requestedSchema = request.requestedSchema as JsonSchemaType;
+ const submitDisabled =
+ busy || hasMissingRequiredFields(requestedSchema, values);
return (
{formatQuoted(request.message)}
{formatWarning(serverName)}
-
);
diff --git a/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx b/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx
index d58a0d3f..5e9f5763 100644
--- a/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx
+++ b/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx
@@ -17,6 +17,12 @@ export interface ElicitationUrlPanelProps {
onCopyUrl: () => void;
onOpenInBrowser: () => void;
onCancel: () => void;
+ /**
+ * A response has been dispatched; lock the responding actions so a second
+ * click can't resolve the request twice (the handler throws if called
+ * again). Copy URL stays enabled — it doesn't resolve the request.
+ */
+ busy?: boolean;
}
const ItalicMessage = Text.withProps({
@@ -51,6 +57,7 @@ export function ElicitationUrlPanel({
onCopyUrl,
onOpenInBrowser,
onCancel,
+ busy = false,
}: ElicitationUrlPanelProps) {
return (
@@ -62,7 +69,7 @@ export function ElicitationUrlPanel({
Copy URL
-
+
Open in Browser
@@ -78,7 +85,7 @@ export function ElicitationUrlPanel({
This will open an external URL. Verify the domain before proceeding.
-
+
Cancel
diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx
new file mode 100644
index 00000000..d5bb6312
--- /dev/null
+++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx
@@ -0,0 +1,117 @@
+import { AppShell } from "@mantine/core";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, userEvent, within } from "storybook/test";
+import type {
+ CreateMessageRequestParams,
+ ElicitRequestFormParams,
+} from "@modelcontextprotocol/sdk/types.js";
+import {
+ PendingClientRequestModal,
+ type PendingClientRequestModalProps,
+} from "./PendingClientRequestModal";
+
+const samplingRequest: CreateMessageRequestParams = {
+ messages: [
+ {
+ role: "user",
+ content: {
+ type: "text",
+ text: "Summarize the latest changes in this repository.",
+ },
+ },
+ ],
+ maxTokens: 1024,
+};
+
+const formRequest: ElicitRequestFormParams = {
+ message: "Please provide your database connection details.",
+ requestedSchema: {
+ type: "object",
+ properties: {
+ host: { type: "string", title: "Host" },
+ port: { type: "string", title: "Port" },
+ },
+ },
+};
+
+function InteractiveRender(args: PendingClientRequestModalProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Groups/PendingClientRequestModal",
+ component: PendingClientRequestModal,
+ parameters: { layout: "fullscreen" },
+ render: InteractiveRender,
+ args: {
+ serverName: "Everything Server",
+ queuePosition: "1 of 1",
+ onSamplingRespond: fn(),
+ onSamplingReject: fn(),
+ onElicitationRespond: fn(),
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Sampling: Story = {
+ args: {
+ request: { kind: "sampling", id: "sampling-1", request: samplingRequest },
+ },
+ play: async ({ canvasElement, args }) => {
+ const body = within(canvasElement.ownerDocument.body);
+ await body.findByText("Sampling Request");
+ expect(
+ body.getByText("The server is requesting an LLM completion."),
+ ).toBeInTheDocument();
+ await userEvent.click(body.getByRole("button", { name: "Auto-respond" }));
+ expect(args.onSamplingRespond).toHaveBeenCalled();
+ },
+};
+
+export const ElicitationForm: Story = {
+ args: {
+ request: {
+ kind: "elicitation-form",
+ id: "elicitation-1",
+ request: formRequest,
+ },
+ },
+ play: async ({ canvasElement, args }) => {
+ const body = within(canvasElement.ownerDocument.body);
+ await body.findByText("Elicitation Request");
+ expect(
+ body.getByText(/Please provide your database connection details/),
+ ).toBeInTheDocument();
+ await userEvent.click(body.getByRole("button", { name: "Submit" }));
+ expect(args.onElicitationRespond).toHaveBeenCalledWith({
+ action: "accept",
+ content: {},
+ });
+ },
+};
+
+export const ElicitationUrl: Story = {
+ args: {
+ request: {
+ kind: "elicitation-url",
+ id: "elicitation-2",
+ message: "Authorize access in your browser to continue.",
+ url: "https://example.com/authorize?token=abc123",
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const body = within(canvasElement.ownerDocument.body);
+ await body.findByText("Elicitation Request");
+ expect(
+ body.getByText("https://example.com/authorize?token=abc123"),
+ ).toBeInTheDocument();
+ },
+};
diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx
new file mode 100644
index 00000000..6a09d3c0
--- /dev/null
+++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx
@@ -0,0 +1,259 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import userEvent from "@testing-library/user-event";
+import type {
+ CreateMessageRequestParams,
+ ElicitRequestFormParams,
+} from "@modelcontextprotocol/sdk/types.js";
+import { renderWithMantine, screen } from "../../../test/renderWithMantine";
+import {
+ PendingClientRequestModal,
+ type PendingClientRequestContent,
+} from "./PendingClientRequestModal";
+
+const samplingRequest: CreateMessageRequestParams = {
+ messages: [
+ {
+ role: "user",
+ content: { type: "text", text: "What is the capital of France?" },
+ },
+ ],
+ maxTokens: 1024,
+};
+
+const formRequest: ElicitRequestFormParams = {
+ message: "Please provide your name.",
+ requestedSchema: {
+ type: "object",
+ properties: { name: { type: "string", title: "Name" } },
+ },
+};
+
+const samplingContent: PendingClientRequestContent = {
+ kind: "sampling",
+ id: "sampling-1",
+ request: samplingRequest,
+};
+
+const formContent: PendingClientRequestContent = {
+ kind: "elicitation-form",
+ id: "elicitation-1",
+ request: formRequest,
+};
+
+const urlContent: PendingClientRequestContent = {
+ kind: "elicitation-url",
+ id: "elicitation-2",
+ message: "Authorize access in your browser.",
+ url: "https://example.com/authorize",
+};
+
+const baseProps = {
+ serverName: "Everything Server",
+ queuePosition: "1 of 1",
+ onSamplingRespond: vi.fn(),
+ onSamplingReject: vi.fn(),
+ onElicitationRespond: vi.fn(),
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("PendingClientRequestModal", () => {
+ it("renders nothing when there is no active request", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.queryByText("Sampling Request")).not.toBeInTheDocument();
+ expect(screen.queryByText("Elicitation Request")).not.toBeInTheDocument();
+ });
+
+ it("renders the sampling title, queue position, and panel", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText("Sampling Request")).toBeInTheDocument();
+ expect(screen.getByText("1 of 1")).toBeInTheDocument();
+ expect(
+ screen.getByText("The server is requesting an LLM completion."),
+ ).toBeInTheDocument();
+ });
+
+ it("auto-responds with a stub sampling result", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Auto-respond" }));
+ expect(baseProps.onSamplingRespond).toHaveBeenCalledWith({
+ model: "stub-model",
+ stopReason: "endTurn",
+ role: "assistant",
+ content: { type: "text", text: "" },
+ });
+ });
+
+ it("sends the edited sampling draft", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ const textarea = screen
+ .getAllByRole("textbox")
+ .find((el) => el.tagName === "TEXTAREA") as HTMLTextAreaElement;
+ await user.type(textarea, "Paris");
+ await user.click(screen.getByRole("button", { name: "Send Response" }));
+ expect(baseProps.onSamplingRespond).toHaveBeenCalledWith(
+ expect.objectContaining({
+ content: { type: "text", text: "Paris" },
+ }),
+ );
+ });
+
+ it("rejects the sampling request", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Reject" }));
+ expect(baseProps.onSamplingReject).toHaveBeenCalledTimes(1);
+ });
+
+ it("resolves a request only once when the action is double-clicked", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ const autoRespond = screen.getByRole("button", { name: "Auto-respond" });
+ await user.click(autoRespond);
+ // After the first dispatch the actions lock (busy); a second click no-ops.
+ await user.click(autoRespond);
+ expect(baseProps.onSamplingRespond).toHaveBeenCalledTimes(1);
+ expect(
+ screen.getByRole("button", { name: "Send Response" }),
+ ).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled();
+ });
+
+ it("renders the elicitation form with the server name warning", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText("Elicitation Request")).toBeInTheDocument();
+ expect(screen.getByText(/Please provide your name/)).toBeInTheDocument();
+ expect(screen.getByText(/Everything Server/)).toBeInTheDocument();
+ });
+
+ it("accepts a form elicitation on submit", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
+ action: "accept",
+ content: {},
+ });
+ });
+
+ it("includes untouched schema defaults in the submitted content", async () => {
+ const user = userEvent.setup();
+ const formWithDefaults: PendingClientRequestContent = {
+ kind: "elicitation-form",
+ id: "elicitation-defaults",
+ request: {
+ message: "Confirm your preferences.",
+ requestedSchema: {
+ type: "object",
+ properties: {
+ firstLine: {
+ type: "string",
+ title: "First line",
+ default: "It was a dark and stormy night.",
+ },
+ integer: { type: "integer", title: "Integer", default: 42 },
+ name: { type: "string", title: "Name" },
+ },
+ },
+ },
+ };
+ renderWithMantine(
+ ,
+ );
+ // Submit without touching any field: the default-only fields must still be
+ // sent (the v1-parity bug this guards against dropped them).
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
+ action: "accept",
+ content: {
+ firstLine: "It was a dark and stormy night.",
+ integer: 42,
+ },
+ });
+ });
+
+ it("declines a form elicitation", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Decline" }));
+ expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
+ action: "decline",
+ });
+ });
+
+ it("cancels a form elicitation", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
+ action: "cancel",
+ });
+ });
+
+ it("opens the URL and accepts a URL elicitation", async () => {
+ const user = userEvent.setup();
+ const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Open in Browser" }));
+ expect(openSpy).toHaveBeenCalledWith(
+ "https://example.com/authorize",
+ "_blank",
+ "noopener,noreferrer",
+ );
+ expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
+ action: "accept",
+ });
+ openSpy.mockRestore();
+ });
+
+ it("copies the URL to the clipboard", async () => {
+ const user = userEvent.setup();
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ });
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Copy URL" }));
+ expect(writeText).toHaveBeenCalledWith("https://example.com/authorize");
+ });
+
+ it("cancels a URL elicitation", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
+ action: "cancel",
+ });
+ });
+});
diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx
new file mode 100644
index 00000000..8e128672
--- /dev/null
+++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx
@@ -0,0 +1,259 @@
+import { useRef, useState } from "react";
+import { Group, Modal, Text } from "@mantine/core";
+import type {
+ CreateMessageRequestParams,
+ CreateMessageResult,
+ ElicitRequestFormParams,
+ ElicitResult,
+} from "@modelcontextprotocol/sdk/types.js";
+import { SamplingRequestPanel } from "../SamplingRequestPanel/SamplingRequestPanel";
+import { ElicitationFormPanel } from "../ElicitationFormPanel/ElicitationFormPanel";
+import { ElicitationUrlPanel } from "../ElicitationUrlPanel/ElicitationUrlPanel";
+import {
+ collectSchemaDefaults,
+ type JsonSchemaType,
+} from "../../../utils/jsonUtils";
+
+/**
+ * The server-initiated request currently shown in the modal. `id` is the
+ * client-side request id; it keys the modal body so per-request draft state
+ * resets when the active request changes.
+ */
+export type PendingClientRequestContent =
+ | { kind: "sampling"; id: string; request: CreateMessageRequestParams }
+ | { kind: "elicitation-form"; id: string; request: ElicitRequestFormParams }
+ | { kind: "elicitation-url"; id: string; message: string; url: string };
+
+export interface PendingClientRequestModalProps {
+ /** The active request to display, or null when nothing is pending. */
+ request: PendingClientRequestContent | null;
+ /** Display name of the connected server (shown in elicitation warnings). */
+ serverName: string;
+ /**
+ * Count label for the still-pending requests, e.g. "3 pending". The modal
+ * always shows the head of the queue, so this is a remaining-count hint, not
+ * a navigable position. Empty string hides the label (nothing else queued).
+ */
+ queuePosition: string;
+ /** Resolve a sampling request with the given (drafted or auto) result. */
+ onSamplingRespond: (result: CreateMessageResult) => void;
+ /** Reject the active sampling request. */
+ onSamplingReject: () => void;
+ /** Resolve an elicitation request (accept/decline/cancel). */
+ onElicitationRespond: (result: ElicitResult) => void;
+}
+
+const TitleRow = Group.withProps({
+ justify: "space-between",
+ gap: "md",
+ w: "100%",
+ wrap: "nowrap",
+});
+
+const TitleText = Text.withProps({ fw: 600 });
+
+const QueueLabel = Text.withProps({ size: "xs", c: "dimmed" });
+
+/**
+ * The stub result pre-filled into a sampling draft. "Auto-respond" sends this
+ * as-is; "Send Response" sends whatever the user edited it into.
+ */
+function createDefaultSamplingResult(): CreateMessageResult {
+ return {
+ model: "stub-model",
+ stopReason: "endTurn",
+ role: "assistant",
+ content: { type: "text", text: "" },
+ };
+}
+
+function titleFor(content: PendingClientRequestContent): string {
+ return content.kind === "sampling"
+ ? "Sampling Request"
+ : "Elicitation Request";
+}
+
+// Empty handler — the modal is intentionally non-dismissable; the request is
+// only resolved via an explicit action button so the blocked call never hangs
+// on an accidental dismissal.
+function ignoreClose(): void {}
+
+/**
+ * Returns a `responded` flag and a `once()` wrapper. The first invocation of
+ * any wrapped callback flips `responded` (to lock the panel's actions) and
+ * runs it; later invocations no-op. The ref makes the guard synchronous so a
+ * fast double-click can't resolve the request twice before the modal unmounts
+ * — respond()/reject() throw if called again. Reset per request via the body's
+ * `key`.
+ */
+function useRespondOnce(): {
+ responded: boolean;
+ once: (fn: () => void) => () => void;
+} {
+ const [responded, setResponded] = useState(false);
+ const respondedRef = useRef(false);
+ const once = (fn: () => void) => () => {
+ if (respondedRef.current) return;
+ respondedRef.current = true;
+ setResponded(true);
+ fn();
+ };
+ return { responded, once };
+}
+
+function SamplingModalBody({
+ request,
+ onRespond,
+ onReject,
+}: {
+ request: CreateMessageRequestParams;
+ onRespond: (result: CreateMessageResult) => void;
+ onReject: () => void;
+}) {
+ const [draftResult, setDraftResult] = useState(
+ createDefaultSamplingResult,
+ );
+ const { responded, once } = useRespondOnce();
+ return (
+ onRespond(createDefaultSamplingResult()))}
+ onSend={once(() => onRespond(draftResult))}
+ onReject={once(onReject)}
+ busy={responded}
+ />
+ );
+}
+
+function ElicitationFormModalBody({
+ request,
+ serverName,
+ onRespond,
+}: {
+ request: ElicitRequestFormParams;
+ serverName: string;
+ onRespond: (result: ElicitResult) => void;
+}) {
+ // Seed with the schema's defaults so default-only fields the user never
+ // touches are still included on submit (the form shows them via
+ // resolveValue, but onChange only writes edited fields). The modal body is
+ // keyed by request id, so this lazy initializer re-runs per request.
+ const [values, setValues] = useState>(() =>
+ collectSchemaDefaults(request.requestedSchema as JsonSchemaType),
+ );
+ const { responded, once } = useRespondOnce();
+ return (
+
+ onRespond({
+ action: "accept",
+ content: values as ElicitResult["content"],
+ }),
+ )}
+ onDecline={once(() => onRespond({ action: "decline" }))}
+ onCancel={once(() => onRespond({ action: "cancel" }))}
+ busy={responded}
+ />
+ );
+}
+
+function ElicitationUrlModalBody({
+ id,
+ message,
+ url,
+ onRespond,
+}: {
+ id: string;
+ message: string;
+ url: string;
+ onRespond: (result: ElicitResult) => void;
+}) {
+ const { responded, once } = useRespondOnce();
+ return (
+ {
+ void navigator.clipboard?.writeText(url);
+ }}
+ onOpenInBrowser={once(() => {
+ window.open(url, "_blank", "noopener,noreferrer");
+ // Accept-on-open: the inspector can't observe completion of an external
+ // flow, so opening the URL is treated as acceptance. This is optimistic
+ // — the user could close the tab without finishing. A proper two-step
+ // "open, then confirm completion" flow (using ElicitationUrlPanel's
+ // isWaiting state) is tracked as a follow-up; see #1415.
+ onRespond({ action: "accept" });
+ })}
+ onCancel={once(() => onRespond({ action: "cancel" }))}
+ busy={responded}
+ />
+ );
+}
+
+/**
+ * App-level modal that surfaces a server-initiated sampling/elicitation request
+ * while a call (e.g. a tool execution) is in flight. Responding resolves the
+ * client's handler Promise, which unblocks the originating call.
+ */
+export function PendingClientRequestModal({
+ request,
+ serverName,
+ queuePosition,
+ onSamplingRespond,
+ onSamplingReject,
+ onElicitationRespond,
+}: PendingClientRequestModalProps) {
+ return (
+
+ {titleFor(request)}
+ {queuePosition && {queuePosition}}
+
+ )
+ }
+ >
+ {request?.kind === "sampling" && (
+
+ )}
+ {request?.kind === "elicitation-form" && (
+
+ )}
+ {request?.kind === "elicitation-url" && (
+
+ )}
+
+ );
+}
diff --git a/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx b/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx
index c403270f..28d75d18 100644
--- a/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx
+++ b/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx
@@ -24,6 +24,11 @@ export interface SamplingRequestPanelProps {
onAutoRespond: () => void;
onSend: () => void;
onReject: () => void;
+ /**
+ * A response has been dispatched; lock the actions so a second click can't
+ * resolve the request twice (the underlying handler throws if called again).
+ */
+ busy?: boolean;
}
function formatPriority(value: number): string {
@@ -62,6 +67,7 @@ export function SamplingRequestPanel({
onAutoRespond,
onSend,
onReject,
+ busy = false,
}: SamplingRequestPanelProps) {
const {
messages,
@@ -173,11 +179,15 @@ export function SamplingRequestPanel({
/>
-
+
Auto-respond
- Reject
- Send Response
+
+ Reject
+
+
+ Send Response
+
);
diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx
index cb8afacd..ca50cddf 100644
--- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx
+++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx
@@ -190,6 +190,66 @@ describe("SchemaForm", () => {
expect(onChange).toHaveBeenCalledWith({ tags: ["a"] });
});
+ it("renders a MultiSelect for an array of enum items and invokes onChange when an option is selected", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const schema: JsonSchemaType = {
+ type: "object",
+ properties: {
+ instruments: {
+ type: "array",
+ description: "Choose your favorite instruments",
+ items: {
+ type: "string",
+ enum: ["Guitar", "Piano", "Drums"],
+ },
+ },
+ },
+ };
+ renderWithMantine(
+ ,
+ );
+ // Falls back to the field name as label when no title is supplied.
+ await user.click(screen.getByRole("textbox", { name: "instruments" }));
+ const option = await screen.findByRole("option", {
+ name: "Guitar",
+ hidden: true,
+ });
+ await user.click(option);
+ expect(onChange).toHaveBeenCalledWith({ instruments: ["Guitar"] });
+ });
+
+ it("uses enumNames for enum-array option labels and the raw value on change", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const schema: JsonSchemaType = {
+ type: "object",
+ properties: {
+ sizes: {
+ type: "array",
+ title: "Sizes",
+ items: {
+ type: "string",
+ enum: ["s", "m", "l"],
+ enumNames: ["Small", "Medium", "Large"],
+ },
+ },
+ },
+ };
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("textbox", { name: "Sizes" }));
+ // The option shows the enumNames label...
+ const option = await screen.findByRole("option", {
+ name: "Medium",
+ hidden: true,
+ });
+ await user.click(option);
+ // ...but the value persisted is the raw enum value.
+ expect(onChange).toHaveBeenCalledWith({ sizes: ["m"] });
+ });
+
it("renders nested object fields recursively", () => {
const onChange = vi.fn();
const schema: JsonSchemaType = {
diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx
index 83d46df1..458619d5 100644
--- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx
+++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx
@@ -155,6 +155,31 @@ export function SchemaForm({
);
}
+ // array of enum values (multi-select)
+ if (fieldSchema.type === "array" && fieldSchema.items?.enum) {
+ const itemEnum = fieldSchema.items.enum;
+ const itemEnumNames = fieldSchema.items.enumNames;
+ const data =
+ itemEnumNames && itemEnumNames.length === itemEnum.length
+ ? itemEnum.map((value, index) => ({
+ value,
+ label: itemEnumNames[index],
+ }))
+ : itemEnum;
+ return (
+ handleFieldChange(fieldName, val)}
+ />
+ );
+ }
+
// array with items having anyOf
if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) {
const data = fieldSchema.items.anyOf.map((item) => ({
diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx
index a3f5be71..9e96d1c7 100644
--- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx
+++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx
@@ -1,4 +1,12 @@
-import { Button, Divider, Group, Image, Stack, Text } from "@mantine/core";
+import {
+ Button,
+ Divider,
+ Group,
+ Image,
+ ScrollArea,
+ Stack,
+ Text,
+} from "@mantine/core";
import type {
ProgressNotification,
Tool,
@@ -24,6 +32,40 @@ export interface ToolDetailPanelProps {
onCancel: () => void;
}
+// Outer column: title/annotations pin at top, the Execute footer pins at the
+// bottom, and the middle (description + form + progress) scrolls when the
+// enclosing card hits its `mah`. `mih: 0` lets the flex children shrink.
+const PanelStack = Stack.withProps({
+ gap: "md",
+ miw: 0,
+ mih: 0,
+});
+
+const PinnedHeader = Stack.withProps({
+ gap: "md",
+ flex: "0 0 auto",
+});
+
+// `0 1 auto` + `mih: 0`: shrinks to the available space and scrolls; a short
+// form doesn't reserve extra height, keeping Execute snug below it.
+const BodyScroll = ScrollArea.withProps({
+ flex: "0 1 auto",
+ miw: 0,
+ mih: 0,
+ type: "auto",
+ scrollbars: "y",
+ offsetScrollbars: true,
+});
+
+const BodyStack = Stack.withProps({
+ gap: "md",
+});
+
+const FooterRow = Group.withProps({
+ justify: "flex-end",
+ flex: "0 0 auto",
+});
+
const TitleRow = Group.withProps({
gap: "sm",
wrap: "nowrap",
@@ -76,42 +118,48 @@ export function ToolDetailPanel({
const iconSrc = icons?.[0]?.src;
return (
-
-
- {iconSrc && }
- {resolveDisplayLabel(name, title)}
-
- {hasAnyAnnotation(annotations) && annotations && (
-
- {annotations.readOnlyHint && (
-
- )}
- {annotations.destructiveHint && (
-
- )}
- {annotations.idempotentHint && (
-
- )}
- {annotations.openWorldHint && (
-
- )}
-
- )}
+
+
+
+ {iconSrc && }
+ {resolveDisplayLabel(name, title)}
+
+ {hasAnyAnnotation(annotations) && annotations && (
+
+ {annotations.readOnlyHint && (
+
+ )}
+ {annotations.destructiveHint && (
+
+ )}
+ {annotations.idempotentHint && (
+
+ )}
+ {annotations.openWorldHint && (
+
+ )}
+
+ )}
+
- {description && {description}}
+
+
+ {description && {description}}
-
+
-
+
- {progress && }
+ {progress && }
+
+
-
+
{isExecuting && Cancel}
Execute Tool
-
-
+
+
);
}
diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
index f7a0f520..a45d72cc 100644
--- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
+++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
@@ -1,4 +1,12 @@
-import { Alert, Button, Group, Stack, Text, Title } from "@mantine/core";
+import {
+ Alert,
+ Button,
+ Group,
+ ScrollArea,
+ Stack,
+ Text,
+ Title,
+} from "@mantine/core";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
@@ -12,33 +20,64 @@ const ClearButton = Button.withProps({
size: "sm",
});
+// Outer column: the header pins (`flex: 0 0 auto`) and the scroll region
+// absorbs overflow when the enclosing card hits its `mah`. `mih: 0` lets the
+// flex children shrink below their content's intrinsic height.
+const PanelStack = Stack.withProps({
+ gap: "md",
+ miw: 0,
+ mih: 0,
+});
+
+const HeaderRow = Group.withProps({
+ justify: "space-between",
+ flex: "0 0 auto",
+});
+
+// `0 1 auto` + `mih: 0` lets the scroll region shrink (never grow) so a short
+// result doesn't reserve height while a long one scrolls within the card cap.
+const ResultScroll = ScrollArea.withProps({
+ flex: "0 1 auto",
+ miw: 0,
+ mih: 0,
+ type: "auto",
+ scrollbars: "y",
+ offsetScrollbars: true,
+});
+
+const ResultStack = Stack.withProps({
+ gap: "md",
+});
+
export function ToolResultPanel({ result, onClear }: ToolResultPanelProps) {
return (
-
-
+
+
Results
Clear
-
- {result.isError ? (
-
- {result.content
- .filter((b) => b.type === "text")
- .map((b) => b.text)
- .join("\n")}
-
- ) : result.content.length === 0 ? (
- No results yet
- ) : (
- <>
- {result.content.map((block, index) => (
-
- ))}
- >
- )}
-
+
+
+
+ {result.isError ? (
+
+ {result.content
+ .filter((b) => b.type === "text")
+ .map((b) => b.text)
+ .join("\n")}
+
+ ) : result.content.length === 0 ? (
+ No results yet
+ ) : (
+ result.content.map((block, index) => (
+
+ ))
+ )}
+
+
+
);
}
diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
index f6190340..60e09ae8 100644
--- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
+++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
@@ -40,7 +40,7 @@ const cohortApp: Tool = {
description: "Cohort retention",
inputSchema: {
type: "object",
- properties: { metric: { type: "string" } },
+ properties: { metric: { type: "string", default: "retention" } },
},
_meta: { ui: { resourceUri: "ui://apps/cohorts" } },
};
@@ -170,6 +170,16 @@ describe("AppsScreen", () => {
expect(screen.getByTitle("Weather Widget")).toBeInTheDocument();
});
+ it("seeds schema defaults so untouched fields are sent on Open App", async () => {
+ const user = userEvent.setup();
+ const onOpenApp = vi.fn();
+ renderWithMantine();
+ await user.click(screen.getByText("Cohort Data"));
+ // Open without editing the metric field: its default must still be sent.
+ await user.click(screen.getByRole("button", { name: /Open App/ }));
+ expect(onOpenApp).toHaveBeenCalledWith("cohorts", { metric: "retention" });
+ });
+
it("returns to the input form when 'Back to Input' is clicked", 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 ca4ededd..fdcab3dc 100644
--- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
+++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
@@ -25,6 +25,7 @@ import {
import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel";
import { AppControls } from "../../groups/AppControls/AppControls";
import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils";
+import { collectSchemaDefaults } from "../../../utils/jsonUtils";
export interface AppsScreenProps {
tools: Tool[];
@@ -64,9 +65,13 @@ const SidebarCard = Card.withProps({
padding: "lg",
});
+// `variant="preview"` (overflow: hidden) keeps the full-height card from
+// bleeding past the viewport: the running app's iframe fills it, and the
+// app-input form scrolls internally (see AppDetailPanel's PanelScroll).
const ContentCard = Card.withProps({
withBorder: true,
padding: "lg",
+ variant: "preview",
});
const EmptyState = Text.withProps({
@@ -149,7 +154,9 @@ export function AppsScreen({
const next = tools.find((t) => t.name === name);
if (!next) return;
setSelectedAppName(name);
- setFormValues({});
+ // Seed schema defaults so default-only fields are sent on Open App (parity
+ // with the form's resolveValue display, which onChange doesn't capture).
+ setFormValues(collectSchemaDefaults(next.inputSchema));
setMaximized(false);
onSelectApp(name);
// No-input apps auto-launch on selection so the user lands directly in
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx
index 8a2359e6..fd27224e 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx
@@ -248,6 +248,7 @@ export const WithElicitationModal: Story = {
values={{}}
onChange={fn()}
onSubmit={fn()}
+ onDecline={fn()}
onCancel={fn()}
/>
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx
index 39151c49..2372c6da 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx
@@ -7,6 +7,13 @@ import { ToolsScreen } from "./ToolsScreen";
const tools: Tool[] = [
{ name: "alpha", inputSchema: { type: "object" } },
{ name: "beta", inputSchema: { type: "object" } },
+ {
+ name: "gamma",
+ inputSchema: {
+ type: "object",
+ properties: { mode: { type: "string", default: "fast" } },
+ },
+ },
];
const baseProps = {
@@ -56,6 +63,16 @@ describe("ToolsScreen", () => {
expect(onCallTool).toHaveBeenCalledWith("alpha", {});
});
+ it("seeds schema defaults so untouched fields are sent on Execute", async () => {
+ const user = userEvent.setup();
+ const onCallTool = vi.fn();
+ renderWithMantine();
+ await user.click(screen.getByText("gamma"));
+ // Execute without editing the form: the default must still be sent.
+ await user.click(screen.getByRole("button", { name: /Execute/ }));
+ expect(onCallTool).toHaveBeenCalledWith("gamma", { mode: "fast" });
+ });
+
it("invokes onClearResult when Clear is clicked on the result panel", async () => {
const user = userEvent.setup();
const onClearResult = vi.fn();
@@ -73,6 +90,23 @@ describe("ToolsScreen", () => {
expect(onClearResult).toHaveBeenCalledTimes(1);
});
+ it("clears the persisted result when the screen unmounts", () => {
+ const onClearResult = vi.fn();
+ const { unmount } = renderWithMantine(
+ ,
+ );
+ expect(onClearResult).not.toHaveBeenCalled();
+ unmount();
+ expect(onClearResult).toHaveBeenCalledTimes(1);
+ });
+
it("treats pending callState as executing", async () => {
const user = userEvent.setup();
renderWithMantine(
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
index 875be843..e0dc82e6 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { Card, Flex, Stack, Text } from "@mantine/core";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
import { ToolControls } from "../../groups/ToolControls/ToolControls";
@@ -7,6 +7,7 @@ import {
type ToolProgress,
} from "../../groups/ToolDetailPanel/ToolDetailPanel";
import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel";
+import { collectSchemaDefaults } from "../../../utils/jsonUtils";
export interface ToolCallState {
status: "idle" | "pending" | "ok" | "error";
@@ -25,12 +26,21 @@ export interface ToolsScreenProps {
onClearResult?: () => void;
}
+// Caps the detail/result columns at the screen's available height: full
+// viewport minus the app-shell header and the screen's top+bottom xl padding,
+// leaving the bottom margin the overflow used to eat.
+const SCROLL_MAX_HEIGHT =
+ "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
+
+// No `align` override: children stretch to the row's full height, giving each
+// column pane a definite height. That definite height is what lets a column's
+// inner ScrollArea know how much it can shrink into (a bare `mah` doesn't —
+// see the Prompts/Resources preview panes this mirrors).
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
- align: "flex-start",
});
const Sidebar = Stack.withProps({
@@ -43,9 +53,22 @@ const SidebarCard = Card.withProps({
padding: "lg",
});
+// Column wrapper: stretches to the screen's available height (capped by the
+// consumer-set `mah`) so the card inside has a definite height to shrink into.
+const ContentPane = Flex.withProps({
+ flex: 1,
+ miw: 0,
+ direction: "column",
+ align: "stretch",
+});
+
+// Detail/result column card: `variant="preview"` (overflow: hidden) lets the
+// panel's inner ScrollArea take over scrolling instead of the card bleeding
+// past the viewport. Sizes to content when short, caps at the pane when tall.
const ContentCard = Card.withProps({
withBorder: true,
padding: "lg",
+ variant: "preview",
});
const EmptyState = Text.withProps({
@@ -72,6 +95,22 @@ export function ToolsScreen({
: undefined;
const isExecuting = callState?.status === "pending";
+ // The result lives in App so it survives pending → ok/error without
+ // remounting the screen. But the screen's selection is local and resets when
+ // the screen unmounts on tab switch — leaving the Results panel showing a
+ // result with no selected tool. Clear the result on unmount so returning to
+ // the screen starts from a clean slate. A ref keeps the latest handler so the
+ // effect can stay mount/unmount-only without re-running mid-session.
+ const onClearResultRef = useRef(onClearResult);
+ useEffect(() => {
+ onClearResultRef.current = onClearResult;
+ }, [onClearResult]);
+ useEffect(() => {
+ return () => {
+ onClearResultRef.current?.();
+ };
+ }, []);
+
return (
@@ -82,39 +121,50 @@ export function ToolsScreen({
listChanged={listChanged}
onRefreshList={onRefreshList}
onSelectTool={(name) => {
- setFormValues({});
+ // Seed the form with the tool's schema defaults so default-only
+ // fields the user never edits are still sent on execute (the
+ // form shows defaults via resolveValue, but onChange only writes
+ // edited fields).
+ const tool = tools.find((t) => t.name === name);
+ setFormValues(
+ tool ? collectSchemaDefaults(tool.inputSchema) : {},
+ );
setSelectedToolName(name);
}}
/>
-
- {selectedTool ? (
- onCallTool(selectedTool.name, formValues)}
- onCancel={() => onCancelCall?.()}
- />
- ) : (
- Select a tool to view details
- )}
-
+
+
+ {selectedTool ? (
+ onCallTool(selectedTool.name, formValues)}
+ onCancel={() => onCancelCall?.()}
+ />
+ ) : (
+ Select a tool to view details
+ )}
+
+
-
- {callState?.result ? (
- onClearResult?.()}
- />
- ) : (
- Results will appear here
- )}
-
+
+
+ {callState?.result ? (
+ onClearResult?.()}
+ />
+ ) : (
+ Results will appear here
+ )}
+
+
);
}
diff --git a/clients/web/src/test/core/react/usePendingClientRequests.test.tsx b/clients/web/src/test/core/react/usePendingClientRequests.test.tsx
new file mode 100644
index 00000000..045523df
--- /dev/null
+++ b/clients/web/src/test/core/react/usePendingClientRequests.test.tsx
@@ -0,0 +1,135 @@
+import { describe, it, expect } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
+import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests";
+import { SamplingCreateMessage } from "@inspector/core/mcp/samplingCreateMessage";
+import { ElicitationCreateMessage } from "@inspector/core/mcp/elicitationCreateMessage";
+import type { InspectorClientProtocol } from "@inspector/core/mcp/inspectorClientProtocol";
+
+const noop = (): void => {};
+
+function makeSample(): SamplingCreateMessage {
+ return new SamplingCreateMessage(
+ {
+ method: "sampling/createMessage",
+ params: { messages: [], maxTokens: 100 },
+ },
+ noop,
+ noop,
+ noop,
+ );
+}
+
+function makeElicitation(): ElicitationCreateMessage {
+ return new ElicitationCreateMessage(
+ {
+ method: "elicitation/create",
+ params: {
+ message: "Provide details",
+ requestedSchema: { type: "object", properties: {} },
+ },
+ },
+ noop,
+ noop,
+ );
+}
+
+describe("usePendingClientRequests", () => {
+ it("returns empty arrays when the client is null", () => {
+ const { result } = renderHook(() => usePendingClientRequests(null));
+ expect(result.current.pendingSamples).toEqual([]);
+ expect(result.current.pendingElicitations).toEqual([]);
+ });
+
+ it("returns the client's initial pending requests", () => {
+ const client = new FakeInspectorClient();
+ const sample = makeSample();
+ const elicitation = makeElicitation();
+ client.setPendingSamples([sample]);
+ client.setPendingElicitations([elicitation]);
+
+ const { result } = renderHook(() => usePendingClientRequests(client));
+ expect(result.current.pendingSamples).toEqual([sample]);
+ expect(result.current.pendingElicitations).toEqual([elicitation]);
+ });
+
+ it("subscribes to pendingSamplesChange", () => {
+ const client = new FakeInspectorClient();
+ const { result } = renderHook(() => usePendingClientRequests(client));
+ expect(result.current.pendingSamples).toEqual([]);
+
+ const sample = makeSample();
+ act(() => {
+ client.setPendingSamples([sample]);
+ });
+ expect(result.current.pendingSamples).toEqual([sample]);
+
+ act(() => {
+ client.setPendingSamples([]);
+ });
+ expect(result.current.pendingSamples).toEqual([]);
+ });
+
+ it("subscribes to pendingElicitationsChange", () => {
+ const client = new FakeInspectorClient();
+ const { result } = renderHook(() => usePendingClientRequests(client));
+ expect(result.current.pendingElicitations).toEqual([]);
+
+ const elicitation = makeElicitation();
+ act(() => {
+ client.setPendingElicitations([elicitation]);
+ });
+ expect(result.current.pendingElicitations).toEqual([elicitation]);
+ });
+
+ it("resets to empty when the client becomes null", () => {
+ const client = new FakeInspectorClient();
+ client.setPendingSamples([makeSample()]);
+ const { result, rerender } = renderHook(
+ ({ c }: { c: InspectorClientProtocol | null }) =>
+ usePendingClientRequests(c),
+ { initialProps: { c: client as InspectorClientProtocol | null } },
+ );
+ expect(result.current.pendingSamples).toHaveLength(1);
+
+ rerender({ c: null });
+ expect(result.current.pendingSamples).toEqual([]);
+ expect(result.current.pendingElicitations).toEqual([]);
+ });
+
+ it("re-subscribes when the client prop changes", () => {
+ const a = new FakeInspectorClient();
+ const b = new FakeInspectorClient();
+ const { result, rerender } = renderHook(
+ ({ c }: { c: InspectorClientProtocol }) => usePendingClientRequests(c),
+ { initialProps: { c: a as InspectorClientProtocol } },
+ );
+
+ rerender({ c: b });
+
+ // Events on the OLD client are ignored.
+ act(() => {
+ a.setPendingSamples([makeSample()]);
+ });
+ expect(result.current.pendingSamples).toEqual([]);
+
+ // Events on the NEW client are observed.
+ const sample = makeSample();
+ act(() => {
+ b.setPendingSamples([sample]);
+ });
+ expect(result.current.pendingSamples).toEqual([sample]);
+ });
+
+ it("unsubscribes on unmount", () => {
+ const client = new FakeInspectorClient();
+ const { result, unmount } = renderHook(() =>
+ usePendingClientRequests(client),
+ );
+ unmount();
+ act(() => {
+ client.setPendingSamples([makeSample()]);
+ });
+ expect(result.current.pendingSamples).toEqual([]);
+ });
+});
diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts
index b4e34f2a..2a160a55 100644
--- a/clients/web/src/test/integration/mcp/remote/transport.test.ts
+++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts
@@ -650,9 +650,16 @@ describe("Remote transport e2e", () => {
it("writes InspectorClient logs to file via createRemoteLogger over remote transport", async () => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-log-test-"));
const logPath = join(tempDir!, "remote.log");
+ // sync: true so writes land before the afterEach rmSync, avoiding a
+ // background sonic-boom flush into a removed temp dir (uncaught ENOENT).
const fileLogger = pino(
{ level: "info" },
- pino.destination({ dest: logPath, append: true, mkdir: true }),
+ pino.destination({
+ dest: logPath,
+ append: true,
+ mkdir: true,
+ sync: true,
+ }),
);
mcpHttpServer = createTestServerHttp({
@@ -1401,7 +1408,14 @@ describe("Remote transport e2e", () => {
const tmp = mkdtempSync(join(tmpdir(), "inspector-log-unknown-level-"));
const logFile = join(tmp, "app.log");
try {
- const logger = pino({ level: "info" }, pino.destination(logFile));
+ // sync: true so the file write completes during the request rather
+ // than on a background sonic-boom flush — otherwise the flush can fire
+ // after this test's `finally` rmSync removes the dir, throwing an
+ // uncaught ENOENT that fails an unrelated later test.
+ const logger = pino(
+ { level: "info" },
+ pino.destination({ dest: logFile, sync: true }),
+ );
const { baseUrl, server, authToken } = await startRemoteServer(0, {
logger,
});
@@ -1427,7 +1441,14 @@ describe("Remote transport e2e", () => {
const tmp = mkdtempSync(join(tmpdir(), "inspector-log-no-messages-"));
const logFile = join(tmp, "app.log");
try {
- const logger = pino({ level: "info" }, pino.destination(logFile));
+ // sync: true so the file write completes during the request rather
+ // than on a background sonic-boom flush — otherwise the flush can fire
+ // after this test's `finally` rmSync removes the dir, throwing an
+ // uncaught ENOENT that fails an unrelated later test.
+ const logger = pino(
+ { level: "info" },
+ pino.destination({ dest: logFile, sync: true }),
+ );
const { baseUrl, server, authToken } = await startRemoteServer(0, {
logger,
});
@@ -1458,7 +1479,14 @@ describe("Remote transport e2e", () => {
const tmp = mkdtempSync(join(tmpdir(), "inspector-log-string-first-"));
const logFile = join(tmp, "app.log");
try {
- const logger = pino({ level: "info" }, pino.destination(logFile));
+ // sync: true so the file write completes during the request rather
+ // than on a background sonic-boom flush — otherwise the flush can fire
+ // after this test's `finally` rmSync removes the dir, throwing an
+ // uncaught ENOENT that fails an unrelated later test.
+ const logger = pino(
+ { level: "info" },
+ pino.destination({ dest: logFile, sync: true }),
+ );
const { baseUrl, server, authToken } = await startRemoteServer(0, {
logger,
});
diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts
index 74df0d30..c127cc9f 100644
--- a/clients/web/src/utils/jsonUtils.test.ts
+++ b/clients/web/src/utils/jsonUtils.test.ts
@@ -4,7 +4,10 @@ import {
tryParseJson,
updateValueAtPath,
getValueAtPath,
+ collectSchemaDefaults,
+ hasMissingRequiredFields,
} from "./jsonUtils";
+import type { JsonSchemaType } from "./jsonUtils";
describe("getDataType", () => {
it("returns 'array' for arrays", () => {
@@ -166,3 +169,82 @@ describe("updateValueAtPath edge case suppression", () => {
expect(console.error).toHaveBeenCalled();
});
});
+
+describe("collectSchemaDefaults", () => {
+ it("collects defaults across field types and omits fields without one", () => {
+ const schema: JsonSchemaType = {
+ type: "object",
+ properties: {
+ firstLine: {
+ type: "string",
+ default: "It was a dark and stormy night.",
+ },
+ integer: { type: "integer", default: 42 },
+ number: { type: "number", default: 3.14 },
+ choice: { type: "string", enum: ["a", "b"], default: "a" },
+ picks: { type: "array", items: { enum: ["x", "y"] }, default: ["x"] },
+ // No default — must be absent from the result, not undefined.
+ name: { type: "string", title: "Name" },
+ },
+ };
+ expect(collectSchemaDefaults(schema)).toEqual({
+ firstLine: "It was a dark and stormy night.",
+ integer: 42,
+ number: 3.14,
+ choice: "a",
+ picks: ["x"],
+ });
+ });
+
+ it("recurses into nested object schemas and skips empty ones", () => {
+ const schema: JsonSchemaType = {
+ type: "object",
+ properties: {
+ db: {
+ type: "object",
+ properties: {
+ host: { type: "string", default: "localhost" },
+ port: { type: "integer" },
+ },
+ },
+ empty: {
+ type: "object",
+ properties: { note: { type: "string" } },
+ },
+ },
+ };
+ expect(collectSchemaDefaults(schema)).toEqual({
+ db: { host: "localhost" },
+ });
+ });
+
+ it("returns an empty object when the schema has no properties", () => {
+ expect(collectSchemaDefaults({ type: "object" })).toEqual({});
+ });
+});
+
+describe("hasMissingRequiredFields", () => {
+ const schema: JsonSchemaType = {
+ type: "object",
+ properties: { name: { type: "string" }, age: { type: "integer" } },
+ required: ["name"],
+ };
+
+ it("is false when no fields are required", () => {
+ expect(hasMissingRequiredFields({ type: "object" }, {})).toBe(false);
+ });
+
+ it("is true when a required field is absent, null, or empty", () => {
+ expect(hasMissingRequiredFields(schema, {})).toBe(true);
+ expect(hasMissingRequiredFields(schema, { name: null })).toBe(true);
+ expect(hasMissingRequiredFields(schema, { name: "" })).toBe(true);
+ });
+
+ it("is false when every required field has a value", () => {
+ expect(hasMissingRequiredFields(schema, { name: "Ada" })).toBe(false);
+ // A non-required field being empty does not matter.
+ expect(hasMissingRequiredFields(schema, { name: "Ada", age: "" })).toBe(
+ false,
+ );
+ });
+});
diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts
index 27fc8247..67a4bfb3 100644
--- a/clients/web/src/utils/jsonUtils.ts
+++ b/clients/web/src/utils/jsonUtils.ts
@@ -81,6 +81,48 @@ export function getDataType(value: JsonValue): DataType {
return typeof value;
}
+/**
+ * Collect a schema's default field values into a values object. A schema form
+ * displays defaults but only writes a field into its `values` once the user
+ * edits it, so an untouched default would otherwise be absent from a
+ * submission. Seeding form state with this keeps default-only fields in the
+ * submitted result (parity with v1). Recurses into nested object schemas and
+ * omits fields that have no default.
+ */
+export function collectSchemaDefaults(
+ schema: JsonSchemaType,
+): Record {
+ const properties = schema.properties ?? {};
+ const result: Record = {};
+ for (const [fieldName, fieldSchema] of Object.entries(properties)) {
+ if (fieldSchema.default !== undefined) {
+ result[fieldName] = fieldSchema.default;
+ } else if (fieldSchema.type === "object" && fieldSchema.properties) {
+ const nested = collectSchemaDefaults(fieldSchema);
+ if (Object.keys(nested).length > 0) {
+ result[fieldName] = nested;
+ }
+ }
+ }
+ return result;
+}
+
+/**
+ * Whether any of the schema's required top-level fields is missing a value in
+ * `values` (absent, null, or empty string). Used to gate a form's submit
+ * action until required fields are supplied.
+ */
+export function hasMissingRequiredFields(
+ schema: JsonSchemaType,
+ values: Record,
+): boolean {
+ const required = schema.required ?? [];
+ return required.some((field) => {
+ const value = values[field];
+ return value === undefined || value === null || value === "";
+ });
+}
+
/**
* Attempts to parse a string as JSON, only for objects and arrays
* @param str The string to parse
diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts
index 17647648..230eda4b 100644
--- a/core/mcp/__tests__/fakeInspectorClient.ts
+++ b/core/mcp/__tests__/fakeInspectorClient.ts
@@ -22,6 +22,8 @@ import type {
AppRendererClient,
InspectorClientProtocol,
} from "../inspectorClientProtocol.js";
+import type { SamplingCreateMessage } from "../samplingCreateMessage.js";
+import type { ElicitationCreateMessage } from "../elicitationCreateMessage.js";
import type {
ConnectionStatus,
PromptGetInvocation,
@@ -54,6 +56,8 @@ export class FakeInspectorClient
private instructions: string | undefined;
private appRendererClient: AppRendererClient | null = null;
private sessionId: string | undefined;
+ private pendingSamples: SamplingCreateMessage[] = [];
+ private pendingElicitations: ElicitationCreateMessage[] = [];
// Each paginated method pulls from a queue of pre-canned pages. Tests push
// pages with `queueToolPages(...)` etc. so they can assert pagination
@@ -72,8 +76,7 @@ export class FakeInspectorClient
async () => this.resourcePages.shift() ?? { resources: [] },
);
listResourceTemplates = vi.fn(
- async () =>
- this.resourceTemplatePages.shift() ?? { resourceTemplates: [] },
+ async () => this.resourceTemplatePages.shift() ?? { resourceTemplates: [] },
);
listRequestorTasks = vi.fn(
async () => this.taskPages.shift() ?? { tasks: [] },
@@ -185,8 +188,29 @@ export class FakeInspectorClient
return this.sessionId;
}
+ getPendingSamples(): SamplingCreateMessage[] {
+ return [...this.pendingSamples];
+ }
+
+ getPendingElicitations(): ElicitationCreateMessage[] {
+ return [...this.pendingElicitations];
+ }
+
// ---- test helpers ----
+ setPendingSamples(samples: SamplingCreateMessage[]): void {
+ this.pendingSamples = [...samples];
+ this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
+ }
+
+ setPendingElicitations(elicitations: ElicitationCreateMessage[]): void {
+ this.pendingElicitations = [...elicitations];
+ this.dispatchTypedEvent(
+ "pendingElicitationsChange",
+ this.pendingElicitations,
+ );
+ }
+
setStatus(status: ConnectionStatus): void {
this.status = status;
this.dispatchTypedEvent("statusChange", status);
@@ -227,9 +251,7 @@ export class FakeInspectorClient
this.promptPages.push(...pages);
}
- queueResourcePages(
- ...pages: Array>
- ): void {
+ queueResourcePages(...pages: Array>): void {
this.resourcePages.push(...pages);
}
diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts
index 0a1ce6c9..2b0ddaab 100644
--- a/core/mcp/inspectorClientProtocol.ts
+++ b/core/mcp/inspectorClientProtocol.ts
@@ -32,6 +32,8 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js";
+import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
+import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
/**
* Opaque type representing the AppRendererClient surface used by @mcp-ui.
@@ -105,6 +107,13 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
setLoggingLevel(level: LoggingLevel): Promise;
getSessionId(): string | undefined;
+ // Server-initiated request queues. Each entry exposes `respond()` / `reject()`
+ // (and `remove()`), which resolve the handler Promise that left the originating
+ // call (e.g. a tool execution) in flight. The `pendingSamplesChange` /
+ // `pendingElicitationsChange` events fire whenever these arrays mutate.
+ getPendingSamples(): SamplingCreateMessage[];
+ getPendingElicitations(): ElicitationCreateMessage[];
+
// Completions (resource templates / prompt arguments)
getCompletions(
ref:
diff --git a/core/react/usePendingClientRequests.ts b/core/react/usePendingClientRequests.ts
new file mode 100644
index 00000000..a3759f1a
--- /dev/null
+++ b/core/react/usePendingClientRequests.ts
@@ -0,0 +1,67 @@
+import { useState, useEffect } from "react";
+import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
+import type { SamplingCreateMessage } from "../mcp/samplingCreateMessage.js";
+import type { ElicitationCreateMessage } from "../mcp/elicitationCreateMessage.js";
+import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
+
+export interface UsePendingClientRequestsResult {
+ pendingSamples: SamplingCreateMessage[];
+ pendingElicitations: ElicitationCreateMessage[];
+}
+
+/**
+ * React hook that subscribes to the InspectorClient's server-initiated request
+ * queues and returns the live pending sampling / elicitation arrays.
+ *
+ * Each entry exposes `respond()` / `reject()`, which resolve the handler Promise
+ * the client returned for the originating call (e.g. a tool execution that
+ * triggered the request). Rendering these and wiring those callbacks is what
+ * lets a tool call that spawned a sampling/elicitation request complete.
+ */
+export function usePendingClientRequests(
+ inspectorClient: InspectorClientProtocol | null,
+): UsePendingClientRequestsResult {
+ const [pendingSamples, setPendingSamples] = useState(
+ inspectorClient?.getPendingSamples() ?? [],
+ );
+ const [pendingElicitations, setPendingElicitations] = useState<
+ ElicitationCreateMessage[]
+ >(inspectorClient?.getPendingElicitations() ?? []);
+
+ useEffect(() => {
+ if (!inspectorClient) {
+ setPendingSamples([]);
+ setPendingElicitations([]);
+ return;
+ }
+ setPendingSamples(inspectorClient.getPendingSamples());
+ setPendingElicitations(inspectorClient.getPendingElicitations());
+
+ const onSamplesChange = (event: TypedEvent<"pendingSamplesChange">) => {
+ setPendingSamples([...event.detail]);
+ };
+ const onElicitationsChange = (
+ event: TypedEvent<"pendingElicitationsChange">,
+ ) => {
+ setPendingElicitations([...event.detail]);
+ };
+
+ inspectorClient.addEventListener("pendingSamplesChange", onSamplesChange);
+ inspectorClient.addEventListener(
+ "pendingElicitationsChange",
+ onElicitationsChange,
+ );
+ return () => {
+ inspectorClient.removeEventListener(
+ "pendingSamplesChange",
+ onSamplesChange,
+ );
+ inspectorClient.removeEventListener(
+ "pendingElicitationsChange",
+ onElicitationsChange,
+ );
+ };
+ }, [inspectorClient]);
+
+ return { pendingSamples, pendingElicitations };
+}