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. */} + - - + + + ); } 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({ - @@ -78,7 +85,7 @@ export function ElicitationUrlPanel({ This will open an external URL. Verify the domain before proceeding. - 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({ /> - - Reject - + + Reject + + ); 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} - - + + ); } 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 }; +}