From 8f6a2e0f787d8f7767fd5f759e5d498b2b96f279 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 2 Jun 2026 17:52:20 -0400 Subject: [PATCH] feat(web): render pending sampling/elicitation requests (#1407) (#1411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): render pending sampling/elicitation requests (#1407) Server-initiated sampling and elicitation requests never reached the v2 web UI: the InspectorClient queued them and returned a Promise that only resolved on a user response, but nothing subscribed to the pending-change events or rendered the (already-built) request components. A tool that triggered one spun the Execute button forever. Wire the queues to the UI: - Add usePendingClientRequests() (core/react) subscribing to pendingSamplesChange / pendingElicitationsChange and returning the live arrays. Expose getPendingSamples() / getPendingElicitations() on InspectorClientProtocol (and the test fake) so hooks can read them. - Add PendingClientRequestModal, an App-level modal that surfaces one queued request at a time (sampling-first) via the existing SamplingRequestPanel / ElicitationFormPanel / ElicitationUrlPanel. It is non-dismissable so the blocked call only resolves via an explicit action. - Wire it in App.tsx: responding/rejecting resolves the client's handler Promise, which unblocks the originating call and clears the spinner. Also fold in two issues surfaced while verifying: - SchemaForm rendered an `array` + `items.enum` (multi-select enum) as a JSON textarea because only `items.anyOf` had a branch; add a MultiSelect branch with enumNames label support. - ToolsScreen's result lives in App and outlived the screen's local selection (reset on tab-switch unmount), leaving a result with no selected tool. Clear the result on unmount for a clean slate on return. Prompts/Resources gate their preview on selection, so they are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(elicitation): support explicit decline; honest queue label; review polish Address @claude review on #1411: - Add an explicit "Decline" action to ElicitationFormPanel so the spec's decline (refusal) is distinguishable from cancel (dismissal); the JSDoc claimed accept/decline/cancel but only accept/cancel were wired. Wire the modal body to emit { action: "decline" }. - Replace the misleading "1 of N" queue label (the leading 1 never changed since the modal always shows the head) with an "N pending" hint, hidden when nothing else is queued. - Make the dead useMemo honest: derive the head request inside the memo from the source arrays so it actually caches (the inline activeElicitation had a fresh identity every render). - Document the URL elicitation accept-on-open semantic with a comment pointing at the follow-up (#1415). - Add an App-level round-trip test driving pendingSamplesChange end-to-end (modal opens, respond() fires, empty event closes it). Follow-ups filed: #1415 (URL two-step completion flow), #1416 (form required- field submit gate + double-submit guard). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(elicitation): include untouched schema defaults in submitted content The schema form displays a field's default via resolveValue(), but onChange only writes a field into `values` once the user edits it. So an elicitation submitted with default-only fields left untouched dropped them entirely — v2 sent just the manually-edited fields while v1 sent defaults too (reported with the everything server: firstLine/integer/number/enum defaults missing). Add collectSchemaDefaults() to jsonUtils (recurses nested objects, omits fields with no default) and seed ElicitationFormModalBody's values state with it via a lazy initializer. The modal body is keyed by request id, so it re-seeds per request; user edits still override seeded defaults via the existing onChange merge. Tests: collectSchemaDefaults unit tests (field types, nested, empty) in jsonUtils.test; a modal test asserting an untouched-default form still submits its defaults in content. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(forms): seed schema defaults for tool-call and app-launch forms too Extend the elicitation defaults fix (collectSchemaDefaults) to the other two SchemaForm consumers that own form state and submit it: ToolsScreen (tool execute args) and AppsScreen (app launch args). Both reset formValues to {} on selection, so default-only fields the user never edited were dropped from the submitted args — same root cause as the elicitation modal. Seed formValues from the selected tool/app inputSchema defaults on select. User edits still override via the existing onFormChange merge. (InlineElicitationRequest has no consumers wired up, so it's left as-is.) Tests: ToolsScreen sends a tool's default on Execute without editing; AppsScreen sends an app's default on Open App without editing. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(elicitation): gate form submit on required fields; guard double-submit (#1416) Fold the #1416 hardening into this PR: - Gate the elicitation form's Submit button on required fields. Promote the hasMissingRequiredFields helper out of AppDetailPanel into jsonUtils (shared) and use it in ElicitationFormPanel so Submit is disabled until every required field has a value. - Guard against double-submit. Add an optional `busy` prop to the sampling, elicitation-form, and elicitation-url panels that disables their responding actions. PendingClientRequestModal's bodies use a new ref-guarded useRespondOnce() hook: the first action dispatches and locks the panel; later clicks no-op. This stops a fast double-click from calling respond()/reject() twice (which throws and produced an unhandled rejection). Copy URL stays enabled — it doesn't resolve the request. Tests: hasMissingRequiredFields unit tests; ElicitationFormPanel submit-gate and busy-lock tests; a modal double-click-resolves-once test. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(tools): cap detail/result panels at viewport and scroll internally The Tools screen's detail and result columns sized to their content and bled past the bottom of the viewport on long output (e.g. the env dump or a long elicitation result). Cap each column at the screen's available height and let its panel scroll internally instead. - ToolsScreen: drop `align: flex-start` and wrap each column in a stretched `ContentPane` (Flex column, capped by `mah` = viewport − header − xl*2). The pane's definite height is what lets the card's inner ScrollArea know how far to shrink — a bare `mah` on the card does not (the card sizes to content, so the scroll region renders full-height and overflow:hidden just clips it, unreachable). Mirrors the proven Prompts/Resources preview panes. - ToolResultPanel: pinned header + inner ScrollArea (flex 0 1 auto, mih 0). - ToolDetailPanel: pinned title/annotations header, scrolling body (description + form + progress), pinned Execute footer. Audited the other screens: Prompts/Resources already use this pattern, ServerList and the four list panels (Logging/Network/History/Tasks) already cap via ScrollArea.Autosize. Only AppsScreen's app-input form had the same gap — fixed it the same way: AppDetailPanel now scrolls internally and the card uses variant="preview" so the running app's iframe and a long form both stay within the viewport. Verified in-browser against the everything server: the get-env result now stops at the viewport bottom with margin and scrolls within the card (clientHeight 502 vs scrollHeight 1931), header pinned. Co-Authored-By: Claude Opus 4.8 (1M context) * test(remote): use sync pino destinations in logging tests to fix teardown race CI flaked with an uncaught `ENOENT: ... /tmp/inspector-log-unknown-level-*/app.log` that failed an unrelated later test. The remote-logging integration tests create `pino.destination(logFile)` loggers (async sonic-boom streams) and rmSync the temp dir in finally/afterEach without closing the stream — a buffered background flush then fires after the dir is gone and throws an uncaught error, which Vitest counts as a run failure even though all tests pass. Make those destinations `sync: true` so the write lands during the request instead of on a later flush, removing the race. Test-only; unrelated to this PR's feature work but it was blocking the PR's CI. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- clients/web/src/App.test.tsx | 57 ++++ clients/web/src/App.tsx | 84 ++++++ .../groups/AppDetailPanel/AppDetailPanel.tsx | 77 +++--- .../ElicitationFormPanel.stories.tsx | 1 + .../ElicitationFormPanel.test.tsx | 49 +++- .../ElicitationFormPanel.tsx | 30 +- .../ElicitationUrlPanel.tsx | 11 +- .../PendingClientRequestModal.stories.tsx | 117 ++++++++ .../PendingClientRequestModal.test.tsx | 259 ++++++++++++++++++ .../PendingClientRequestModal.tsx | 259 ++++++++++++++++++ .../SamplingRequestPanel.tsx | 16 +- .../groups/SchemaForm/SchemaForm.test.tsx | 60 ++++ .../groups/SchemaForm/SchemaForm.tsx | 25 ++ .../ToolDetailPanel/ToolDetailPanel.tsx | 116 +++++--- .../ToolResultPanel/ToolResultPanel.tsx | 89 ++++-- .../screens/AppsScreen/AppsScreen.test.tsx | 12 +- .../screens/AppsScreen/AppsScreen.tsx | 9 +- .../ToolsScreen/ToolsScreen.stories.tsx | 1 + .../screens/ToolsScreen/ToolsScreen.test.tsx | 34 +++ .../screens/ToolsScreen/ToolsScreen.tsx | 106 +++++-- .../react/usePendingClientRequests.test.tsx | 135 +++++++++ .../integration/mcp/remote/transport.test.ts | 36 ++- clients/web/src/utils/jsonUtils.test.ts | 82 ++++++ clients/web/src/utils/jsonUtils.ts | 42 +++ core/mcp/__tests__/fakeInspectorClient.ts | 32 ++- core/mcp/inspectorClientProtocol.ts | 9 + core/react/usePendingClientRequests.ts | 67 +++++ 27 files changed, 1673 insertions(+), 142 deletions(-) create mode 100644 clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx create mode 100644 clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx create mode 100644 clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx create mode 100644 clients/web/src/test/core/react/usePendingClientRequests.test.tsx create mode 100644 core/react/usePendingClientRequests.ts 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 }; +}