feat(web): URL elicitation — explicit completion step + error-path (-32042) handling (#1415) (#1436)
* feat(web): two-step URL elicitation completion (#1415) Replace accept-on-open with an explicit completion step for URL elicitations. Opening the URL now only transitions the panel to its waiting state; the elicitation resolves with `accept` only when the user clicks the revealed "I've completed it" action, or with `cancel`. - ElicitationUrlPanel: add `onComplete`, reveal the completion button and relabel open→"Reopen in Browser" while `isWaiting`. - PendingClientRequestModal: wire local `isWaiting`; open no longer sends a response. Removed the accept-on-open comment that pointed here. - Tests cover open→waiting→complete and open→cancel, and assert opening alone does not resolve the elicitation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): handle URL-elicitation-required (-32042) error path Implements the spec's "URL mode with elicitation required error" flow (2025-11-25). When a tools/call returns a `-32042` UrlElicitationRequired error, the client now surfaces the required URL elicitation(s) and retries the call once completed, instead of showing a bare error. Core (InspectorClient.callTool): - Detect `-32042` (typed UrlElicitationRequiredError or generic McpError) via the new `getUrlElicitationsFromError` helper. - Surface each carried elicitation in order through the existing pending- elicitation queue / modal (reusing the two-step "Open → I've completed it" UI), then re-issue the original call once all are accepted. A decline/cancel aborts with a clear message; a bounded retry count guards a server that keeps returning the error. - `ElicitationCreateMessage.completeIfPending()` lets the optional `notifications/elicitation/complete` auto-advance/auto-accept an open URL elicitation; the completion-notification handler now resolves (was: only removed) the pending entry. - Extracted `attemptToolCall` / `dispatchFailedToolCall` from the old callTool body to support the retry loop without duplicating bookkeeping. Web (App): - A non-spec `-32042` with no elicitations (nothing to open) shows a dedicated yellow toast linking to a new UrlElicitationErrorModal with the raw error body, mirroring the output-schema-validation toast. Tests: helper unit tests, completeIfPending unit tests, callTool error-path retry/cancel/no-list/ordinary-error tests (injected fake client), the new modal (test + stories), and an App toast test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): abort URL-elicitation error path on a repeated URL Guard the -32042 retry loop: if the server's retry error re-requests a URL the user already completed earlier in the same call, completing it again can't make progress, so callTool aborts with a new typed UrlElicitationLoopError instead of re-prompting (which would loop until the retry cap). The web layer detects it and shows a "URL elicitation loop" toast naming the repeated URL rather than a generic failure. Tests: core abort-on-repeat test (URL presented once, two attempts, then loop error) and an App loop-toast test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): let Copy-URL reach the URL-elicitation completion step Address review of #1436: the "I've completed it" action was gated behind clicking "Open in Browser" (only that set isWaiting), so a user who used Copy URL to finish the flow externally could only Cancel. Copying the URL now also moves the panel into the waiting state, exposing the completion step. Also trims the now-redundant second sentence of the waiting hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): address review of error-path URL elicitation Review follow-ups on #1436 (all non-blocking): - Remove the leftover bare block scope in attemptToolCall (extraction artifact); body de-indented one level. - Log a warning when callTool gives up after the URL-elicitation retry cap is hit, so a server that keeps demanding new elicitations is diagnosable rather than looking like an ordinary failure. - Assert the dispatch-once invariant on the loop-abort and cancel-abort paths (exactly one failed toolCallResultChange event). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(core): fix attemptToolCall body indentation Run prettier over the extracted attemptToolCall method body, which was left over-indented by 2 spaces after the bare-block removal in 0754b43d. core/ is outside clients/web's format:check scope, so npm run validate did not catch it. Whitespace-only; the pre-existing line 658 wrap (also outside the format scope) is left as-is, out of scope for this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): settle pending elicitations on disconnect to avoid callTool hang Address review of #1436: an error-path `awaitUrlElicitation` promise only settled via respond, but `disconnect()` cleared `pendingElicitations` wholesale — so a disconnect mid-elicitation (server crash, manual disconnect, transport close) left `callTool` (and the UI's in-flight toolCallState) hanging forever. - Add `ElicitationCreateMessage.cancel()`: settles a pending elicitation as `{ action: "cancel" }` without calling `onRemove` (the queue is cleared by the caller, so it must not splice mid-iteration). - `disconnect()` now cancels each pending elicitation before clearing the array, and dispatches `pendingElicitationsChange` (it already did so for samples) so the modal closes on a transport-close teardown too. Tests: disconnect-mid-elicitation settles callTool (no hang); cancel() unit tests (resolves-without-remove, no-op once responded). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49d6be6987
commit
5cabc1ecd7
16 changed files with 1232 additions and 124 deletions
|
|
@ -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(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
(
|
||||
clientInstances[0] as unknown as {
|
||||
callTool: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).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(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
(
|
||||
clientInstances[0] as unknown as {
|
||||
callTool: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).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: [],
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
</Stack>
|
||||
);
|
||||
|
||||
// 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;
|
||||
}) => (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
The server reported a URLElicitationRequired error but listed no required
|
||||
elicitations, so there's nothing to open.
|
||||
</Text>
|
||||
<Anchor component="button" type="button" size="sm" onClick={onViewDetails}>
|
||||
View error details
|
||||
</Anchor>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
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: (
|
||||
<Text size="sm">
|
||||
The server requested the same URL again after you completed it (
|
||||
{err.url}), so the call was cancelled to avoid an endless loop.
|
||||
</Text>
|
||||
),
|
||||
});
|
||||
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: (
|
||||
<UrlElicitationErrorToastMessage
|
||||
onViewDetails={() => 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)}
|
||||
/>
|
||||
<UrlElicitationErrorModal
|
||||
opened={urlElicitationErrorDetails !== null}
|
||||
toolName={urlElicitationErrorDetails?.toolName}
|
||||
details={urlElicitationErrorDetails?.details}
|
||||
onClose={() => setUrlElicitationErrorDetails(null)}
|
||||
/>
|
||||
<PendingClientRequestModal
|
||||
request={pendingRequestContent}
|
||||
serverName={activeServer?.name ?? "this server"}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const meta: Meta<typeof ElicitationUrlPanel> = {
|
|||
args: {
|
||||
onCopyUrl: fn(),
|
||||
onOpenInBrowser: fn(),
|
||||
onComplete: fn(),
|
||||
onCancel: fn(),
|
||||
message: "Please authenticate with the external service.",
|
||||
requestId: "elicit-abc-123",
|
||||
|
|
|
|||
|
|
@ -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(<ElicitationUrlPanel {...baseProps} />);
|
||||
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(<ElicitationUrlPanel {...baseProps} isWaiting />);
|
||||
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(
|
||||
<ElicitationUrlPanel {...baseProps} isWaiting onComplete={onComplete} />,
|
||||
);
|
||||
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(<ElicitationUrlPanel {...baseProps} isWaiting busy />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "I've completed it" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</Button>
|
||||
<Button variant="light" onClick={onOpenInBrowser} disabled={busy}>
|
||||
Open in Browser
|
||||
{isWaiting ? "Reopen in Browser" : "Open in Browser"}
|
||||
</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
|
@ -88,6 +96,11 @@ export function ElicitationUrlPanel({
|
|||
<Button variant="light" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isWaiting && (
|
||||
<Button onClick={onComplete} disabled={busy}>
|
||||
I've completed it
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<PendingClientRequestModal {...baseProps} request={urlContent} />,
|
||||
);
|
||||
// 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(
|
||||
<PendingClientRequestModal {...baseProps} request={urlContent} />,
|
||||
);
|
||||
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(
|
||||
<PendingClientRequestModal {...baseProps} request={urlContent} />,
|
||||
);
|
||||
// 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(
|
||||
<PendingClientRequestModal {...baseProps} request={urlContent} />,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -174,25 +174,30 @@ function ElicitationUrlModalBody({
|
|||
url: string;
|
||||
onRespond: (result: ElicitResult) => void;
|
||||
}) {
|
||||
const [isWaiting, setIsWaiting] = useState(false);
|
||||
const { responded, once } = useRespondOnce();
|
||||
return (
|
||||
<ElicitationUrlPanel
|
||||
message={message}
|
||||
url={url}
|
||||
requestId={id}
|
||||
isWaiting={false}
|
||||
isWaiting={isWaiting}
|
||||
onCopyUrl={() => {
|
||||
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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AppShell>
|
||||
<AppShell.Main>
|
||||
<UrlElicitationErrorModal {...args} />
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof UrlElicitationErrorModal> = {
|
||||
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<typeof UrlElicitationErrorModal>;
|
||||
|
||||
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 },
|
||||
};
|
||||
|
|
@ -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(
|
||||
<UrlElicitationErrorModal
|
||||
opened
|
||||
onClose={vi.fn()}
|
||||
toolName="trigger-url-elicitation"
|
||||
details={DETAILS}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<UrlElicitationErrorModal opened onClose={vi.fn()} details={DETAILS} />,
|
||||
);
|
||||
expect(screen.getByText(/no required elicitations/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when the close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<UrlElicitationErrorModal opened onClose={onClose} details={DETAILS} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing visible when closed", () => {
|
||||
renderWithMantine(
|
||||
<UrlElicitationErrorModal
|
||||
opened={false}
|
||||
onClose={vi.fn()}
|
||||
details={DETAILS}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("URL elicitation required"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Title order={4} flex={1}>
|
||||
URL elicitation required
|
||||
</Title>
|
||||
<CloseButton aria-label="Close" onClick={onClose} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{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."}
|
||||
</Text>
|
||||
<Textarea
|
||||
aria-label="Error details"
|
||||
readOnly
|
||||
autosize
|
||||
minRows={6}
|
||||
maxRows={18}
|
||||
value={details ?? ""}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof vi.fn>;
|
||||
request: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
53
clients/web/src/test/core/mcp/urlElicitation.test.ts
Normal file
53
clients/web/src/test/core/mcp/urlElicitation.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, JsonValue> = args;
|
||||
const stringArgs: Record<string, string> = {};
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, JsonValue>,
|
||||
generalMetadata?: Record<string, string>,
|
||||
toolSpecificMetadata?: Record<string, string>,
|
||||
taskOptions?: { ttl?: number },
|
||||
options?: { skipOutputValidation?: boolean },
|
||||
): Promise<ToolCallInvocation> {
|
||||
const client = this.client;
|
||||
if (!client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
let convertedArgs: Record<string, JsonValue> = args;
|
||||
const stringArgs: Record<string, string> = {};
|
||||
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 };
|
||||
}
|
||||
|
||||
// Merge general metadata with tool-specific metadata; tool-specific wins.
|
||||
const callMetadata: Record<string, string> | 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<string, JsonValue>;
|
||||
_meta?: Record<string, string>;
|
||||
task?: { ttl: number };
|
||||
} = {
|
||||
name: tool.name,
|
||||
arguments: convertedArgs,
|
||||
_meta: metadata,
|
||||
};
|
||||
if (taskOptions?.ttl != null) {
|
||||
callParams.task = { ttl: taskOptions.ttl };
|
||||
}
|
||||
|
||||
// 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<string, string> | undefined =
|
||||
generalMetadata || toolSpecificMetadata
|
||||
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
|
||||
: undefined;
|
||||
|
||||
const invocation: ToolCallInvocation = {
|
||||
toolName: tool.name,
|
||||
params: args,
|
||||
result: result as CallToolResult,
|
||||
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);
|
||||
|
||||
this.dispatchTypedEvent("toolCallResultChange", {
|
||||
toolName: tool.name,
|
||||
params: args,
|
||||
result: invocation.result,
|
||||
timestamp,
|
||||
success: true,
|
||||
metadata,
|
||||
outputValidationError,
|
||||
});
|
||||
|
||||
return invocation;
|
||||
} catch (error) {
|
||||
// Merge general metadata with tool-specific metadata for error case
|
||||
const callMetadata: Record<string, string> | undefined =
|
||||
generalMetadata || toolSpecificMetadata
|
||||
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
|
||||
: undefined;
|
||||
|
||||
const timestamp = new Date();
|
||||
const metadata = this.mergeMeta(callMetadata);
|
||||
|
||||
const invocation: ToolCallInvocation = {
|
||||
toolName: tool.name,
|
||||
params: args,
|
||||
result: null,
|
||||
timestamp,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
metadata,
|
||||
};
|
||||
|
||||
this.dispatchTypedEvent("toolCallResultChange", {
|
||||
toolName: tool.name,
|
||||
params: args,
|
||||
result: null,
|
||||
timestamp,
|
||||
success: false,
|
||||
error: invocation.error,
|
||||
metadata,
|
||||
});
|
||||
|
||||
throw error;
|
||||
const callParams: {
|
||||
name: string;
|
||||
arguments: Record<string, JsonValue>;
|
||||
_meta?: Record<string, string>;
|
||||
task?: { ttl: number };
|
||||
} = {
|
||||
name: tool.name,
|
||||
arguments: convertedArgs,
|
||||
_meta: metadata,
|
||||
};
|
||||
if (taskOptions?.ttl != null) {
|
||||
callParams.task = { ttl: taskOptions.ttl };
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
this.dispatchTypedEvent("toolCallResultChange", {
|
||||
toolName: tool.name,
|
||||
params: args,
|
||||
result: invocation.result,
|
||||
timestamp,
|
||||
success: true,
|
||||
metadata,
|
||||
outputValidationError,
|
||||
});
|
||||
|
||||
return invocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, JsonValue>,
|
||||
generalMetadata: Record<string, string> | undefined,
|
||||
toolSpecificMetadata: Record<string, string> | undefined,
|
||||
errorMessage: string,
|
||||
): void {
|
||||
const callMetadata: Record<string, string> | 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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ElicitResult["action"]> {
|
||||
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<ElicitResult["action"]> {
|
||||
return new Promise<ElicitResult["action"]>((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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
60
core/mcp/urlElicitation.ts
Normal file
60
core/mcp/urlElicitation.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue