Skip to content
Merged
57 changes: 57 additions & 0 deletions clients/web/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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: [],
Expand Down
100 changes: 99 additions & 1 deletion clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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&apos;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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
});
}
},
Expand Down Expand Up @@ -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"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const baseProps = {
isWaiting: false,
onCopyUrl: vi.fn(),
onOpenInBrowser: vi.fn(),
onComplete: vi.fn(),
onCancel: vi.fn(),
};

Expand All @@ -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 () => {
Expand All @@ -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();
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,6 +63,7 @@ export function ElicitationUrlPanel({
isWaiting,
onCopyUrl,
onOpenInBrowser,
onComplete,
onCancel,
busy = false,
}: ElicitationUrlPanelProps) {
Expand All @@ -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 />
Expand All @@ -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>
);
Expand Down
Loading
Loading