diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx
index 674a106ec..f8c2bcd35 100644
--- a/clients/web/src/App.test.tsx
+++ b/clients/web/src/App.test.tsx
@@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
+import { UrlElicitationLoopError } from "@inspector/core/mcp/urlElicitation.js";
import {
renderWithMantine,
screen,
@@ -720,6 +722,61 @@ describe("App task wiring", () => {
);
});
+ it("shows a URL-elicitation toast when a tool call fails with a no-list -32042", async () => {
+ const user = userEvent.setup();
+ renderWithMantine();
+ await user.click(screen.getByText("connect"));
+ await waitFor(() => expect(clientInstances).toHaveLength(1));
+
+ (
+ clientInstances[0] as unknown as {
+ callTool: ReturnType;
+ }
+ ).callTool.mockRejectedValueOnce(
+ new McpError(
+ ErrorCode.UrlElicitationRequired,
+ "This request requires browser-based authorization.",
+ ),
+ );
+
+ await user.click(screen.getByText("call"));
+
+ await waitFor(() =>
+ expect(notificationsMock.show).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "URL elicitation required",
+ color: "yellow",
+ }),
+ ),
+ );
+ });
+
+ it("shows a loop toast when a tool call aborts on a repeated URL elicitation", async () => {
+ const user = userEvent.setup();
+ renderWithMantine();
+ await user.click(screen.getByText("connect"));
+ await waitFor(() => expect(clientInstances).toHaveLength(1));
+
+ (
+ clientInstances[0] as unknown as {
+ callTool: ReturnType;
+ }
+ ).callTool.mockRejectedValueOnce(
+ new UrlElicitationLoopError("https://example.com/authorize"),
+ );
+
+ await user.click(screen.getByText("call"));
+
+ await waitFor(() =>
+ expect(notificationsMock.show).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "URL elicitation loop",
+ color: "yellow",
+ }),
+ ),
+ );
+ });
+
it("surfaces a refresh failure as a red toast", async () => {
vi.mocked(useManagedRequestorTasks).mockReturnValue({
tasks: [],
diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx
index 5dc00dab6..c8b84f80b 100644
--- a/clients/web/src/App.tsx
+++ b/clients/web/src/App.tsx
@@ -23,6 +23,10 @@ import type {
InspectorClientEventMap,
JsonValue,
} from "@inspector/core/mcp/index.js";
+import {
+ getUrlElicitationsFromError,
+ UrlElicitationLoopError,
+} from "@inspector/core/mcp/urlElicitation.js";
import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js";
import type {
InspectorServerSettings,
@@ -98,6 +102,7 @@ import {
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal";
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
+import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
import {
@@ -223,6 +228,46 @@ const OutputValidationToastMessage = ({
);
+// Body of the non-spec URLElicitationRequired toast: the server returned a
+// -32042 error with no `elicitations` list, so there's no URL to open. We keep
+// the toast short and link to a modal with the raw error body.
+const UrlElicitationErrorToastMessage = ({
+ onViewDetails,
+}: {
+ onViewDetails: () => void;
+}) => (
+
+
+ The server reported a URLElicitationRequired error but listed no required
+ elicitations, so there's nothing to open.
+
+
+ View error details
+
+
+);
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : String(err);
+}
+
+// Pretty-print a thrown error for the URL-elicitation details modal: an McpError
+// carries a `code`/`data` worth showing alongside the message, so include them
+// when present; otherwise fall back to the plain message.
+function formatErrorDetails(err: unknown): string {
+ if (err && typeof err === "object") {
+ const e = err as { code?: unknown; message?: unknown; data?: unknown };
+ if (e.code !== undefined || e.data !== undefined) {
+ return JSON.stringify(
+ { code: e.code, message: e.message, data: e.data },
+ null,
+ 2,
+ );
+ }
+ }
+ return errorMessage(err);
+}
+
// How long a progress toast lingers after its last tick. Each new tick on the
// same progress stream resets this window (via `notifications.update`), so a
// steady stream keeps one toast alive; the toast clears a few seconds after
@@ -339,6 +384,12 @@ function App() {
toolName: string;
message: string;
} | null>(null);
+ // Raw body for the non-spec URLElicitationRequired (-32042, no elicitations)
+ // modal opened from its warning toast.
+ const [urlElicitationErrorDetails, setUrlElicitationErrorDetails] = useState<{
+ toolName: string;
+ details: string;
+ } | null>(null);
// The active connection target. `null` between sessions; set as soon as
// the user toggles a server card on. Drives state-manager lifetime.
@@ -1258,9 +1309,50 @@ function App() {
error: invocation.error,
});
} catch (err) {
+ // The server kept asking for a URL the user already completed this call,
+ // so callTool aborted to avoid an endless re-prompt loop. Surface that
+ // explicitly rather than as a generic failure.
+ if (err instanceof UrlElicitationLoopError) {
+ setToolCallState({ status: "error", error: err.message });
+ notifications.show({
+ autoClose: false,
+ title: "URL elicitation loop",
+ color: "yellow",
+ message: (
+
+ The server requested the same URL again after you completed it (
+ {err.url}), so the call was cancelled to avoid an endless loop.
+
+ ),
+ });
+ return;
+ }
+ // A URLElicitationRequired (-32042) error that reaches here carried no
+ // elicitations (a non-spec response — the with-list case is handled and
+ // retried inside callTool). There's no URL to open, so surface a short
+ // toast that links to the raw error rather than a bare error panel.
+ const urlElicitations = getUrlElicitationsFromError(err);
+ if (urlElicitations !== null && urlElicitations.length === 0) {
+ const details = {
+ toolName: name,
+ details: formatErrorDetails(err),
+ };
+ setToolCallState({ status: "error", error: errorMessage(err) });
+ notifications.show({
+ autoClose: false,
+ title: "URL elicitation required",
+ color: "yellow",
+ message: (
+ setUrlElicitationErrorDetails(details)}
+ />
+ ),
+ });
+ return;
+ }
setToolCallState({
status: "error",
- error: err instanceof Error ? err.message : String(err),
+ error: errorMessage(err),
});
}
},
@@ -1953,6 +2045,12 @@ function App() {
message={outputValidationDetails?.message}
onClose={() => setOutputValidationDetails(null)}
/>
+ setUrlElicitationErrorDetails(null)}
+ />
= {
args: {
onCopyUrl: fn(),
onOpenInBrowser: fn(),
+ onComplete: fn(),
onCancel: fn(),
message: "Please authenticate with the external service.",
requestId: "elicit-abc-123",
diff --git a/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.test.tsx b/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.test.tsx
index 37007c3c7..82eea6930 100644
--- a/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.test.tsx
+++ b/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.test.tsx
@@ -10,6 +10,7 @@ const baseProps = {
isWaiting: false,
onCopyUrl: vi.fn(),
onOpenInBrowser: vi.fn(),
+ onComplete: vi.fn(),
onCancel: vi.fn(),
};
@@ -33,16 +34,29 @@ describe("ElicitationUrlPanel", () => {
).toBeInTheDocument();
});
- it("does not render waiting indicator when isWaiting is false", () => {
+ it("does not render the waiting indicator or complete action when not waiting", () => {
renderWithMantine();
expect(
- screen.queryByText("Waiting for completion..."),
+ screen.queryByText(/Waiting for completion/),
).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "I've completed it" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Open in Browser" }),
+ ).toBeInTheDocument();
});
- it("renders waiting indicator when isWaiting is true", () => {
+ it("renders the waiting indicator and complete action when waiting", () => {
renderWithMantine();
- expect(screen.getByText("Waiting for completion...")).toBeInTheDocument();
+ expect(screen.getByText(/Waiting for completion/)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "I've completed it" }),
+ ).toBeInTheDocument();
+ // The open action is relabelled so the user can reopen the tab.
+ expect(
+ screen.getByRole("button", { name: "Reopen in Browser" }),
+ ).toBeInTheDocument();
});
it("invokes onCopyUrl when Copy URL is clicked", async () => {
@@ -65,6 +79,16 @@ describe("ElicitationUrlPanel", () => {
expect(onOpenInBrowser).toHaveBeenCalledTimes(1);
});
+ it("invokes onComplete when I've completed it is clicked while waiting", async () => {
+ const user = userEvent.setup();
+ const onComplete = vi.fn();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "I've completed it" }));
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ });
+
it("invokes onCancel when Cancel is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
@@ -74,4 +98,12 @@ describe("ElicitationUrlPanel", () => {
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
+
+ it("disables the completion and cancel actions when busy", () => {
+ renderWithMantine();
+ expect(
+ screen.getByRole("button", { name: "I've completed it" }),
+ ).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
+ });
});
diff --git a/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx b/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx
index 5e9f57638..df69fcf36 100644
--- a/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx
+++ b/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx
@@ -13,9 +13,16 @@ export interface ElicitationUrlPanelProps {
message: string;
url: string;
requestId: string;
+ /**
+ * The URL has been opened and we're waiting for the user to confirm they
+ * finished the external flow. Reveals the "I've completed it" action; opening
+ * the URL does not resolve the elicitation on its own.
+ */
isWaiting: boolean;
onCopyUrl: () => void;
onOpenInBrowser: () => void;
+ /** User confirms the external flow is done — sends `accept`. */
+ onComplete: () => void;
onCancel: () => void;
/**
* A response has been dispatched; lock the responding actions so a second
@@ -56,6 +63,7 @@ export function ElicitationUrlPanel({
isWaiting,
onCopyUrl,
onOpenInBrowser,
+ onComplete,
onCancel,
busy = false,
}: ElicitationUrlPanelProps) {
@@ -70,7 +78,7 @@ export function ElicitationUrlPanel({
Copy URL
@@ -88,6 +96,11 @@ export function ElicitationUrlPanel({
+ {isWaiting && (
+
+ )}
);
diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx
index 6a09d3c08..e1469e397 100644
--- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx
+++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx
@@ -214,18 +214,39 @@ describe("PendingClientRequestModal", () => {
});
});
- it("opens the URL and accepts a URL elicitation", async () => {
+ it("opens the URL into a waiting state without resolving the elicitation", async () => {
const user = userEvent.setup();
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
renderWithMantine(
,
);
+ // Before opening there is no completion action.
+ expect(
+ screen.queryByRole("button", { name: "I've completed it" }),
+ ).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open in Browser" }));
expect(openSpy).toHaveBeenCalledWith(
"https://example.com/authorize",
"_blank",
"noopener,noreferrer",
);
+ // Opening alone must not resolve the elicitation; it only reveals the
+ // explicit completion step.
+ expect(baseProps.onElicitationRespond).not.toHaveBeenCalled();
+ expect(
+ screen.getByRole("button", { name: "I've completed it" }),
+ ).toBeInTheDocument();
+ openSpy.mockRestore();
+ });
+
+ it("accepts a URL elicitation only after the user confirms completion", async () => {
+ const user = userEvent.setup();
+ const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Open in Browser" }));
+ await user.click(screen.getByRole("button", { name: "I've completed it" }));
expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
action: "accept",
});
@@ -246,14 +267,38 @@ describe("PendingClientRequestModal", () => {
expect(writeText).toHaveBeenCalledWith("https://example.com/authorize");
});
- it("cancels a URL elicitation", async () => {
+ it("reveals the completion step after Copy URL so a copy-paste flow can accept", async () => {
const user = userEvent.setup();
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
+ configurable: true,
+ });
renderWithMantine(
,
);
+ // Before copying there is no completion action.
+ expect(
+ screen.queryByRole("button", { name: "I've completed it" }),
+ ).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Copy URL" }));
+ await user.click(screen.getByRole("button", { name: "I've completed it" }));
+ expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
+ action: "accept",
+ });
+ });
+
+ it("cancels a URL elicitation after opening without sending accept", async () => {
+ const user = userEvent.setup();
+ const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Open in Browser" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(baseProps.onElicitationRespond).toHaveBeenCalledTimes(1);
expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
action: "cancel",
});
+ openSpy.mockRestore();
});
});
diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx
index 8e128672b..deb62880a 100644
--- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx
+++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx
@@ -174,25 +174,30 @@ function ElicitationUrlModalBody({
url: string;
onRespond: (result: ElicitResult) => void;
}) {
+ const [isWaiting, setIsWaiting] = useState(false);
const { responded, once } = useRespondOnce();
return (
{
void navigator.clipboard?.writeText(url);
+ // Copying the URL is the other way to start the external flow (paste
+ // into a browser). Reveal the completion step too, otherwise a user who
+ // copies rather than clicking "Open in Browser" could only Cancel.
+ setIsWaiting(true);
}}
- onOpenInBrowser={once(() => {
+ onOpenInBrowser={() => {
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" });
- })}
+ // Opening the URL only moves the panel into its waiting state — the
+ // inspector can't observe completion of an external flow, so the
+ // elicitation resolves only when the user explicitly confirms
+ // completion (accept) or cancels.
+ setIsWaiting(true);
+ }}
+ onComplete={once(() => onRespond({ action: "accept" }))}
onCancel={once(() => onRespond({ action: "cancel" }))}
busy={responded}
/>
diff --git a/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.stories.tsx b/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.stories.tsx
new file mode 100644
index 000000000..4a6c64e8b
--- /dev/null
+++ b/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.stories.tsx
@@ -0,0 +1,59 @@
+import { AppShell } from "@mantine/core";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, within } from "storybook/test";
+import {
+ UrlElicitationErrorModal,
+ type UrlElicitationErrorModalProps,
+} from "./UrlElicitationErrorModal";
+
+const SAMPLE_DETAILS = JSON.stringify(
+ {
+ code: -32042,
+ message: "This request requires browser-based authorization.",
+ data: {},
+ },
+ null,
+ 2,
+);
+
+function InteractiveRender(args: UrlElicitationErrorModalProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Groups/UrlElicitationErrorModal",
+ component: UrlElicitationErrorModal,
+ parameters: { layout: "fullscreen" },
+ render: InteractiveRender,
+ args: {
+ opened: true,
+ onClose: fn(),
+ toolName: "trigger-url-elicitation",
+ details: SAMPLE_DETAILS,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ // Mantine renders the modal in a portal at document.body.
+ const body = within(canvasElement.ownerDocument.body);
+ await body.findByText("URL elicitation required");
+ expect(body.getByText(/trigger-url-elicitation/)).toBeInTheDocument();
+ const details = body.getByLabelText("Error details") as HTMLTextAreaElement;
+ expect(details.value).toContain("-32042");
+ expect(details.readOnly).toBe(true);
+ },
+};
+
+export const WithoutToolName: Story = {
+ args: { toolName: undefined },
+};
diff --git a/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.test.tsx b/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.test.tsx
new file mode 100644
index 000000000..fe52b5b24
--- /dev/null
+++ b/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.test.tsx
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithMantine, screen } from "../../../test/renderWithMantine";
+import { UrlElicitationErrorModal } from "./UrlElicitationErrorModal";
+
+const DETAILS = JSON.stringify(
+ {
+ code: -32042,
+ message: "This request requires browser-based authorization.",
+ data: {},
+ },
+ null,
+ 2,
+);
+
+describe("UrlElicitationErrorModal", () => {
+ it("renders the tool name and the raw error body in a read-only field", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText("URL elicitation required")).toBeInTheDocument();
+ expect(screen.getByText(/trigger-url-elicitation/)).toBeInTheDocument();
+ const details = screen.getByLabelText(
+ "Error details",
+ ) as HTMLTextAreaElement;
+ expect(details.value).toBe(DETAILS);
+ expect(details.readOnly).toBe(true);
+ });
+
+ it("falls back to a generic message when no tool name is provided", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText(/no required elicitations/i)).toBeInTheDocument();
+ });
+
+ it("calls onClose when the close button is clicked", async () => {
+ const onClose = vi.fn();
+ renderWithMantine(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders nothing visible when closed", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.queryByText("URL elicitation required"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.tsx b/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.tsx
new file mode 100644
index 000000000..5f1421b40
--- /dev/null
+++ b/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.tsx
@@ -0,0 +1,64 @@
+import {
+ CloseButton,
+ Group,
+ Modal,
+ Stack,
+ Text,
+ Textarea,
+ Title,
+} from "@mantine/core";
+
+export interface UrlElicitationErrorModalProps {
+ opened: boolean;
+ onClose: () => void;
+ /** The tool whose call returned the URL-elicitation-required error. */
+ toolName?: string;
+ /** The raw error body (message + data), pretty-printed for inspection. */
+ details?: string;
+}
+
+/**
+ * Surfaces the raw body of a `URLElicitationRequired` (`-32042`) error that
+ * carried no `elicitations` list — a non-spec server response the inspector
+ * can't act on (there's no URL to open). The Tools screen shows a short toast;
+ * this modal, opened from that toast, exposes the full error so a server
+ * developer can see what the server actually returned.
+ */
+export function UrlElicitationErrorModal({
+ opened,
+ onClose,
+ toolName,
+ details,
+}: UrlElicitationErrorModalProps) {
+ return (
+
+
+
+
+ URL elicitation required
+
+
+
+
+ {toolName
+ ? `"${toolName}" returned a URLElicitationRequired (-32042) error with no required elicitations. Per the MCP spec the error must list the URL elicitations to complete before retrying, so the inspector has nothing to open.`
+ : "The server returned a URLElicitationRequired (-32042) error with no required elicitations. Per the MCP spec the error must list the URL elicitations to complete before retrying, so the inspector has nothing to open."}
+
+
+
+
+ );
+}
diff --git a/clients/web/src/test/core/mcp/elicitationCreateMessage.test.ts b/clients/web/src/test/core/mcp/elicitationCreateMessage.test.ts
new file mode 100644
index 000000000..41d8a976e
--- /dev/null
+++ b/clients/web/src/test/core/mcp/elicitationCreateMessage.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect, vi } from "vitest";
+import type { ElicitRequest } from "@modelcontextprotocol/sdk/types.js";
+import { ElicitationCreateMessage } from "@inspector/core/mcp/elicitationCreateMessage.js";
+
+function urlRequest(): ElicitRequest {
+ return {
+ method: "elicitation/create",
+ params: {
+ mode: "url",
+ url: "https://example.com/authorize",
+ message: "Authorize to continue.",
+ elicitationId: "elicit-1",
+ },
+ } as ElicitRequest;
+}
+
+describe("ElicitationCreateMessage.completeIfPending", () => {
+ it("resolves as accepted and removes when still pending", async () => {
+ const resolve = vi.fn();
+ const onRemove = vi.fn();
+ const message = new ElicitationCreateMessage(
+ urlRequest(),
+ resolve,
+ onRemove,
+ );
+
+ message.completeIfPending();
+ // respond() is async (it awaits the microtask in remove path); flush.
+ await Promise.resolve();
+
+ expect(resolve).toHaveBeenCalledWith({ action: "accept" });
+ expect(onRemove).toHaveBeenCalledWith(message.id);
+ });
+
+ it("is a no-op once the elicitation has already been responded to", async () => {
+ const resolve = vi.fn();
+ const onRemove = vi.fn();
+ const message = new ElicitationCreateMessage(
+ urlRequest(),
+ resolve,
+ onRemove,
+ );
+
+ await message.respond({ action: "cancel" });
+ resolve.mockClear();
+ onRemove.mockClear();
+
+ // A late completion notification must not re-resolve (respond() would throw
+ // "already resolved").
+ expect(() => message.completeIfPending()).not.toThrow();
+ await Promise.resolve();
+ expect(resolve).not.toHaveBeenCalled();
+ expect(onRemove).not.toHaveBeenCalled();
+ });
+});
+
+describe("ElicitationCreateMessage.cancel", () => {
+ it("resolves a pending elicitation as cancelled without removing it", () => {
+ const resolve = vi.fn();
+ const onRemove = vi.fn();
+ const message = new ElicitationCreateMessage(
+ urlRequest(),
+ resolve,
+ onRemove,
+ );
+
+ message.cancel();
+
+ // Settles the awaiting promise (so callTool unblocks on teardown) but does
+ // not splice the queue — disconnect() clears it wholesale.
+ expect(resolve).toHaveBeenCalledWith({ action: "cancel" });
+ expect(onRemove).not.toHaveBeenCalled();
+ });
+
+ it("is a no-op once already responded to", async () => {
+ const resolve = vi.fn();
+ const onRemove = vi.fn();
+ const message = new ElicitationCreateMessage(
+ urlRequest(),
+ resolve,
+ onRemove,
+ );
+
+ await message.respond({ action: "accept" });
+ resolve.mockClear();
+
+ message.cancel();
+ expect(resolve).not.toHaveBeenCalled();
+ });
+});
diff --git a/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts b/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts
new file mode 100644
index 000000000..c32b7000d
--- /dev/null
+++ b/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts
@@ -0,0 +1,259 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ ErrorCode,
+ McpError,
+ UrlElicitationRequiredError,
+} from "@modelcontextprotocol/sdk/types.js";
+import type {
+ ElicitRequestURLParams,
+ Tool,
+} from "@modelcontextprotocol/sdk/types.js";
+import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import { UrlElicitationLoopError } from "@inspector/core/mcp/urlElicitation.js";
+import type { CreateTransport } from "@inspector/core/mcp/types.js";
+
+// The client is never connected in these tests, so the transport factory is
+// never invoked — a throwing stub satisfies the required environment seam.
+const noopTransport: CreateTransport = () => {
+ throw new Error("transport should not be created in these tests");
+};
+
+const elicitation: ElicitRequestURLParams = {
+ mode: "url",
+ url: "https://example.com/authorize",
+ message: "Authorize to continue.",
+ elicitationId: "elicit-1",
+};
+
+const tool = {
+ name: "trigger-url-elicitation",
+ inputSchema: { type: "object" },
+} as Tool;
+
+const okResult = { content: [{ type: "text", text: "done" }] };
+
+type FakeClient = {
+ callTool: ReturnType;
+ request: ReturnType;
+};
+
+/**
+ * Build an InspectorClient with its internal SDK client replaced by a fake, so
+ * we can drive `callTool` through the URL-elicitation error path without a live
+ * server. The client is never connected; `callTool` only needs the injected
+ * `callTool`/`request` methods plus the (connection-independent) helpers.
+ */
+function makeClient(fake: FakeClient): InspectorClient {
+ const client = new InspectorClient(
+ { type: "stdio", command: "noop", args: [] },
+ { elicit: { url: true }, environment: { transport: noopTransport } },
+ );
+ (client as unknown as { client: FakeClient }).client = fake;
+ return client;
+}
+
+/**
+ * Count the failed `toolCallResultChange` events a client records. Used to lock
+ * in the "record a failure exactly once" invariant on the terminal error paths.
+ */
+function trackFailedDispatches(client: InspectorClient): () => number {
+ let count = 0;
+ client.addEventListener("toolCallResultChange", (e) => {
+ if (!e.detail.success) count += 1;
+ });
+ return () => count;
+}
+
+describe("InspectorClient URL-elicitation error path", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("surfaces the URL elicitation, then retries the call on accept", async () => {
+ let attempt = 0;
+ const fake: FakeClient = {
+ callTool: vi.fn(async () => {
+ attempt += 1;
+ if (attempt === 1) {
+ throw new UrlElicitationRequiredError([elicitation]);
+ }
+ return okResult;
+ }),
+ request: vi.fn(),
+ };
+ const client = makeClient(fake);
+
+ const pending = client.callTool(tool, {});
+
+ await vi.waitFor(() =>
+ expect(client.getPendingElicitations()).toHaveLength(1),
+ );
+ const queued = client.getPendingElicitations()[0];
+ expect(queued.request.params).toMatchObject({
+ mode: "url",
+ url: elicitation.url,
+ elicitationId: elicitation.elicitationId,
+ });
+
+ await queued.respond({ action: "accept" });
+
+ const invocation = await pending;
+ expect(invocation.success).toBe(true);
+ expect(fake.callTool).toHaveBeenCalledTimes(2);
+ expect(client.getPendingElicitations()).toHaveLength(0);
+ });
+
+ it("processes multiple required elicitations in order before retrying", async () => {
+ const second: ElicitRequestURLParams = {
+ ...elicitation,
+ elicitationId: "elicit-2",
+ url: "https://example.com/second",
+ };
+ let attempt = 0;
+ const fake: FakeClient = {
+ callTool: vi.fn(async () => {
+ attempt += 1;
+ if (attempt === 1) {
+ throw new UrlElicitationRequiredError([elicitation, second]);
+ }
+ return okResult;
+ }),
+ request: vi.fn(),
+ };
+ const client = makeClient(fake);
+
+ const pending = client.callTool(tool, {});
+
+ // First elicitation is presented; accept it.
+ await vi.waitFor(() =>
+ expect(client.getPendingElicitations()).toHaveLength(1),
+ );
+ expect(client.getPendingElicitations()[0].request.params).toMatchObject({
+ elicitationId: "elicit-1",
+ });
+ await client.getPendingElicitations()[0].respond({ action: "accept" });
+
+ // Only after the first resolves does the second appear.
+ await vi.waitFor(() =>
+ expect(client.getPendingElicitations()[0]?.request.params).toMatchObject({
+ elicitationId: "elicit-2",
+ }),
+ );
+ await client.getPendingElicitations()[0].respond({ action: "accept" });
+
+ const invocation = await pending;
+ expect(invocation.success).toBe(true);
+ expect(fake.callTool).toHaveBeenCalledTimes(2);
+ });
+
+ it("aborts the call (no retry) when the user cancels a required elicitation", async () => {
+ const fake: FakeClient = {
+ callTool: vi.fn(async () => {
+ throw new UrlElicitationRequiredError([elicitation]);
+ }),
+ request: vi.fn(),
+ };
+ const client = makeClient(fake);
+ const failedDispatches = trackFailedDispatches(client);
+
+ const pending = client.callTool(tool, {});
+ await vi.waitFor(() =>
+ expect(client.getPendingElicitations()).toHaveLength(1),
+ );
+ await client.getPendingElicitations()[0].respond({ action: "cancel" });
+
+ await expect(pending).rejects.toThrow(/cancelled/i);
+ expect(fake.callTool).toHaveBeenCalledTimes(1);
+ // The abort records exactly one failed history entry — not zero, not a
+ // duplicate from the generic catch.
+ expect(failedDispatches()).toBe(1);
+ });
+
+ it("aborts with a loop error when the server re-requests a completed URL", async () => {
+ // The server keeps returning the same URL after the user completes it.
+ const fake: FakeClient = {
+ callTool: vi.fn(async () => {
+ throw new UrlElicitationRequiredError([elicitation]);
+ }),
+ request: vi.fn(),
+ };
+ const client = makeClient(fake);
+ const failedDispatches = trackFailedDispatches(client);
+
+ const pending = client.callTool(tool, {});
+
+ // First round: the URL is presented; the user completes it.
+ await vi.waitFor(() =>
+ expect(client.getPendingElicitations()).toHaveLength(1),
+ );
+ await client.getPendingElicitations()[0].respond({ action: "accept" });
+
+ // The retry returns the same URL; rather than re-prompt, the call aborts.
+ await expect(pending).rejects.toBeInstanceOf(UrlElicitationLoopError);
+ await expect(pending).rejects.toMatchObject({ url: elicitation.url });
+ // Only the initial call + one retry ran; the URL was presented just once.
+ expect(fake.callTool).toHaveBeenCalledTimes(2);
+ expect(client.getPendingElicitations()).toHaveLength(0);
+ // The loop abort records exactly one failed history entry (the accepted
+ // first round records nothing).
+ expect(failedDispatches()).toBe(1);
+ });
+
+ it("settles a pending error-path elicitation as cancelled on disconnect (no hang)", async () => {
+ const fake: FakeClient = {
+ callTool: vi.fn(async () => {
+ throw new UrlElicitationRequiredError([elicitation]);
+ }),
+ request: vi.fn(),
+ };
+ const client = makeClient(fake);
+
+ const pending = client.callTool(tool, {});
+ await vi.waitFor(() =>
+ expect(client.getPendingElicitations()).toHaveLength(1),
+ );
+
+ // Tearing down mid-elicitation must settle the awaiting promise rather than
+ // leave callTool hanging forever on a dropped queue.
+ await client.disconnect();
+
+ await expect(pending).rejects.toThrow(/cancelled/i);
+ expect(client.getPendingElicitations()).toHaveLength(0);
+ });
+
+ it("rethrows a -32042 error with no elicitations without queuing anything", async () => {
+ const fake: FakeClient = {
+ callTool: vi.fn(async () => {
+ throw new McpError(
+ ErrorCode.UrlElicitationRequired,
+ "This request requires browser-based authorization.",
+ );
+ }),
+ request: vi.fn(),
+ };
+ const client = makeClient(fake);
+
+ await expect(client.callTool(tool, {})).rejects.toMatchObject({
+ code: ErrorCode.UrlElicitationRequired,
+ });
+ expect(client.getPendingElicitations()).toHaveLength(0);
+ expect(fake.callTool).toHaveBeenCalledTimes(1);
+ });
+
+ it("rethrows an ordinary error unchanged", async () => {
+ const fake: FakeClient = {
+ callTool: vi.fn(async () => {
+ throw new Error("boom");
+ }),
+ request: vi.fn(),
+ };
+ const client = makeClient(fake);
+ const failed = vi.fn();
+ client.addEventListener("toolCallResultChange", (e) => {
+ if (!e.detail.success) failed();
+ });
+
+ await expect(client.callTool(tool, {})).rejects.toThrow("boom");
+ expect(failed).toHaveBeenCalled();
+ });
+});
diff --git a/clients/web/src/test/core/mcp/urlElicitation.test.ts b/clients/web/src/test/core/mcp/urlElicitation.test.ts
new file mode 100644
index 000000000..e92dbd55e
--- /dev/null
+++ b/clients/web/src/test/core/mcp/urlElicitation.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from "vitest";
+import {
+ ErrorCode,
+ McpError,
+ UrlElicitationRequiredError,
+} from "@modelcontextprotocol/sdk/types.js";
+import type { ElicitRequestURLParams } from "@modelcontextprotocol/sdk/types.js";
+import { getUrlElicitationsFromError } from "@inspector/core/mcp/urlElicitation.js";
+
+const elicitation: ElicitRequestURLParams = {
+ mode: "url",
+ url: "https://example.com/authorize",
+ message: "Authorize to continue.",
+ elicitationId: "elicit-1",
+};
+
+describe("getUrlElicitationsFromError", () => {
+ it("returns the elicitations from a typed UrlElicitationRequiredError", () => {
+ const error = new UrlElicitationRequiredError([elicitation]);
+ expect(getUrlElicitationsFromError(error)).toEqual([elicitation]);
+ });
+
+ it("returns the elicitations from a generic McpError carrying the -32042 data", () => {
+ const error = new McpError(
+ ErrorCode.UrlElicitationRequired,
+ "This request requires browser-based authorization.",
+ { elicitations: [elicitation] },
+ );
+ expect(getUrlElicitationsFromError(error)).toEqual([elicitation]);
+ });
+
+ it("returns an empty array for a -32042 McpError with no elicitations (non-spec)", () => {
+ const error = new McpError(
+ ErrorCode.UrlElicitationRequired,
+ "This request requires browser-based authorization.",
+ );
+ expect(getUrlElicitationsFromError(error)).toEqual([]);
+ });
+
+ it("returns null for a non -32042 McpError", () => {
+ const error = new McpError(ErrorCode.InvalidParams, "bad params");
+ expect(getUrlElicitationsFromError(error)).toBeNull();
+ });
+
+ it("returns null for a plain Error", () => {
+ expect(getUrlElicitationsFromError(new Error("boom"))).toBeNull();
+ });
+
+ it("returns null for non-error values", () => {
+ expect(getUrlElicitationsFromError("nope")).toBeNull();
+ expect(getUrlElicitationsFromError(undefined)).toBeNull();
+ });
+});
diff --git a/core/mcp/elicitationCreateMessage.ts b/core/mcp/elicitationCreateMessage.ts
index 7537b4f23..830bcd5d2 100644
--- a/core/mcp/elicitationCreateMessage.ts
+++ b/core/mcp/elicitationCreateMessage.ts
@@ -72,6 +72,38 @@ export class ElicitationCreateMessage {
this.remove();
}
+ /**
+ * Resolve this elicitation as accepted, but only if it is still pending.
+ *
+ * Used by the URL-mode `notifications/elicitation/complete` handler to
+ * auto-advance an open URL elicitation when the server signals the
+ * out-of-band flow finished. It is a no-op once the user has already
+ * responded — that guard (plus the modal's own once-guard) keeps `respond()`
+ * from throwing its "already resolved" error on a race between the manual
+ * "I've completed it" click and the server's completion notification.
+ */
+ completeIfPending(): void {
+ if (this.resolvePromise) {
+ void this.respond({ action: "accept" });
+ }
+ }
+
+ /**
+ * Settle a still-pending elicitation as cancelled, without removing it from
+ * the queue. Used by `disconnect()` teardown so an awaiting caller — notably
+ * the error-path `awaitUrlElicitation` that blocks `callTool` — doesn't hang
+ * forever when the pending queue is dropped wholesale. No-op once already
+ * resolved; deliberately does not call `onRemove` (the caller clears the
+ * queue itself, so we must not splice it mid-iteration).
+ */
+ cancel(): void {
+ if (this.resolvePromise) {
+ this.resolvePromise({ action: "cancel" });
+ this.resolvePromise = undefined;
+ }
+ this.rejectCallback = undefined;
+ }
+
/**
* Remove this pending elicitation from the list
*/
diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts
index b9125b050..575c3efea 100644
--- a/core/mcp/inspectorClient.ts
+++ b/core/mcp/inspectorClient.ts
@@ -56,7 +56,9 @@ import type {
Prompt,
Root,
CreateMessageResult,
+ ElicitRequest,
ElicitResult,
+ ElicitRequestURLParams,
CallToolResult,
Task,
Progress,
@@ -110,6 +112,10 @@ import {
} from "./inspectorClientEventTarget.js";
import { SamplingCreateMessage } from "./samplingCreateMessage.js";
import { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
+import {
+ getUrlElicitationsFromError,
+ UrlElicitationLoopError,
+} from "./urlElicitation.js";
import type { AuthGuidedState, OAuthStep } from "../auth/types.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import type pino from "pino";
@@ -126,6 +132,14 @@ interface ReceiverTaskRecord {
cleanupTimeoutId?: ReturnType;
}
+/**
+ * Cap on how many times a single `callTool` will surface URL elicitations and
+ * retry after a `-32042` (UrlElicitationRequired) response. A spec-compliant
+ * flow resolves in one round; the bound only guards against a server that keeps
+ * returning the error.
+ */
+const MAX_URL_ELICITATION_RETRIES = 5;
+
/**
* InspectorClient wraps an MCP Client and provides:
* - Message tracking and storage
@@ -938,7 +952,11 @@ export class InspectorClient extends InspectorClientEventTarget {
e.request.params?.elicitationId === elicitationId,
);
if (pending) {
- pending.remove();
+ // Resolve (not just remove): for the error-path retry loop this
+ // unblocks `awaitUrlElicitation`, and for request-path it sends
+ // the `accept` response the server is still awaiting. No-op once
+ // the user already clicked "I've completed it".
+ pending.completeIfPending();
}
},
);
@@ -1005,8 +1023,14 @@ export class InspectorClient extends InspectorClientEventTarget {
this.dispatchTypedEvent("disconnect");
}
- // Clear server state on disconnect (list state is in state managers)
+ // Clear server state on disconnect (list state is in state managers).
+ // Settle any outstanding elicitations as cancelled before dropping them, so
+ // an error-path `awaitUrlElicitation` (which blocks `callTool`) doesn't hang
+ // forever when the queue is cleared on teardown.
this.pendingSamples = [];
+ for (const elicitation of this.pendingElicitations) {
+ elicitation.cancel();
+ }
this.pendingElicitations = [];
// Clear resource subscriptions on disconnect
this.subscribedResources.clear();
@@ -1022,6 +1046,7 @@ export class InspectorClient extends InspectorClientEventTarget {
this.serverInfo = undefined;
this.instructions = undefined;
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
+ this.dispatchTypedEvent("pendingElicitationsChange", this.pendingElicitations);
this.dispatchTypedEvent("capabilitiesChange", this.capabilities);
this.dispatchTypedEvent("serverInfoChange", this.serverInfo);
this.dispatchTypedEvent("instructionsChange", this.instructions);
@@ -1330,124 +1355,276 @@ export class InspectorClient extends InspectorClientEventTarget {
);
}
- try {
- let convertedArgs: Record = args;
- const stringArgs: Record = {};
- for (const [key, value] of Object.entries(args)) {
- if (typeof value === "string") {
- stringArgs[key] = value;
+ // Retry loop for the URL-elicitation error path: a `-32042`
+ // (UrlElicitationRequired) response means the server needs the user to
+ // complete one or more URL elicitations before the call can succeed. We
+ // surface them, wait for completion, then re-issue the same call. The
+ // counter bounds a server that keeps returning `-32042` so we can't spin
+ // forever (each accepted round is one attempt).
+ let urlElicitationAttempt = 0;
+ // URLs already presented in this call. If a retry's error re-requests one,
+ // completing it again can't make progress (the server isn't advancing), so
+ // we abort with a UrlElicitationLoopError rather than re-prompt the user.
+ const presentedUrls = new Set();
+ while (true) {
+ try {
+ return await this.attemptToolCall(
+ tool,
+ args,
+ generalMetadata,
+ toolSpecificMetadata,
+ taskOptions,
+ options,
+ );
+ } catch (error) {
+ const urlElicitations = getUrlElicitationsFromError(error);
+ if (
+ urlElicitations &&
+ urlElicitations.length > 0 &&
+ urlElicitationAttempt < MAX_URL_ELICITATION_RETRIES
+ ) {
+ // Loop guard: the server repeated a URL we already handled this call.
+ const repeated = urlElicitations.find((e) =>
+ presentedUrls.has(e.url),
+ );
+ if (repeated) {
+ const loopError = new UrlElicitationLoopError(repeated.url);
+ this.dispatchFailedToolCall(
+ tool,
+ args,
+ generalMetadata,
+ toolSpecificMetadata,
+ loopError.message,
+ );
+ throw loopError;
+ }
+ urlElicitationAttempt++;
+ for (const e of urlElicitations) {
+ presentedUrls.add(e.url);
+ }
+ const action = await this.runUrlElicitations(urlElicitations);
+ if (action === "accept") {
+ continue;
+ }
+ // The user declined/cancelled a required URL elicitation, so the
+ // original call can't proceed. Surface it as a failed call with a
+ // clear reason instead of the raw "-32042" message.
+ const abortError = new Error(
+ `Tool call cancelled: required URL elicitation was ${
+ action === "decline" ? "declined" : "cancelled"
+ }.`,
+ );
+ this.dispatchFailedToolCall(
+ tool,
+ args,
+ generalMetadata,
+ toolSpecificMetadata,
+ abortError.message,
+ );
+ throw abortError;
}
+ // Not a URL-elicitation error (or the non-spec no-list variant, or
+ // retries exhausted): record + rethrow so the caller can surface it.
+ // The App distinguishes the no-list `-32042` case (a dedicated toast)
+ // via getUrlElicitationsFromError on the thrown error.
+ if (urlElicitations && urlElicitations.length > 0) {
+ // A non-empty list here means the retry cap was hit (the live path
+ // returns or continues). Log the give-up so a server that keeps
+ // demanding new URL elicitations is diagnosable rather than looking
+ // like an ordinary failure.
+ this.logger.warn(
+ { tool: tool.name, attempts: urlElicitationAttempt },
+ `Tool "${tool.name}" still required URL elicitations after ${MAX_URL_ELICITATION_RETRIES} attempts; giving up.`,
+ );
+ }
+ this.dispatchFailedToolCall(
+ tool,
+ args,
+ generalMetadata,
+ toolSpecificMetadata,
+ error instanceof Error ? error.message : String(error),
+ );
+ throw error;
}
- if (Object.keys(stringArgs).length > 0) {
- const convertedStringArgs = convertToolParameters(tool, stringArgs);
- convertedArgs = { ...args, ...convertedStringArgs };
- }
-
- // Merge general metadata with tool-specific metadata; tool-specific wins.
- const callMetadata: Record | undefined =
- generalMetadata || toolSpecificMetadata
- ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
- : undefined;
-
- const timestamp = new Date();
- // Fold in this client's defaultMetadata so server-wide _meta reaches
- // the wire even when the caller passed nothing.
- const metadata = this.mergeMeta(callMetadata);
+ }
+ }
- const callParams: {
- name: string;
- arguments: Record;
- _meta?: Record;
- task?: { ttl: number };
- } = {
- name: tool.name,
- arguments: convertedArgs,
- _meta: metadata,
- };
- if (taskOptions?.ttl != null) {
- callParams.task = { ttl: taskOptions.ttl };
+ /**
+ * Run a single tools/call attempt: convert args, issue the request, validate,
+ * and return a successful {@link ToolCallInvocation}. Throws on any error
+ * (including a `-32042` UrlElicitationRequired response); {@link callTool}'s
+ * retry loop owns the elicitation handling and failure bookkeeping.
+ */
+ private async attemptToolCall(
+ tool: Tool,
+ args: Record,
+ generalMetadata?: Record,
+ toolSpecificMetadata?: Record,
+ taskOptions?: { ttl?: number },
+ options?: { skipOutputValidation?: boolean },
+ ): Promise {
+ const client = this.client;
+ if (!client) {
+ throw new Error("Client is not connected");
+ }
+ let convertedArgs: Record = args;
+ const stringArgs: Record = {};
+ for (const [key, value] of Object.entries(args)) {
+ if (typeof value === "string") {
+ stringArgs[key] = value;
}
+ }
+ if (Object.keys(stringArgs).length > 0) {
+ const convertedStringArgs = convertToolParameters(tool, stringArgs);
+ convertedArgs = { ...args, ...convertedStringArgs };
+ }
- // MCP Apps forward the server's CallToolResult straight to the running
- // view, which is the real consumer. The SDK's callTool() validates
- // structuredContent against the tool's outputSchema and THROWS on a
- // mismatch — which would deny the app a result the server actually
- // returned (and that legacy hosts render fine). For those passthrough
- // calls go through request() directly, which skips that host-side
- // validation. Regular Tools-screen calls keep validating.
- const requestOptions = this.getRequestOptions(metadata?.progressToken);
- // Both branches yield a CallToolResult: request() parsed it with
- // CallToolResultSchema above, callTool() returns the same shape — so the
- // `as CallToolResult` casts below are safe.
- const result = options?.skipOutputValidation
- ? await this.client.request(
- { method: "tools/call", params: callParams },
- CallToolResultSchema,
- requestOptions,
- )
- : await this.client.callTool(callParams, undefined, requestOptions);
-
- // On the bypass path the result was delivered without the SDK's strict
- // output validation. Run that check ourselves, non-fatally, so callers can
- // warn that strict clients would reject this payload (the app still
- // renders, but it may not in other hosts).
- const outputValidationError = options?.skipOutputValidation
- ? this.validateToolOutput(tool, result as CallToolResult)
+ // Merge general metadata with tool-specific metadata; tool-specific wins.
+ const callMetadata: Record | undefined =
+ generalMetadata || toolSpecificMetadata
+ ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
: undefined;
- const invocation: ToolCallInvocation = {
- toolName: tool.name,
- params: args,
- result: result as CallToolResult,
- timestamp,
- success: true,
- metadata,
- outputValidationError,
- };
-
- this.dispatchTypedEvent("toolCallResultChange", {
- toolName: tool.name,
- params: args,
- result: invocation.result,
- timestamp,
- success: true,
- metadata,
- outputValidationError,
- });
+ const timestamp = new Date();
+ // Fold in this client's defaultMetadata so server-wide _meta reaches
+ // the wire even when the caller passed nothing.
+ const metadata = this.mergeMeta(callMetadata);
+
+ const callParams: {
+ name: string;
+ arguments: Record;
+ _meta?: Record;
+ task?: { ttl: number };
+ } = {
+ name: tool.name,
+ arguments: convertedArgs,
+ _meta: metadata,
+ };
+ if (taskOptions?.ttl != null) {
+ callParams.task = { ttl: taskOptions.ttl };
+ }
- return invocation;
- } catch (error) {
- // Merge general metadata with tool-specific metadata for error case
- const callMetadata: Record | undefined =
- generalMetadata || toolSpecificMetadata
- ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
- : undefined;
+ // MCP Apps forward the server's CallToolResult straight to the running
+ // view, which is the real consumer. The SDK's callTool() validates
+ // structuredContent against the tool's outputSchema and THROWS on a
+ // mismatch — which would deny the app a result the server actually
+ // returned (and that legacy hosts render fine). For those passthrough
+ // calls go through request() directly, which skips that host-side
+ // validation. Regular Tools-screen calls keep validating.
+ const requestOptions = this.getRequestOptions(metadata?.progressToken);
+ // Both branches yield a CallToolResult: request() parsed it with
+ // CallToolResultSchema above, callTool() returns the same shape — so the
+ // `as CallToolResult` casts below are safe.
+ const result = options?.skipOutputValidation
+ ? await client.request(
+ { method: "tools/call", params: callParams },
+ CallToolResultSchema,
+ requestOptions,
+ )
+ : await client.callTool(callParams, undefined, requestOptions);
+
+ // On the bypass path the result was delivered without the SDK's strict
+ // output validation. Run that check ourselves, non-fatally, so callers can
+ // warn that strict clients would reject this payload (the app still
+ // renders, but it may not in other hosts).
+ const outputValidationError = options?.skipOutputValidation
+ ? this.validateToolOutput(tool, result as CallToolResult)
+ : undefined;
+
+ const invocation: ToolCallInvocation = {
+ toolName: tool.name,
+ params: args,
+ result: result as CallToolResult,
+ timestamp,
+ success: true,
+ metadata,
+ outputValidationError,
+ };
- const timestamp = new Date();
- const metadata = this.mergeMeta(callMetadata);
+ this.dispatchTypedEvent("toolCallResultChange", {
+ toolName: tool.name,
+ params: args,
+ result: invocation.result,
+ timestamp,
+ success: true,
+ metadata,
+ outputValidationError,
+ });
- const invocation: ToolCallInvocation = {
- toolName: tool.name,
- params: args,
- result: null,
- timestamp,
- success: false,
- error: error instanceof Error ? error.message : String(error),
- metadata,
- };
+ return invocation;
+ }
- this.dispatchTypedEvent("toolCallResultChange", {
- toolName: tool.name,
- params: args,
- result: null,
- timestamp,
- success: false,
- error: invocation.error,
- metadata,
- });
+ /**
+ * Record a failed tools/call as a `toolCallResultChange` event (history + the
+ * Tools panel) without throwing. {@link callTool} calls this before rethrowing
+ * so a failure — whether a transport error, a declined URL elicitation, or a
+ * non-spec `-32042` — lands in the request history exactly once.
+ */
+ private dispatchFailedToolCall(
+ tool: Tool,
+ args: Record,
+ generalMetadata: Record | undefined,
+ toolSpecificMetadata: Record | undefined,
+ errorMessage: string,
+ ): void {
+ const callMetadata: Record | undefined =
+ generalMetadata || toolSpecificMetadata
+ ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
+ : undefined;
+ const metadata = this.mergeMeta(callMetadata);
+ this.dispatchTypedEvent("toolCallResultChange", {
+ toolName: tool.name,
+ params: args,
+ result: null,
+ timestamp: new Date(),
+ success: false,
+ error: errorMessage,
+ metadata,
+ });
+ }
- throw error;
+ /**
+ * Surface the URL elicitations carried by a `-32042` error, one at a time and
+ * in order (per the spec's "URL mode with elicitation required error" flow),
+ * returning as soon as the user declines/cancels one. Returns `"accept"` only
+ * when every elicitation was accepted, which is {@link callTool}'s signal to
+ * retry the original call.
+ */
+ private async runUrlElicitations(
+ elicitations: ElicitRequestURLParams[],
+ ): Promise {
+ for (const params of elicitations) {
+ const action = await this.awaitUrlElicitation(params);
+ if (action !== "accept") {
+ return action;
+ }
}
+ return "accept";
+ }
+
+ /**
+ * Add one error-path URL elicitation to the pending queue (so it renders in
+ * the same modal as request-path elicitations) and resolve with the user's
+ * action. Unlike the request-path handler there is no server request to
+ * answer — accepting it just unblocks the retry; the server's optional
+ * `notifications/elicitation/complete` resolves it as accepted too (via
+ * `completeIfPending`).
+ */
+ private awaitUrlElicitation(
+ params: ElicitRequestURLParams,
+ ): Promise {
+ return new Promise((resolve) => {
+ const request = {
+ method: "elicitation/create",
+ params,
+ } as ElicitRequest;
+ const message = new ElicitationCreateMessage(
+ request,
+ (result) => resolve(result.action),
+ (id) => this.removePendingElicitation(id),
+ );
+ this.addPendingElicitation(message);
+ });
}
/**
diff --git a/core/mcp/urlElicitation.ts b/core/mcp/urlElicitation.ts
new file mode 100644
index 000000000..fdb5ebd55
--- /dev/null
+++ b/core/mcp/urlElicitation.ts
@@ -0,0 +1,60 @@
+import {
+ ErrorCode,
+ McpError,
+ UrlElicitationRequiredError,
+} from "@modelcontextprotocol/sdk/types.js";
+import type { ElicitRequestURLParams } from "@modelcontextprotocol/sdk/types.js";
+
+export type { ElicitRequestURLParams };
+
+/**
+ * Thrown by `callTool` when the URL-elicitation error path would loop: the
+ * server's `-32042` retry response re-requests a URL the user already completed
+ * earlier in the same call. Completing it again can't make progress, so the
+ * call is cancelled instead of re-presenting the same URL. The web layer
+ * detects this (over a generic failure) to show a "same URL again" toast.
+ */
+export class UrlElicitationLoopError extends Error {
+ /** The URL the server repeated. */
+ readonly url: string;
+
+ constructor(url: string) {
+ super(
+ `The server asked for the same URL elicitation again (${url}); cancelling the call to avoid a loop.`,
+ );
+ this.name = "UrlElicitationLoopError";
+ this.url = url;
+ }
+}
+
+/**
+ * Detect a `URLElicitationRequiredError` (JSON-RPC code `-32042`) and return the
+ * list of URL-mode elicitations the server attached, or `null` when `error` is
+ * not that error.
+ *
+ * Two shapes reach us, both code `-32042`:
+ * - the SDK's typed {@link UrlElicitationRequiredError} (created by
+ * `McpError.fromError` when `data.elicitations` is present), and
+ * - a generic {@link McpError} with code `-32042` when the server omitted
+ * `data.elicitations` (a non-spec response — the spec requires the list).
+ *
+ * The empty array is meaningful: it signals the non-spec "no elicitations"
+ * case, which the caller surfaces differently (a toast pointing at the raw
+ * error) from the spec-compliant case (surface each URL elicitation, then retry
+ * the original request). A non-`-32042` error returns `null` so callers fall
+ * through to their generic error handling.
+ */
+export function getUrlElicitationsFromError(
+ error: unknown,
+): ElicitRequestURLParams[] | null {
+ if (error instanceof UrlElicitationRequiredError) {
+ return error.elicitations ?? [];
+ }
+ if (error instanceof McpError && error.code === ErrorCode.UrlElicitationRequired) {
+ const data = error.data as
+ | { elicitations?: ElicitRequestURLParams[] }
+ | undefined;
+ return data?.elicitations ?? [];
+ }
+ return null;
+}