Merge remote-tracking branch 'origin/v2/main' into v2/fix/2071-oauth-resource-metadata

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	core/mcp/node/transport.ts
This commit is contained in:
cliffhall 2026-08-24 21:26:59 -04:00
commit baf4fe954f
65 changed files with 9048 additions and 1271 deletions

View file

@ -73,6 +73,16 @@
ScrollArea would break flex sizing, e.g. the Resources accordion) blend in. */
--inspector-scrollbar-thumb: rgba(0, 0, 0, 0.4);
/* ── Code editor (Ace) ─────────────────────────────────── */
/* Ace's own `github` themes fail WCAG AA on the gutter: `#AAA` on the light
gutter's `#e8e8e8` is 1.89:1, and `#7388b5` on the dark gutter's `#24292e`
is 4.13:1 — both under 4.5:1 at the 12px the line numbers render. The
numbers are `aria-hidden`, which exempts nothing here: contrast is a
concern for sighted users, and axe flags them accordingly.
`gray-7` is 5.82:1 on the light gutter; the dark block lifts it to
`gray-5`, 4.88:1 on the dark one. */
--inspector-code-gutter-text: var(--mantine-color-gray-7);
/* ── Connection status ─────────────────────────────────── */
--inspector-status-connected: var(--mantine-color-green-6);
--inspector-status-connecting: var(--mantine-color-yellow-6);
@ -161,6 +171,9 @@
schemes, which sits too dark on charcoal — lift them one to two steps so
they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */
/* Code editor (Ace) — see the note in `:root`. */
--inspector-code-gutter-text: var(--mantine-color-gray-5);
/* Status */
--inspector-status-connected: var(--mantine-color-green-4);
--inspector-status-connecting: var(--mantine-color-yellow-4);
@ -593,3 +606,12 @@ body.resizing-col {
user-select: none;
cursor: col-resize;
}
/* Ace renders its own DOM, so its gutter can only be reached by selector — it
is not a Mantine component with a theme entry. The doubled class raises
specificity above Ace's own `.ace-github .ace_gutter`, which is injected into
<head> at runtime and therefore wins on source order otherwise. */
.ace_editor.ace-github .ace_gutter,
.ace_editor.ace-github-dark .ace_gutter {
color: var(--inspector-code-gutter-text);
}

View file

@ -220,16 +220,29 @@ const SERVER_A = {
const { updateServerSettingsSpy } = vi.hoisted(() => ({
updateServerSettingsSpy: vi.fn(() => Promise.resolve()),
}));
// Same idea for the edit-modal rename path (#1914): the test needs to make the
// PUT's *reload* fail, which only a spy it controls can do.
const { updateServerSpy, addServerSpy } = vi.hoisted(() => ({
updateServerSpy: vi.fn(() => Promise.resolve()),
addServerSpy: vi.fn(() => Promise.resolve()),
}));
// Stable spy for the tools list-changed acknowledgement, so a test can assert
// the paginated Refresh clears the indicator (#1721).
const { clearToolsListChangedSpy } = vi.hoisted(() => ({
clearToolsListChangedSpy: vi.fn(),
}));
vi.mock("@inspector/core/react/useServers.js", () => ({
vi.mock("@inspector/core/react/useServers.js", async (importOriginal) => ({
// `ServerListReloadError` is a real class the pagination toggle branches on
// with `instanceof`, so it must be the genuine one — a stub would make the
// check vacuously false and the #1914 branch untestable (#1914 review r1).
...(await importOriginal<
typeof import("@inspector/core/react/useServers.js")
>()),
useServers: vi.fn(() => ({
servers: [SERVER_A],
addServer: vi.fn(),
updateServer: vi.fn(),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
})),
@ -355,6 +368,47 @@ vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({
})),
}));
// --- App bridge factory spy (#2055) -----------------------------------------
// Passes through to the real factory but records the deps App hands it, so a
// test can drive `getListedResourceMeta` — the wiring that carries a
// `resources/list` entry's `_meta.ui` into the sandbox CSP. Without this the
// bridge-factory unit tests would still pass while App stopped supplying it.
const appBridgeFactoryDeps: AppBridgeFactoryDeps[] = [];
vi.mock(
"./components/elements/AppRenderer/createAppBridgeFactory",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("./components/elements/AppRenderer/createAppBridgeFactory")
>();
return {
...actual,
createAppBridgeFactory: (deps: AppBridgeFactoryDeps) => {
appBridgeFactoryDeps.push(deps);
return actual.createAppBridgeFactory(deps);
},
};
},
);
// --- publishAppDocument spy (#2056) -----------------------------------------
// The dedicated-origin publisher App hands both bridge factories. Mocked at the
// module so a test can assert the wiring is live — the factory's own tests
// inject the dep and cannot see App failing to supply it.
const publishAppDocumentMock =
vi.fn<
(
doc: { html: string; csp?: string },
opts: { baseUrl: string; authToken?: string },
) => Promise<string | null>
>();
vi.mock("./lib/publishAppDocument", () => ({
publishAppDocument: (
doc: { html: string; csp?: string },
opts: { baseUrl: string; authToken?: string },
) => publishAppDocumentMock(doc, opts),
}));
// --- InspectorView double ---------------------------------------------------
// Surfaces each piece of session-scoped state under test and exposes buttons
// that invoke the App's connect / call-tool / get-prompt / read-resource /
@ -412,6 +466,9 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
onClearCompletedTasks: () => void;
onRefreshTasks: () => void;
onServerSettings: (id: string) => void;
onServerEdit: (id: string) => void;
onServerAdd: () => void;
highlightedServerIds?: string[];
onClearProtocol: () => void;
onReplayProtocol: (id: string) => void;
onTogglePinProtocol: (id: string) => void;
@ -537,6 +594,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</button>
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
{/* The real server grid (and its Add / Edit controls) lives inside this
mocked view, so the config modal is only reachable through these
callbacks — and the highlight batch only observable through this prop. */}
<button onClick={() => props.onServerEdit("A")}>edit-server</button>
<button onClick={() => props.onServerAdd()}>add-server</button>
<span data-testid="highlighted-servers">
{(props.highlightedServerIds ?? []).join(",") || "none"}
</span>
<span data-testid="pinned-history">
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
</span>
@ -584,12 +649,19 @@ import { usePagedTools } from "@inspector/core/react/usePagedTools.js";
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
import { useServers } from "@inspector/core/react/useServers.js";
import {
ServerListReloadError,
useServers,
} from "@inspector/core/react/useServers.js";
import type {
InspectorServerSettings,
MessageEntry,
ServerEntry,
} from "@inspector/core/mcp/types.js";
import type { AppBridgeFactoryDeps } from "./components/elements/AppRenderer/createAppBridgeFactory";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
import type { UseManagedResourcesResult } from "@inspector/core/react/useManagedResources.js";
import type { Resource } from "@modelcontextprotocol/client";
// Default useInspectorClient return — capabilities empty (no task tool calls).
// Individual tests override via vi.mocked(...).mockReturnValue(...).
@ -1638,7 +1710,7 @@ const settingsWithRoots = (
): InspectorServerSettings => ({
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
@ -2963,6 +3035,236 @@ describe("App paginated list pagination toggle (#1721)", () => {
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("treats a modal save whose list reload failed as landed (#1914 + #2089)", async () => {
// A `ServerListReloadError` rejects *after* the PUT landed, so the save is
// on disk. It has to be recorded as the last landed write and applied like
// any other settled one — the failure path would roll the UI, the live
// client and the modal's seed back to a value disk no longer holds.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
updateServerSettingsSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
const saved: InspectorServerSettings = {
...settingsWithRoots([]),
paginatedLists: true,
};
await act(async () => {
await draftOptions.onPersist("A", saved).catch(() => undefined);
});
// `servers` is mocked to a fixed entry here — exactly what a failed reload
// leaves — so both of these read the record rather than the entry.
expect(draftOptions.resolveInitial("A")).toEqual(
expect.objectContaining({ paginatedLists: true }),
);
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
});
it("keeps the optimistic toggle when only the list reload failed (#1914)", async () => {
// The mirror of the test above. `refreshAfterWrite` rejects with a
// `ServerListReloadError` when the PUT landed and only reading the list
// back failed — the setting IS on disk, so the #1721 rollback would put
// the UI and the live client on the value the user just changed away
// from, and contradict what a reload would show.
const user = userEvent.setup();
updateServerSettingsSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
// Give the rejection a turn to land, then confirm nothing reverted it.
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true");
// Single intersection assertion rather than the `as unknown as` this file
// uses elsewhere: the fake client really is an EventTarget, and widening
// it keeps that relationship instead of erasing it (#1914 r2).
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
});
// A post-write reload failure rejects *after* the write landed, so every piece
// of state `onConfigSubmit` used to apply on the resolve path has to be applied
// on the reject path too — the rename target and the highlight batch both
// describe a row that really is on disk (#1914 review r2 / r3).
describe("App config submit with a failed list reload (#1914)", () => {
// Earlier describes install their own `useServers` return value, which
// outlives them — so this block installs its own and puts the previous
// implementation back, rather than trusting whatever leaked in.
let restoreUseServers: (() => void) | undefined;
const serversResult = (ids: string[]): ReturnType<typeof useServers> => ({
servers: ids.map((id) => ({ ...SERVER_A, id }) as ServerEntry),
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn().mockResolvedValue(undefined),
reorderServers: vi.fn().mockResolvedValue(undefined),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
beforeEach(() => {
clientInstances.length = 0;
const previous = vi.mocked(useServers).getMockImplementation();
restoreUseServers = previous
? () => vi.mocked(useServers).mockImplementation(previous)
: undefined;
updateServerSpy.mockClear();
addServerSpy.mockClear();
updateServerSettingsSpy.mockClear();
vi.mocked(useServers).mockReturnValue(serversResult(["A"]));
});
afterEach(() => {
restoreUseServers?.();
});
it("follows the rename so the active id isn't orphaned (#1914)", async () => {
// `activeServerId` isn't rendered anywhere, so it's read back through the
// one handler that passes it straight to a spy: the pagination toggle
// calls `updateServerSettings(activeServerId, …)`.
const user = userEvent.setup();
updateServerSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
const { rerender } = renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("edit-server"));
const idField = await screen.findByLabelText(/Server ID/);
await user.clear(idField);
await user.type(idField, "A-renamed");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(updateServerSpy).toHaveBeenCalledWith(
"A",
"A-renamed",
expect.anything(),
),
);
// The rejection still reaches the modal, which stays open showing it.
expect(
await screen.findByText(/could not be reloaded: disk full/),
).toBeInTheDocument();
// Now let the row land, as the SSE refresh the write itself triggers
// would. The modal's target ("A") is gone, so it closes rather than
// blanking its own form — `initialId`/`initialConfig` would go undefined
// and ServerConfigModal's reset would wipe the open form and the error
// it is showing (#1914 r3).
vi.mocked(useServers).mockReturnValue(serversResult(["A-renamed"]));
rerender(<App />);
await waitFor(() =>
expect(screen.queryByLabelText(/Server ID/)).not.toBeInTheDocument(),
);
// With the modal gone, read the active id back off the toggle.
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
"A-renamed",
expect.objectContaining({ paginatedLists: true }),
),
);
});
it("labels a settings save whose reload failed as saved, not failed (#1914)", () => {
// The settings draft debounces and flushes on close, so this toast is the
// user's only signal — a flush that rejects usually does so after the
// modal is gone. `useSettingsDraft` is mocked here, so the App's `onError`
// is invoked directly with the options it was handed.
renderWithMantine(<App />);
const onError = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0].onError;
expect(onError).toBeDefined();
act(() => {
onError!(
"A",
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
});
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Saved settings for "A", but the server list did not reload',
message: expect.stringContaining("disk full"),
}),
);
// A genuinely failed write still reads as a failure.
act(() => {
onError!("A", new Error("disk full"));
});
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Failed to save settings for "A"' }),
);
});
it("keeps a successful add in the highlight batch when the reload failed (#1914)", async () => {
// `addServerHighlighted` marked the new id only after `addServer`
// resolved. The row is on disk either way, so on a reload failure it has
// to be marked from the catch — the next successful refresh is what
// renders it, and it would otherwise arrive unhighlighted.
const user = userEvent.setup();
addServerSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was added, but the server list could not be reloaded: disk full",
),
);
renderWithMantine(<App />);
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent("none");
await user.click(screen.getByText("add-server"));
await user.type(await screen.findByLabelText(/Server ID/), "brand-new");
await user.type(screen.getByLabelText(/^Command/), "node");
await user.click(screen.getByRole("button", { name: "Add" }));
await waitFor(() =>
expect(addServerSpy).toHaveBeenCalledWith("brand-new", expect.anything()),
);
expect(
await screen.findByText(/could not be reloaded: disk full/),
).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent(
"brand-new",
),
);
});
});
// A background command runs through `runCommandInBackground`, which owns the
@ -3117,3 +3419,102 @@ describe("App background command rejections (#2049)", () => {
}
});
});
/** A complete `useManagedResources` return, so the mock stays type-checked against the hook's contract. */
function managedResourcesResult(
resources: Resource[],
): UseManagedResourcesResult {
return {
error: null,
resources,
listChanged: false,
refresh: vi.fn().mockResolvedValue(resources),
clearListChanged: vi.fn(),
};
}
describe("App MCP App listed-resource metadata wiring (#2055)", () => {
beforeEach(() => {
appBridgeFactoryDeps.length = 0;
});
afterEach(() => {
// The hook mock is module-level and shared, so put the empty-list default
// back rather than leaving a populated list for whatever runs next.
vi.mocked(useManagedResources).mockReturnValue(managedResourcesResult([]));
});
it("hands the bridge factory a getListedResourceMeta reading the resources/list entries", async () => {
// ext-apps treats a listing entry's `_meta.ui` as the static default for a
// UI resource, so the App has to surface the listing to the bridge — the
// factory's own tests inject the dep and cannot see this wiring break.
const listedMeta = {
ui: { csp: { connectDomains: ["https://api.example.com"] } },
};
vi.mocked(useManagedResources).mockReturnValue(
managedResourcesResult([
{ uri: "ui://weather/app.html", name: "app", _meta: listedMeta },
]),
);
renderWithMantine(<App />);
// App builds two factories, differing only in `advertiseElicitation`. Wait
// for BOTH by that flag rather than for a count: waiting on "at least one"
// would still pass with the wiring stripped from either call site, since
// the other's deps sit in the same array.
const appsFactory = () =>
appBridgeFactoryDeps.find((d) => !d.advertiseElicitation);
const elicitationFactory = () =>
appBridgeFactoryDeps.find((d) => d.advertiseElicitation === true);
await waitFor(() => {
expect(appsFactory()).toBeDefined();
expect(elicitationFactory()).toBeDefined();
});
for (const deps of [appsFactory(), elicitationFactory()]) {
expect(deps?.getListedResourceMeta?.("ui://weather/app.html")).toEqual(
listedMeta,
);
expect(
deps?.getListedResourceMeta?.("ui://other/app.html"),
).toBeUndefined();
}
});
it("hands BOTH bridge factories a working publishAppDocument (#2056)", async () => {
// Both call sites pass the same `publishDocument`, and the end-to-end smoke
// only covers the Apps-screen one — so without this the elicitation
// factory's `publishAppDocument` could be dropped and every test stays
// green while a domain-declaring elicitation app silently loses its real
// origin. Assert it is not merely present but actually reaches the lib.
publishAppDocumentMock.mockResolvedValue(
"http://127.0.0.1:6278/app-document/deadbeef",
);
renderWithMantine(<App />);
const appsFactory = () =>
appBridgeFactoryDeps.find((d) => !d.advertiseElicitation);
const elicitationFactory = () =>
appBridgeFactoryDeps.find((d) => d.advertiseElicitation === true);
await waitFor(() => {
expect(appsFactory()).toBeDefined();
expect(elicitationFactory()).toBeDefined();
});
for (const deps of [appsFactory(), elicitationFactory()]) {
publishAppDocumentMock.mockClear();
await expect(
deps?.publishAppDocument?.({
html: "<p>app</p>",
csp: "default-src 'none'",
}),
).resolves.toBe("http://127.0.0.1:6278/app-document/deadbeef");
expect(publishAppDocumentMock).toHaveBeenCalledWith(
{ html: "<p>app</p>", csp: "default-src 'none'" },
expect.objectContaining({ baseUrl: expect.any(String) }),
);
}
});
});

View file

@ -25,6 +25,7 @@ import type {
LoggingMessageNotification,
Progress,
ProgressToken,
Resource,
Task,
Tool,
} from "@modelcontextprotocol/client";
@ -98,7 +99,10 @@ import {
} from "@inspector/core/auth/index.js";
import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useServers } from "@inspector/core/react/useServers.js";
import {
ServerListReloadError,
useServers,
} from "@inspector/core/react/useServers.js";
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
@ -144,6 +148,7 @@ import {
import { clearScrollMemory } from "./hooks/useScrollMemory";
import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer";
import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory";
import { publishAppDocument } from "./lib/publishAppDocument";
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
import {
AppElicitationController,
@ -386,7 +391,7 @@ async function replayProtocolRequest(
const EMPTY_SETTINGS: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
@ -784,10 +789,45 @@ function App() {
});
}, [configBaseUrl]);
// The `resources/list` entries, read lazily by the App bridge factories below.
// ext-apps treats a listing entry's `_meta.ui` as the static default for its
// UI resource (a read content item's own `_meta.ui` wins), so the sandbox CSP
// has to be able to see it. A ref rather than a dependency: `resources` is
// derived further down this component, and the factories only read it inside
// an async sandboxready handler, long after any render that produced it.
const listedResourcesRef = useRef<Resource[]>([]);
// Best-effort by construction: this reads the list as it stands when the app
// opens, and does not distinguish "no entry for this URI" from "the list
// hasn't arrived yet". Both yield no hints, which is the same outcome as
// having no listing carrier at all. Blocking the render on list readiness
// instead would mean waiting on a request that, for a server advertising no
// `resources` capability or whose list errored, never resolves — trading a
// missing default for an app that never renders.
const getListedResourceMeta = useCallback(
(uri: string) =>
listedResourcesRef.current.find((r) => r.uri === uri)?._meta,
[],
);
// `_meta.ui.domain` support (#2056): hand a wrapped app document to the
// backend so it can serve it from a dedicated origin, giving the app's
// requests a real `Origin`. Resolves `null` on any backend that can't, and
// the factory then renders the app the default (opaque-origin) way.
const publishDocument = useCallback(
(doc: { html: string; csp?: string }) =>
publishAppDocument(doc, {
baseUrl: configBaseUrl,
authToken: getAuthToken(),
}),
[configBaseUrl],
);
const sandboxBridgeFactory = useMemo(
() =>
createAppBridgeFactory({
publishAppDocument: publishDocument,
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
getListedResourceMeta,
readResource: async (uri) => {
if (!inspectorClient) throw new Error("No MCP client connected.");
const invocation = await inspectorClient.readResource(uri);
@ -807,7 +847,7 @@ function App() {
});
},
}),
[inspectorClient],
[inspectorClient, getListedResourceMeta, publishDocument],
);
// App-rendered form elicitations (#1854). The controller is created once and
@ -860,7 +900,9 @@ function App() {
() =>
createAppBridgeFactory({
advertiseElicitation: true,
publishAppDocument: publishDocument,
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
getListedResourceMeta,
readResource: async (uri) => {
if (!inspectorClient) throw new Error("No MCP client connected.");
const invocation = await inspectorClient.readResource(uri);
@ -877,7 +919,7 @@ function App() {
});
},
}),
[inspectorClient],
[inspectorClient, getListedResourceMeta, publishDocument],
);
/**
* Close the previous client's session and open one for the client being
@ -1238,6 +1280,14 @@ function App() {
const tools = toolsPagination.items;
const prompts = promptsPagination.items;
const resources = resourcesPagination.items;
// Whatever the resource list currently holds — every page in the default
// aggregate mode, only the pages fetched so far under `paginatedLists`. The
// listing is a documented *default* that a read content item overrides, and
// an app whose entry sits on an unfetched page simply falls back to no hints
// (`connect-src 'none'`), exactly as before this wiring existed. Walking the
// whole list to close that would issue the very requests the user opted out
// of by turning pagination on, so the setting wins.
listedResourcesRef.current = resources;
const {
tasks,
refresh: refreshTasks,
@ -2536,13 +2586,9 @@ function App() {
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
// Flatten the persisted settings into the InspectorClient options shape.
// Empty / zero values stay unset so the SDK defaults apply.
const defaultMetadata = savedSettings?.metadata
? Object.fromEntries(
savedSettings.metadata
.filter((m) => m.key.trim() !== "")
.map((m) => [m.key, m.value]),
)
: undefined;
// Per-server default `_meta` is already a JSON object (#1910) — no
// pair-array flattening left to do; `{}` means "no defaults".
const defaultMetadata = savedSettings?.metadata;
const serverAuthorizationParams = savedSettings
? oauthAuthorizationParamsFromSettings(savedSettings)
: undefined;
@ -3835,30 +3881,46 @@ function App() {
// are ordered by when they were issued rather than by which one's list
// reload finished first (#2089).
const write = lastPersistedSettings.begin(activeServerId);
// This value is on disk now. Remember it as the rollback baseline for
// whatever is written next, since the `servers` entry it was derived
// from will keep describing the old value if the reload behind this
// write — or any later one — fails (#2089).
//
// Re-apply it when this write is the settled one: an overlapping toggle
// that failed *first* rolled the UI and the live client back to a
// baseline this write has since replaced, and if the list read behind
// this write failed too, nothing else would ever correct them. Through
// the *current* client, not this continuation's closure: a reconnect to
// the same server passes the id check while the captured instance is
// already destroyed.
const settlePaginationWrite = () => {
const settled = write.landed(next);
if (settled && activeServerIdRef.current === activeServerId) {
setPaginatedListsOverride(next.paginatedLists ?? false);
applyLiveServerSettings(next);
}
};
void refreshingPersist(updateServerSettings, refreshInitialConfig)(
activeServerId,
next,
)
.then(() => {
// This value is on disk now. Remember it as the rollback baseline for
// whatever is written next, since the `servers` entry it was derived
// from will keep describing the old value if the reload behind this
// write — or any later one — fails (#2089).
//
// Re-apply it when this write is the settled one: an overlapping
// toggle that failed *first* rolled the UI and the live client back
// to a baseline this write has since replaced, and if the list read
// behind this write failed too, nothing else would ever correct them.
// Through the *current* client, not this continuation's closure: a
// reconnect to the same server passes the id check while the captured
// instance is already destroyed.
const settled = write.landed(next);
if (settled && activeServerIdRef.current === activeServerId) {
setPaginatedListsOverride(next.paginatedLists ?? false);
applyLiveServerSettings(next);
}
})
.then(settlePaginationWrite)
.catch((err: unknown) => {
// A `ServerListReloadError` means the PUT landed and only reading the
// list back failed, so the new setting IS on disk (#1914). That is a
// landed write, not a failed one: rolling back would put the UI and
// the live client on the *old* value and contradict disk. Settle it
// exactly as the success path does and report only the failed reload.
if (err instanceof ServerListReloadError) {
settlePaginationWrite();
notifications.show({
title:
"Pagination setting saved, but the server list did not reload",
message: err.message,
color: "red",
});
return;
}
// This write is over and never reached disk, so it stops counting as
// in flight: an earlier write still running is the settled state once
// it lands, and is what re-applies the UI this rollback is about to
@ -4186,8 +4248,20 @@ function App() {
// opens (see the menu handlers).
const addServerHighlighted = useCallback(
async (id: string, config: MCPServerConfig) => {
await addServer(id, config);
setHighlightedServerIds((ids) => (ids.includes(id) ? ids : [...ids, id]));
const markAdded = () =>
setHighlightedServerIds((ids) =>
ids.includes(id) ? ids : [...ids, id],
);
try {
await addServer(id, config);
} catch (err) {
// A failed list reload still means the row is on disk, so it belongs
// in the highlight batch — the next successful refresh is what
// renders it, and it would otherwise arrive unmarked (#1914 r2).
if (err instanceof ServerListReloadError) markAdded();
throw err;
}
markAdded();
},
[addServer],
);
@ -4197,10 +4271,24 @@ function App() {
async (id: string, config: MCPServerConfig) => {
if (configModal?.mode === "edit" && configModal.targetId) {
const originalId = configModal.targetId;
await updateServer(originalId, id, config);
if (originalId === activeServerId && id !== originalId) {
setActiveServerId(id);
const followRename = () => {
if (originalId === activeServerId && id !== originalId) {
setActiveServerId(id);
}
};
try {
await updateServer(originalId, id, config);
} catch (err) {
// A `ServerListReloadError` means the PUT landed and only reading
// the list back failed — the rename IS on disk. The active
// selection has to follow it anyway, or the next successful
// refresh leaves `activeServerId` pointing at an id that no longer
// exists (#1914 r2). Still rethrow, so the modal shows the reload
// error rather than closing as if nothing happened.
if (err instanceof ServerListReloadError) followRename();
throw err;
}
followRename();
return;
}
// add or clone
@ -4225,6 +4313,30 @@ function App() {
return servers.find((s) => s.id === configModal.targetId);
}, [configModal, servers]);
// An edit/clone modal whose target has left the list can no longer render a
// coherent form: `initialId`/`initialConfig` go undefined, and
// ServerConfigModal's own `useValueChange` reset then blanks the open form
// *and* clears the error it is showing.
//
// Only reachable since #1914 made the modal stay open on a failed post-write
// reload: rename A → B, the PUT lands, the reload fails, and the modal sits
// there targeting A. The write itself is what triggers the backend's change
// broadcast, so the SSE-driven background refresh that lands B (and drops A)
// is the *likely* next event, not a remote one. Closing is right — the edit
// is on disk and the list now agrees — and it covers the ordinary case too,
// where an external mcp.json edit removes the server being edited.
//
// Adjusted during render rather than in an effect (see `useValueChange`), so
// no frame ever paints the blanked form.
useValueChange(
configModal?.mode === "add" || configModal?.targetId === undefined
? true
: servers.some((s) => s.id === configModal.targetId),
(targetPresent) => {
if (!targetPresent) setConfigModal(null);
},
);
const settingsModalTarget = useMemo(() => {
if (!settingsModalTargetId) return undefined;
return servers.find((s) => s.id === settingsModalTargetId);
@ -4275,12 +4387,27 @@ function App() {
// flush can put two saves for one server in flight, and the later-issued
// one describes disk however the two happen to finish (#2089).
const write = lastPersistedSettings.begin(id);
const settleSettingsWrite = () => {
const settled = write.landed(value);
if (settled && activeServerIdRef.current === id) {
setPaginatedListsOverride(value.paginatedLists ?? false);
applyLiveServerSettings(value);
}
};
try {
await refreshingPersist(updateServerSettings, refreshInitialConfig)(
id,
value,
);
} catch (err) {
// A `ServerListReloadError` means the PUT landed and only reading the
// list back failed, so this write did reach disk (#1914): record it
// like the success path below instead of rolling anything back, and
// rethrow so `onError` can say the reload — not the save — failed.
if (err instanceof ServerListReloadError) {
settleSettingsWrite();
throw err;
}
// Stop counting as in flight before the rejection goes on to
// `onError` — an earlier write still running settles the state once it
// lands, and cannot know that while this one looks pending (#2089).
@ -4307,11 +4434,7 @@ function App() {
// toggle failing while this save is in flight rolls the UI and the live
// client back to a baseline this save then replaces on disk, so a settled
// save has to re-apply itself exactly as a settled toggle does (#2089).
const settled = write.landed(value);
if (settled && activeServerIdRef.current === id) {
setPaginatedListsOverride(value.paginatedLists ?? false);
applyLiveServerSettings(value);
}
settleSettingsWrite();
},
// Surface failures via toast — the modal usually closes
// immediately on user dismiss, so a silent fail-on-flush would
@ -4319,7 +4442,14 @@ function App() {
// didn't (especially painful for the OAuth client secret).
onError: (id, err) => {
notifications.show({
title: `Failed to save settings for "${id}"`,
// A `ServerListReloadError` means the PUT landed and only reading the
// list back failed, so "Failed to save" would be false — and this
// toast is the user's only signal here, since the modal has usually
// closed by the time a flush rejects (#1914 r3).
title:
err instanceof ServerListReloadError
? `Saved settings for "${id}", but the server list did not reload`
: `Failed to save settings for "${id}"`,
message: err instanceof Error ? err.message : String(err),
color: "red",
});

View file

@ -1,5 +1,5 @@
import { createRef, StrictMode } from "react";
import { act } from "@testing-library/react";
import { act, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult, Tool } from "@modelcontextprotocol/client";
@ -546,15 +546,30 @@ describe("AppRenderer", () => {
// MutationObserver callbacks are delivered on a microtask.
await Promise.resolve();
});
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
expect.objectContaining({
theme: "dark",
styles: expect.objectContaining({
variables: expect.objectContaining({
"--color-background-primary": "#101113",
// `waitFor`, not a bare assertion after the single microtask above: how
// many ticks happy-dom takes to deliver the MutationObserver callback is
// load-dependent, so under a full parallel run the observer can still be
// pending here. Observed failing exactly once that way, with 0 calls.
//
// The default 1000ms then turned out not to be enough either — it failed
// twice in a row in `npm run ci` (at ~1021ms, still 0 calls) while
// passing every time this file runs alone, which is the signature of a
// budget that is too tight rather than of a broken observer. Raised
// rather than retried: a `waitFor` that is generous costs nothing on the
// passing path, since it returns as soon as the assertion holds.
await waitFor(
() =>
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
expect.objectContaining({
theme: "dark",
styles: expect.objectContaining({
variables: expect.objectContaining({
"--color-background-primary": "#101113",
}),
}),
}),
}),
}),
),
{ timeout: 5000 },
);
} finally {
getComputedStyleSpy.mockRestore();

View file

@ -83,21 +83,37 @@ function makeIframe(hasWindow = true): HTMLIFrameElement {
const fakeClient = { name: "sdk-client" } as unknown as Client;
/**
* A UI resource read result. `meta` is the MCP Apps sandbox metadata, which the
* spec nests under the `_meta` bag's `ui` key — the helper wraps it so every
* test drives the real wire shape. `opts.at` places the bag on the result
* envelope (`McpUiReadResourceResult`) instead of the content item, and
* `opts.flat` writes it unnested (the non-conforming shape the host ignores).
*/
function uiResource(
text: string | undefined,
meta?: Record<string, unknown>,
opts: { at?: "content" | "result"; flat?: boolean } = {},
): ReadResourceResult {
const bag = meta ? { _meta: opts.flat ? meta : { ui: meta } } : {};
const onResult = opts.at === "result";
return {
...(onResult ? bag : {}),
contents: [
{
uri: "ui://weather/app.html",
...(text === undefined ? {} : { text }),
...(meta ? { _meta: meta } : {}),
...(onResult ? {} : bag),
},
],
} as ReadResourceResult;
}
/** The `_meta` of a `resources/list` entry, as `getListedResourceMeta` returns it. */
function listedMeta(meta: Record<string, unknown>): { ui: unknown } {
return { ui: meta };
}
async function flush(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
@ -230,6 +246,378 @@ describe("createAppBridgeFactory", () => {
});
});
it("falls back to the read result's _meta.ui when the content item carries none", async () => {
// `McpUiReadResourceResult` — what a registerAppResource callback returns —
// types `_meta.ui` at the envelope level, so a server stamping it there
// must have it honored.
const readResource = vi.fn().mockResolvedValue(
uiResource(
"<h1>x</h1>",
{
permissions: { microphone: {} },
csp: { connectDomains: ["https://envelope.example.com"] },
},
{ at: "result" },
),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
permissions: unknown;
};
expect(call.permissions).toEqual({ microphone: {} });
expect(call.html).toContain("connect-src https://envelope.example.com");
});
it("prefers the read result's _meta.ui over the listing entry's", async () => {
// Precedence runs most-specific first: content item, then the result
// envelope, then the listing default.
const readResource = vi
.fn()
.mockResolvedValue(
uiResource(
"<h1>x</h1>",
{ csp: { connectDomains: ["https://envelope.example.com"] } },
{ at: "result" },
),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
getListedResourceMeta: () =>
listedMeta({ csp: { connectDomains: ["https://listed.example.com"] } }),
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
};
expect(call.html).toContain("connect-src https://envelope.example.com");
expect(call.html).not.toContain("listed.example.com");
});
it("falls back to the resources/list entry's _meta.ui when the content block carries none", async () => {
// ext-apps documents the listing-level `_meta.ui` as the static default for
// a UI resource, so an app declaring its CSP only there must still have it
// honored.
const readResource = vi.fn().mockResolvedValue(uiResource("<h1>x</h1>"));
const getListedResourceMeta = vi.fn().mockReturnValue(
listedMeta({
permissions: { camera: {} },
csp: { connectDomains: ["https://api.example.com"] },
}),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
getListedResourceMeta,
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
expect(getListedResourceMeta).toHaveBeenCalledWith("ui://weather/app.html");
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
permissions: unknown;
};
expect(call.permissions).toEqual({ camera: {} });
expect(call.html).toContain("connect-src https://api.example.com");
});
it("prefers the read content item's _meta.ui over the listing entry's", async () => {
// The listing value is only a default: a content item that carries its own
// `_meta.ui` wins outright, rather than being merged with it.
const readResource = vi.fn().mockResolvedValue(
uiResource("<h1>x</h1>", {
csp: { connectDomains: ["https://read.example.com"] },
}),
);
const getListedResourceMeta = vi.fn().mockReturnValue(
listedMeta({
permissions: { camera: {} },
csp: { connectDomains: ["https://listed.example.com"] },
}),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
getListedResourceMeta,
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
permissions: unknown;
};
expect(call.html).toContain("connect-src https://read.example.com");
expect(call.html).not.toContain("listed.example.com");
expect(call.permissions).toBeUndefined();
});
it("renders with no sandbox hints when neither the content item nor the listing has _meta.ui", async () => {
// A host with no resource listing (or one not covering this URI) is
// unaffected: the optional dep is simply absent.
const readResource = vi.fn().mockResolvedValue(uiResource("<h1>x</h1>"));
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
getListedResourceMeta: () => undefined,
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
permissions: unknown;
};
expect(call.permissions).toBeUndefined();
expect(call.html).toContain("connect-src &#39;none&#39;");
});
it("prefers the content item's _meta.ui over both broader carriers", async () => {
// Pins the full three-way order — content item > result envelope > listing
// — which no single-fallback case can: swapping the first two operands
// would still satisfy them.
const result = uiResource("<h1>x</h1>", {
csp: { connectDomains: ["https://content.example.com"] },
});
(result as { _meta?: unknown })._meta = {
ui: { csp: { connectDomains: ["https://envelope.example.com"] } },
};
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi.fn().mockResolvedValue(result),
getListedResourceMeta: () =>
listedMeta({ csp: { connectDomains: ["https://listed.example.com"] } }),
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
};
expect(call.html).toContain("connect-src https://content.example.com");
expect(call.html).not.toContain("envelope.example.com");
expect(call.html).not.toContain("listed.example.com");
});
it("stops at a carrier whose _meta.ui is malformed rather than falling through", async () => {
// A declared-but-unusable `ui` is that carrier's answer. Falling through
// would grant whatever a BROADER carrier asked for — wrong precedence, and
// the wrong direction to fail in.
const readResource = vi
.fn()
.mockResolvedValue(
uiResource("<h1>x</h1>", { ui: null }, { flat: true }),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
getListedResourceMeta: () =>
listedMeta({
permissions: { camera: {} },
csp: { connectDomains: ["https://listed.example.com"] },
}),
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
permissions: unknown;
};
expect(call.permissions).toBeUndefined();
expect(call.html).not.toContain("listed.example.com");
expect(call.html).toContain("connect-src &#39;none&#39;");
});
it("ignores sandbox metadata written unnested on _meta", async () => {
// `McpUiResourceMeta` describes the value of `_meta.ui`, not `_meta`. A bag
// whose keys sit at the top level is not the spec shape, so it must not be
// read as one — reading it there is what produced #2055 in reverse.
const readResource = vi
.fn()
.mockResolvedValue(
uiResource(
"<h1>x</h1>",
{ csp: { connectDomains: ["https://api.example.com"] } },
{ flat: true },
),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
};
expect(call.html).not.toContain("api.example.com");
// The policy is rendered into an HTML attribute, so quotes arrive escaped.
expect(call.html).toContain("connect-src &#39;none&#39;");
});
describe("_meta.ui.domain — dedicated origin (#2056)", () => {
// An app rendered under the default opaque origin sends `Origin: null`, so
// no CORS / OAuth-callback / API-key allowlist can admit it. `domain` is
// how a server asks its host for a real one; the Inspector answers with a
// URL on its own app-origin listener and passes it as `src`.
it("publishes the wrapped document and passes its URL as `src`", async () => {
const readResource = vi.fn().mockResolvedValue(
uiResource("<h1>weather</h1>", {
domain: "my-app.example.com",
csp: { connectDomains: ["https://api.example.com"] },
}),
);
const publishAppDocument = vi
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
publishAppDocument,
});
await factory(makeIframe(), { kind: "tool", tool });
bridgeInstances[0].emit("sandboxready");
await flush();
// What is published is the SAME wrapped document the srcdoc path would
// have rendered, plus the policy as a separate string so the backend can
// serve it as a real response header.
const published = publishAppDocument.mock.calls[0][0];
expect(published.html).toContain("<body><h1>weather</h1></body>");
expect(published.csp).toContain("connect-src https://api.example.com");
const call = bridgeInstances[0].sendSandboxResourceReady.mock
.calls[0][0] as { html: string; src?: string };
expect(call.src).toBe("http://localhost:6278/app-document/abc");
// `html` still rides along: a proxy that ignores `src` renders the app
// rather than a blank frame.
expect(call.html).toBe(published.html);
});
it.each([
["an empty domain", ""],
["a whitespace-only domain", " "],
["a non-string domain", 42],
])("does not take the dedicated path for %s", async (_label, domain) => {
const publishAppDocument = vi
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi
.fn()
.mockResolvedValue(uiResource("<h1>x</h1>", { domain })),
publishAppDocument,
});
await factory(makeIframe(), { kind: "tool", tool });
bridgeInstances[0].emit("sandboxready");
await flush();
expect(publishAppDocument).not.toHaveBeenCalled();
expect(
bridgeInstances[0].sendSandboxResourceReady.mock.calls[0][0],
).not.toHaveProperty("src");
});
it("never publishes a resource that declares no domain", async () => {
const publishAppDocument = vi
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
publishAppDocument,
});
await factory(makeIframe(), { kind: "tool", tool });
bridgeInstances[0].emit("sandboxready");
await flush();
expect(publishAppDocument).not.toHaveBeenCalled();
});
it("falls back to srcdoc, warning, when the backend cannot host the document", async () => {
// A backend with no app-origin listener. Losing the real origin degrades
// what the app can reach and the developer needs to see why — losing the
// app itself would be worse.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi
.fn()
.mockResolvedValue(
uiResource("<h1>x</h1>", { domain: "my-app.example.com" }),
),
publishAppDocument: vi.fn().mockResolvedValue(null),
});
await factory(makeIframe(), { kind: "tool", tool });
bridgeInstances[0].emit("sandboxready");
await flush();
const call = bridgeInstances[0].sendSandboxResourceReady.mock
.calls[0][0] as { html: string; src?: string };
expect(call.src).toBeUndefined();
expect(call.html).toContain("<body><h1>x</h1></body>");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("_meta.ui.domain"),
);
} finally {
warnSpy.mockRestore();
}
});
it("falls back to srcdoc when the factory was given no publisher at all", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi
.fn()
.mockResolvedValue(
uiResource("<h1>x</h1>", { domain: "my-app.example.com" }),
),
});
await factory(makeIframe(), { kind: "tool", tool });
bridgeInstances[0].emit("sandboxready");
await flush();
expect(
bridgeInstances[0].sendSandboxResourceReady.mock.calls[0][0],
).not.toHaveProperty("src");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("_meta.ui.domain"),
);
} finally {
warnSpy.mockRestore();
}
});
});
it("does not mutate the shared HOST_CAPABILITIES when echoing the approved sandbox", async () => {
// The factory builds a per-app copy ({ ...HOST_CAPABILITIES }) so the
// sandbox echo never leaks across apps/renders. Lock that in: after a

View file

@ -7,6 +7,7 @@ import type {
McpUiDisplayMode,
McpUiHostCapabilities,
McpUiResourceMeta,
McpUiSandboxResourceReadyNotification,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Client } from "@modelcontextprotocol/client";
import type {
@ -17,6 +18,7 @@ import type {
} from "@modelcontextprotocol/client";
import {
approveCspSources,
approveSandboxPermissions,
buildSandboxCspPolicy,
wrapSandboxedHtml,
} from "../../../utils/sandbox-csp";
@ -88,6 +90,15 @@ export interface AppBridgeFactoryDeps {
* frame; the error is also always console.error'd.
*/
onResourceError?: (err: Error) => void;
/**
* The `_meta` of the `resources/list` entry for `uri`, when the host has that
* listing. ext-apps treats the listing-level `_meta.ui` as the static default
* for a UI resource, so an app that declares its CSP only there must still
* get it honored; a read content item's own `_meta.ui` wins over it. Omitted
* (or returning undefined) simply means no default — the host that has no
* resource listing, or whose listing does not include this URI, is unaffected.
*/
getListedResourceMeta?: (uri: string) => object | undefined;
/**
* Advertise `hostCapabilities.elicitation` — "this host can forward a
* form-mode `elicitation/create` to the app and return its result to the
@ -98,25 +109,101 @@ export interface AppBridgeFactoryDeps {
* there would tell the app something untrue about what its host will do.
*/
advertiseElicitation?: boolean;
/**
* Publish a wrapped app document to the backend's dedicated app-origin
* listener and resolve with the URL to load it from, or `null` when the
* backend can't host one (#2056).
*
* Only consulted for a UI resource that declares `_meta.ui.domain` — the
* spec field by which a server asks its host for a stable, dedicated origin
* so its requests carry a real `Origin` instead of `null`. Optional: a
* factory without it (and a `null` result from one with it) renders every
* app the default way, under an opaque origin.
*/
publishAppDocument?: (doc: {
html: string;
csp?: string;
}) => Promise<string | null>;
}
/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */
function extractHtmlAndMeta(result: ReadResourceResult): {
/**
* The MCP Apps sandbox hints (`csp`, `permissions`, …) a single `_meta` bag
* declares under its `ui` key, or undefined when that bag declares none.
*
* The two "no hints" cases are deliberately distinguished by the return type:
* a bag with no `ui` key at all returns undefined (this carrier is silent —
* keep looking), while a bag whose `ui` is malformed returns `{ meta:
* undefined }` (this carrier spoke, and what it said is unusable — stop). Were
* they conflated, a content item declaring `ui: null` would fall through to a
* broader carrier and be granted whatever *that* one asked for, which is both
* the wrong precedence and the wrong direction to fail in.
*
* `McpUiResourceMeta` describes the value of `_meta.ui`, NOT `_meta` itself —
* reading `_meta` directly leaves every field undefined, so a spec-conforming
* app's declared `connectDomains` never reach the generated CSP and it renders
* under `connect-src 'none'` (#2055). This mirrors the tool side, where
* `getToolUiResourceUri` already reads `_meta.ui.resourceUri`.
*/
function uiMeta(
bag: object | undefined,
): { meta: McpUiResourceMeta | undefined } | undefined {
if (bag === undefined || !("ui" in bag)) return undefined;
const ui: unknown = (bag as { ui: unknown }).ui;
const wellFormed =
ui !== null && typeof ui === "object" && !Array.isArray(ui);
return { meta: wellFormed ? (ui as McpUiResourceMeta) : undefined };
}
/**
* First text content block of a UI resource, plus its `_meta.ui` sandbox hints.
*
* ext-apps exposes three carriers, and they are consulted most-specific first:
*
* 1. the **read content item**'s own `_meta.ui`;
* 2. the **read result envelope**'s — `McpUiReadResourceResult` (what a
* `registerAppResource` callback returns) types `_meta.ui` at that level;
* 3. the matching **`resources/list` entry**'s, which `McpUiAppResourceConfig`
* documents as the static default a host reviews at connection time, and
* which a read content item explicitly takes precedence over.
*
* The first carrier that *declares* a `ui` key decides, and its value is used
* outright — the levels are not merged, since a server that restates only
* `csp` at the more specific level means that to be the whole grant, not a
* patch over the broader one. A carrier declaring a malformed `ui` decides
* too, for "no hints"; see {@link uiMeta}.
*/
function extractHtmlAndMeta(
result: ReadResourceResult,
listedMeta?: object,
): {
html: string;
meta: McpUiResourceMeta | undefined;
} {
for (const content of result.contents) {
const text = (content as { text?: unknown }).text;
if (typeof text === "string") {
return {
html: text,
meta: content._meta as McpUiResourceMeta | undefined,
};
const carried =
uiMeta(content._meta) ?? uiMeta(result._meta) ?? uiMeta(listedMeta);
return { html: text, meta: carried?.meta };
}
}
throw new Error("UI resource has no text (HTML) content");
}
/**
* Whether a UI resource asked for a dedicated origin (#2056).
*
* Any non-empty `domain` string counts. The spec is explicit that the field's
* format is host-dependent — hosts publish their own rules, and the examples it
* gives (`{hash}.claudemcpcontent.com`, `www-example-com.oaiusercontent.com`)
* are each one host's convention. The Inspector's convention is that the value
* is a *request*, not an address: it grants a real loopback origin and does not
* try to honor the specific name, which it could not serve anyway.
*/
function wantsDedicatedOrigin(meta: McpUiResourceMeta | undefined): boolean {
return typeof meta?.domain === "string" && meta.domain.trim() !== "";
}
/**
* Decode a base64-encoded blob resource into bytes for download. Allocates the
* backing store explicitly so the return type is `Uint8Array<ArrayBuffer>`
@ -271,7 +358,10 @@ export function createAppBridgeFactory(
const uri = resolveSourceUri(source);
if (!uri) return;
const result = await deps.readResource(uri);
const { html, meta } = extractHtmlAndMeta(result);
const { html, meta } = extractHtmlAndMeta(
result,
deps.getListedResourceMeta?.(uri),
);
// Build the per-app CSP host-side: filter the requested sources to
// ones the host accepts, render the policy string, and wrap the
// app's HTML in a fixed shell whose first <head> child is the CSP
@ -283,24 +373,59 @@ export function createAppBridgeFactory(
// sendSandboxResourceReady: the view only sends ui/initialize once it
// has the HTML, so the bridge reflects this in the initialize result.
const approvedCsp = approveCspSources(meta?.csp);
// NOTE on the CSP-vs-permissions asymmetry: `csp` is injection-filtered
// by approveCspSources because its sources are interpolated into the
// CSP <meta> content string. `permissions` is NOT filtered here — it is
// a structured object (camera/microphone/geolocation/clipboardWrite
// booleans), and its only consumer is the sandbox proxy's
// buildAllowAttribute(), which maps each known key to a fixed
// Permissions-Policy token and ignores anything else. Untrusted values
// therefore can't reach the iframe `sandbox`/`allow` attribute as raw
// text (that layer, and the allow-same-origin strip, is owned by the
// sandbox-hardening work in #1565), so no source-style allowlist applies.
// Both halves of the app-supplied sandbox config are screened, for
// different reasons. `csp` is injection-filtered by approveCspSources
// because its sources are interpolated into the CSP <meta> content
// string. `permissions` can't reach the `sandbox`/`allow` attribute as
// raw text — the proxy maps each known key to a fixed Permissions-Policy
// token — but it tests those keys for TRUTHINESS, so a non-boolean
// (`camera: "false"`) would read as a grant; approveSandboxPermissions
// reduces the bag to the keys actually requested. The `allow-same-origin`
// strip and the rest of that layer stay owned by #1565.
const approvedPermissions = approveSandboxPermissions(
meta?.permissions,
);
hostCapabilities.sandbox = {
permissions: meta?.permissions,
permissions: approvedPermissions,
csp: approvedCsp,
};
await bridge.sendSandboxResourceReady({
html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)),
permissions: meta?.permissions,
});
const policy = buildSandboxCspPolicy(approvedCsp);
const wrapped = wrapSandboxedHtml(html, policy);
// `_meta.ui.domain` (#2056): the resource is asking to be served
// from a stable, dedicated origin rather than rendered into the
// default opaque-origin srcdoc frame, because an opaque origin sends
// `Origin: null` and no CORS / OAuth-callback / API-key allowlist can
// admit that. The spec makes the field's format host-dependent and
// the Inspector owns no domain infrastructure, so what it honors the
// request WITH is a real loopback origin — any non-empty domain opts
// in, and the string itself is not interpreted (see the app-origin
// controller for the host-specific contract).
//
// A `null` back means the backend has no such listener (an older
// backend, or one whose port never bound). Fall back to srcdoc: the
// app loses its real origin, which is a degradation the developer
// can see and act on — losing the app entirely is not.
const dedicated = wantsDedicatedOrigin(meta);
const src = dedicated
? await deps.publishAppDocument?.({ html: wrapped, csp: policy })
: undefined;
if (dedicated && !src) {
console.warn(
"[mcp-app] resource declares _meta.ui.domain but no dedicated app origin is available; rendering under an opaque origin (requests will send `Origin: null`).",
);
}
// `src` is an Inspector-specific extension to the (internal)
// sandbox-resource-ready params, which ext-apps types but does not
// validate — the bridge forwards the params object verbatim. Declared
// as an intersection rather than cast so the extra key stays typed.
const readyParams: McpUiSandboxResourceReadyNotification["params"] & {
src?: string;
} = {
html: wrapped,
permissions: approvedPermissions,
...(src ? { src } : {}),
};
await bridge.sendSandboxResourceReady(readyParams);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
console.error(

View file

@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { EnlargeButton } from "./EnlargeButton";
const meta: Meta<typeof EnlargeButton> = {
title: "Elements/EnlargeButton",
component: EnlargeButton,
args: { onClick: () => {} },
};
export default meta;
type Story = StoryObj<typeof EnlargeButton>;
// Default: labelled "Enlarge", reachable by keyboard, and clickable.
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole("button", { name: "Enlarge" });
await expect(button).not.toHaveAttribute("tabindex", "-1");
await userEvent.click(button);
},
};
// A per-field accessible name, for a form where several fields each carry one.
export const CustomLabel: Story = {
args: { ariaLabel: "Enlarge Query" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByRole("button", { name: "Enlarge Query" }),
).toBeVisible();
},
};

View file

@ -0,0 +1,46 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { EnlargeButton } from "./EnlargeButton";
describe("EnlargeButton", () => {
it('renders with the "Enlarge" accessible name', () => {
renderWithMantine(<EnlargeButton onClick={vi.fn()} />);
expect(screen.getByRole("button", { name: "Enlarge" })).toBeInTheDocument();
});
it("stays in the keyboard tab order", () => {
renderWithMantine(<EnlargeButton onClick={vi.fn()} />);
expect(screen.getByRole("button", { name: "Enlarge" })).not.toHaveAttribute(
"tabindex",
"-1",
);
});
it("invokes onClick when clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
renderWithMantine(<EnlargeButton onClick={onClick} />);
await user.click(screen.getByRole("button", { name: "Enlarge" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("allows the accessible name to be overridden", () => {
renderWithMantine(
<EnlargeButton onClick={vi.fn()} ariaLabel="Enlarge Query" />,
);
expect(
screen.getByRole("button", { name: "Enlarge Query" }),
).toBeInTheDocument();
});
it("is inert when the field it belongs to is disabled", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
renderWithMantine(<EnlargeButton onClick={onClick} disabled />);
const button = screen.getByRole("button", { name: "Enlarge" });
expect(button).toBeDisabled();
await user.click(button);
expect(onClick).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,59 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { ImEnlarge2 } from "react-icons/im";
export interface EnlargeButtonProps {
/** Swap the single-line input this sits in for a multiline text area. */
onClick: () => void;
/**
* Overrides the accessible name (aria-label), which defaults to the tooltip
* text ("Enlarge"). Pass a per-field name (e.g. including the field's label)
* when several inputs in one form each carry a button, so assistive tech can
* tell them apart; the visible tooltip stays the plain verb.
*/
ariaLabel?: string;
/**
* Mirrors the disabled state of the input this sits in. A live button on a
* disabled field would still swap in the text area — which mounts disabled and
* cannot take focus, dropping keyboard focus to the document.
*/
disabled?: boolean;
}
const EnlargeActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "sm",
});
/**
* The enlarge affordance shown to the left of a text input's ClearButton
* (#2042). Clicking it replaces the single-line input with a multiline text
* area, so a value containing newlines can be typed — a plain `<input>` swallows
* Enter, which left string arguments effectively single-line.
*
* Deliberately not a toggle: shrinking back would have to decide what to do with
* the newlines already typed, and either answer (silently discard them, or keep
* a value the field can no longer display) is worse than staying enlarged.
*
* Unlike ClearButton it stays in the keyboard tab order. Clearing a field has a
* keyboard equivalent (select-all, delete), so #1487 could take that button out
* of the tab order without cost; entering multiline mode has none, so removing
* this one would put the feature out of reach of keyboard users entirely.
*/
export function EnlargeButton({
onClick,
ariaLabel,
disabled,
}: EnlargeButtonProps) {
return (
<Tooltip label="Enlarge">
<EnlargeActionIcon
aria-label={ariaLabel ?? "Enlarge"}
disabled={disabled}
onClick={onClick}
>
<ImEnlarge2 size={12} />
</EnlargeActionIcon>
</Tooltip>
);
}

View file

@ -0,0 +1,217 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import { JsonObjectInput } from "./JsonObjectInput";
const LABEL = "Request metadata JSON";
/**
* The component is controlled, so every story drives it through a small
* stateful wrapper — otherwise the editor would not accept a keystroke and the
* play functions could not exercise the draft behavior at all.
*
* These stories run in real Chromium, which is what makes them the *only*
* place Ace's keyboard behavior can be tested: the unit suite runs in
* happy-dom, where Ace's hidden-textarea input path does not work at all.
*/
function ControlledJsonObjectInput({ initial }: { initial: StrictJsonObject }) {
const [value, setValue] = useState<StrictJsonObject>(initial);
return (
<JsonObjectInput
label="Request metadata"
ariaLabel={LABEL}
value={value}
onChange={setValue}
/>
);
}
const meta: Meta<typeof ControlledJsonObjectInput> = {
title: "Elements/JsonObjectInput",
component: ControlledJsonObjectInput,
};
export default meta;
type Story = StoryObj<typeof ControlledJsonObjectInput>;
/** The live editor instance, which Ace hangs off its container as `env.editor`. */
function aceEditor(canvasElement: HTMLElement): {
focus(): void;
selectAll(): void;
getValue(): string;
session: { getAnnotations(): { row: number; type: string }[] };
} {
const container = canvasElement.querySelector(".ace_editor");
const editor = (
container as
| (HTMLElement & {
env?: {
editor?: {
focus(): void;
selectAll(): void;
getValue(): string;
session: { getAnnotations(): { row: number; type: string }[] };
};
};
})
| null
)?.env?.editor;
if (!editor) throw new Error("Ace editor not mounted");
return editor;
}
/**
* Focus the editor and empty it, so the next keystrokes are the whole document
* and the typing itself — the thing under test — is left to `userEvent`.
*
* Emptied rather than merely selected: typing an opening brace or quote with a
* selection makes Ace **wrap** the selection instead of replacing it, so typing
* `{` over a selected `{}` yields `{{}}` and the assertion stops being about
* auto-closing at all.
*
* Focus and select go through Ace's API on purpose. `userEvent.click` does not
* focus the editor — Ace's input is a 1px offscreen textarea, so the click
* lands nowhere and the keystrokes reach the body instead. And select-all is
* not `Control+A` everywhere (Ace binds `Cmd-A` on mac), so pressing it would
* make these stories pass or fail by host OS.
*/
async function focusAndClear(canvasElement: HTMLElement): Promise<void> {
const editor = aceEditor(canvasElement);
editor.focus();
editor.selectAll();
await userEvent.keyboard("{Backspace}");
}
/** Ace renders each token in its own element, so read the document, not text. */
function editorText(canvasElement: HTMLElement): string {
return aceEditor(canvasElement).getValue();
}
/** A structured payload renders as formatted JSON, nesting intact. */
export const Populated: Story = {
args: {
initial: {
tenant: "acme",
trace: { id: "abc123", sampled: true },
features: ["apps", "tasks"],
},
},
play: async ({ canvasElement }) => {
await expect(JSON.parse(editorText(canvasElement))).toEqual({
tenant: "acme",
trace: { id: "abc123", sampled: true },
features: ["apps", "tasks"],
});
},
};
/** An empty payload opens as `{}` and carries no error. */
export const Empty: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(editorText(canvasElement)).toBe("{}");
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
},
};
/**
* Opening a brace closes it — one of the two reasons this is an editor rather
* than a textarea, and the thing that makes hand-writing a nested `_meta`
* payload bearable.
*
* Deliberately one keystroke: Ace also auto-pairs quotes and skips over a
* closing character you type yourself, so a longer sequence asserts the sum of
* several behaviours and stops being a readable statement about this one.
*/
export const ClosesBracesWhileTyping: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await focusAndClear(canvasElement);
// `{{` is userEvent's escape for a literal `{`.
await userEvent.keyboard("{{");
// Ace does not close it on the keystroke — an opening brace at the end of a
// line is often the start of a block the user is about to fill in.
await expect(editorText(canvasElement)).toBe("{");
// Enter is what completes the pair, leaving the cursor indented between
// them. This is the behaviour that makes writing nested `_meta` by hand
// bearable, and the reason this is an editor rather than a textarea.
await userEvent.keyboard("{Enter}");
await expect(editorText(canvasElement)).toBe("{\n \n}");
// The result parses, so nothing is flagged.
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
},
};
/**
* Half-typed text is displayed as typed and flagged, rather than being
* re-escaped back into the box each keystroke (the #1928 failure this draft
* split exists to avoid).
*/
export const InvalidDraft: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await focusAndClear(canvasElement);
// Genuinely half-typed — the state a user is in partway through writing an
// entry, rather than a contrived bad string.
await userEvent.keyboard('{{"a":');
// Shown exactly as typed. The old textarea re-escaped its own contents on
// every keystroke here, which is what made it unusable (#1928).
await expect(editorText(canvasElement)).toBe('{"a":');
await expect(await canvas.findByText(/Not valid JSON/)).toBeVisible();
},
};
/**
* Valid JSON that is not an object is rejected with its own message, distinct
* from unparseable text.
*
* A bare number rather than an array: brackets are auto-paired too, so typing
* one would test the pairing as much as the rejection.
*/
export const NotAnObject: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await focusAndClear(canvasElement);
await userEvent.keyboard("42");
await expect(editorText(canvasElement)).toBe("42");
await expect(
await canvas.findByText(/Must be a JSON object/),
).toBeVisible();
},
};
/**
* The JSON worker marks the offending line in the gutter — the reason this
* component registers `worker-json` through `ace.config.setModuleUrl` with a
* Vite `?url` asset.
*
* Asserted because nothing else covers it: the worker runs out-of-process, so
* if its URL registration breaks, Ace fails to start it and silently falls back
* to no annotations. Every other test here — including the wrapper's own
* "Not valid JSON" message, which comes from our parser, not from Ace — would
* still pass while the gutter diagnostics quietly disappeared.
*/
export const AnnotatesTheOffendingLine: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
await focusAndClear(canvasElement);
// A trailing comma on line 2: valid-looking until the closing brace, so the
// worker has to locate it rather than just reject the document.
await userEvent.keyboard('{{"a": 1,{Enter}}');
await waitFor(
async () => {
const annotations = aceEditor(canvasElement).session.getAnnotations();
await expect(annotations.length).toBeGreaterThan(0);
await expect(annotations.some((a) => a.type === "error")).toBe(true);
},
// The worker is asynchronous and debounced, so this needs a real wait
// rather than a tick.
{ timeout: 5000 },
);
},
};

View file

@ -0,0 +1,260 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import { act, screen } from "@testing-library/react";
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
import { JsonObjectInput } from "./JsonObjectInput";
const LABEL = "Payload JSON";
/** Controlled harness: the component never owns the value, only the draft. */
function Harness({
initial,
onChange,
}: {
initial: StrictJsonObject;
onChange?: (v: StrictJsonObject) => void;
}) {
const [value, setValue] = useState<StrictJsonObject>(initial);
return (
<>
<JsonObjectInput
ariaLabel={LABEL}
value={value}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
/>
<output data-testid="emitted">{JSON.stringify(value)}</output>
</>
);
}
describe("JsonObjectInput", () => {
it("names the editor for assistive tech", () => {
renderWithMantine(<Harness initial={{}} />);
// Ace composes "<label>, Cursor at row N", so the match is by substring —
// the position readout is Ace's, the name is ours.
expect(screen.getByLabelText(aceLabel(LABEL))).toBeInTheDocument();
});
it("uses Ace's dark theme under a dark color scheme", () => {
// The editor picks its Ace theme from the computed scheme rather than
// inheriting Mantine tokens, so the dark branch is only exercised by
// actually rendering dark.
renderWithMantine(<Harness initial={{}} />, { colorScheme: "dark" });
// `ace-github-dark`, hyphenated — Ace's cssClass, not the module name
// (`theme-github_dark`). The App.css gutter-contrast override keys off this
// same class, so getting it wrong silently drops the fix in dark mode.
expect(document.querySelector(".ace-github-dark")).not.toBeNull();
expect(document.querySelector(".ace-github")).toBeNull();
});
it("uses Ace's light theme under a light color scheme", () => {
renderWithMantine(<Harness initial={{}} />, { colorScheme: "light" });
expect(document.querySelector(".ace-github")).not.toBeNull();
expect(document.querySelector(".ace-github_dark")).toBeNull();
});
it("opens with the value serialized two-space, nesting intact", () => {
renderWithMantine(<Harness initial={{ a: { b: [1, 2] } }} />);
expect(getAceText()).toBe(
'{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}',
);
});
it("emits the parsed object — a structured value stays structured", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
await setAceText('{"trace":{"id":"x"},"n":3}');
expect(onChange).toHaveBeenLastCalledWith({ trace: { id: "x" }, n: 3 });
});
// Also the regression test for the paired-event bug `handleChange` coalesces:
// `setValue` is a remove followed by an insert, and acting on the remove
// emitted `{}` and wiped `keep` before the insert had landed.
it("shows the text as typed while it is invalid and does not emit", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
await setAceText('{"a":');
// Displayed verbatim — not re-escaped back through JSON.stringify.
expect(getAceText()).toBe('{"a":');
expect(
screen.getByText("Not valid JSON — changes are not applied"),
).toBeInTheDocument();
// Nothing emitted, so the caller still holds the last valid object.
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByTestId("emitted").textContent).toBe('{"keep":"me"}');
});
it("flags valid JSON that is not an object without emitting", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
await setAceText("[1]");
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it("treats a cleared editor as the empty object", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
await setAceText("");
expect(onChange).toHaveBeenLastCalledWith({});
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
});
it("re-syncs when the parent's value diverges from the draft", async () => {
// An external reset (switching servers, a reloaded catalog) must reach the
// editor; an in-progress edit must not be overwritten by what it emitted.
function Externally() {
const [value, setValue] = useState<StrictJsonObject>({ a: 1 });
return (
<>
<JsonObjectInput
ariaLabel={LABEL}
value={value}
onChange={setValue}
/>
<button onClick={() => setValue({ b: 2 })}>reset</button>
</>
);
}
renderWithMantine(<Externally />);
await act(async () => {
screen.getByRole("button", { name: "reset" }).click();
});
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
});
it("applies an external value it showed before (A → B → A)", async () => {
// Regression: `echoed` used to hold only what this component last emitted,
// so returning to a previously-shown value matched the stale entry and was
// dismissed as our own echo, stranding the editor on B.
function Externally({ value }: { value: StrictJsonObject }) {
return (
<JsonObjectInput ariaLabel={LABEL} value={value} onChange={() => {}} />
);
}
const { rerender } = renderWithMantine(<Externally value={{ a: 1 }} />);
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
rerender(<Externally value={{ b: 2 }} />);
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
// Back to the first value — a distinct object, same content.
rerender(<Externally value={{ a: 1 }} />);
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
});
describe("wiring to the Input.Wrapper label and error", () => {
/** Ace's hidden textarea — the element the wrapper has to point at. */
function textarea(): HTMLTextAreaElement {
const el = document.querySelector<HTMLTextAreaElement>(
"textarea.ace_text-input",
);
if (!el) throw new Error("Ace text input not mounted");
return el;
}
it("gives two instances distinct DOM ids", () => {
// react-ace uses `name` as the container id; a fixed value would put
// duplicate ids in the document as soon as a second editor renders.
renderWithMantine(
<>
<JsonObjectInput ariaLabel="First" value={{}} onChange={() => {}} />
<JsonObjectInput ariaLabel="Second" value={{}} onChange={() => {}} />
</>,
);
const containerIds = Array.from(
document.querySelectorAll(".ace_editor"),
).map((n) => n.id);
const inputIds = Array.from(
document.querySelectorAll("textarea.ace_text-input"),
).map((n) => n.id);
expect(containerIds).toHaveLength(2);
expect(new Set([...containerIds, ...inputIds]).size).toBe(4);
expect(containerIds.concat(inputIds).every((id) => id !== "")).toBe(true);
});
it("lets the visible label focus the editor", () => {
renderWithMantine(
<JsonObjectInput
label="Request metadata"
ariaLabel={LABEL}
value={{}}
onChange={() => {}}
/>,
);
// `Input.Wrapper` renders `<label for={id}>`; the editor is only
// reachable from it if Ace's textarea carries that same id.
const label = screen.getByText("Request metadata");
expect(label.getAttribute("for")).toBe(textarea().id);
expect(textarea().id).not.toBe("");
});
it("marks the editor invalid and points it at the error text", async () => {
renderWithMantine(<Harness initial={{}} />);
expect(textarea().getAttribute("aria-invalid")).toBeNull();
await setAceText("[1]");
expect(textarea().getAttribute("aria-invalid")).toBe("true");
const describedBy = textarea().getAttribute("aria-describedby");
expect(describedBy).not.toBeNull();
// The id must resolve to the message actually on screen.
expect(document.getElementById(describedBy!)?.textContent).toMatch(
/Must be a JSON object/,
);
});
it("announces a supplied description, and keeps it when the draft breaks", async () => {
function WithDescription() {
const [value, setValue] = useState<StrictJsonObject>({});
return (
<JsonObjectInput
ariaLabel={LABEL}
description="Sent with every request"
value={value}
onChange={setValue}
/>
);
}
renderWithMantine(<WithDescription />);
const described = () =>
(textarea().getAttribute("aria-describedby") ?? "")
.split(" ")
.filter(Boolean)
.map((id) => document.getElementById(id)?.textContent);
// Valid draft: described by the description alone.
expect(described()).toEqual(["Sent with every request"]);
// Invalid draft: the error joins it rather than replacing it — the
// description is still true, and dropping it would silently un-describe
// the control at the moment it needs explaining most.
await setAceText("[1]");
expect(described()).toHaveLength(2);
expect(described()[0]).toBe("Sent with every request");
expect(described()[1]).toMatch(/Must be a JSON object/);
});
it("clears the invalid marking once the text parses again", async () => {
renderWithMantine(<Harness initial={{}} />);
await setAceText("[1]");
expect(textarea().getAttribute("aria-invalid")).toBe("true");
await setAceText('{"a":1}');
expect(textarea().getAttribute("aria-invalid")).toBeNull();
expect(textarea().getAttribute("aria-describedby")).toBeNull();
});
});
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
renderWithMantine(<Harness initial={{}} />);
// The parent re-renders with the object this text parsed to; the draft must
// not be reformatted out from under the cursor.
await setAceText('{"a":1}');
expect(getAceText()).toBe('{"a":1}');
});
});

View file

@ -0,0 +1,251 @@
import { useEffect, useId, useRef, useState } from "react";
import { Input, useComputedColorScheme } from "@mantine/core";
import type { Ace } from "ace-builds";
import AceEditor from "react-ace";
import ace from "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import "ace-builds/src-noconflict/theme-github_dark";
// `?url` so Vite emits the worker as its own asset and hands back a real URL.
// Without it Ace fetches `worker-json.js` from a path that does not exist in a
// bundled app, silently loses its annotations, and logs a fetch error.
import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json.js?url";
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import { useValueChange } from "../../../hooks/useValueChange";
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
// Module scope: registering the worker URL is global to Ace and idempotent, so
// it must not be repeated per mount.
ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl);
/** Two-space canonical form — the same shape the catalog file is written in. */
function serialize(value: StrictJsonObject): string {
return JSON.stringify(value, null, 2);
}
export interface JsonObjectInputProps {
value: StrictJsonObject;
onChange: (value: StrictJsonObject) => void;
label?: string;
description?: string;
/**
* Accessible name for the editor's text input. Ace labels its hidden
* textarea "Cursor at row N" by default, which is a position readout rather
* than a name — so this is what makes the control identifiable to a screen
* reader, and what `getByLabelText` finds in a test.
*/
ariaLabel: string;
/** Rows shown before the editor grows; it autosizes between these bounds. */
minLines?: number;
maxLines?: number;
}
/**
* A JSON-object editor: the user types JSON, the parent receives the parsed
* object.
*
* This is the editor for payloads whose *values* may be any JSON, which is why
* it exists beside the key/value row editors used for headers and env. `_meta`
* is the case that forced it (#1910) — nothing in the MCP spec restricts a
* `_meta` value to a string, so a `{ key, value }` row editor cannot express
* what the protocol allows.
*
* Built on Ace rather than a plain textarea because hand-writing JSON in a bare
* box is the actual pain: Ace closes a brace when you open one, folds nested
* objects, and — via its JSON worker — marks the offending line in the gutter
* instead of reporting one message for the whole document.
*
* The text the user is typing is the source of truth for what is *displayed*;
* the parent only ever sees a parsed object. That split is what makes the box
* typeable: driving it off the parent's value and stringifying on every render
* re-escapes half-typed text once per keystroke, which is the #1928 symptom.
* `SchemaJsonField` in `SchemaForm.tsx` carries the same split for the same
* reason — see the long comment there.
*
* While the text is invalid the parent is **not** told, so the last valid
* object stands. The alternative — emitting `{}` — would silently discard a
* user's configured metadata the moment they typed a stray character, and this
* form has no Save button to gate instead: `onChange` writes straight through.
* The state is not silent, though: Ace flags the position in the gutter, and
* the wrapper repeats the reason underneath.
*/
export function JsonObjectInput({
value,
onChange,
label,
description,
ariaLabel,
minLines = 6,
maxLines = 24,
}: JsonObjectInputProps) {
const [draft, setDraft] = useState(() => serialize(value));
// The canonical JSON of the last value this component emitted.
//
// The re-sync below has to tell an *external* change apart from the parent
// echoing back what we just sent it, and the draft alone cannot: while the
// text is invalid it parses to nothing, so every parent change would look
// external and overwrite what is being typed.
//
// That is not hypothetical. Ace's `setValue` — which is what a select-all
// paste and any programmatic replace go through — fires *two* change events,
// a remove then an insert. The remove leaves an empty document, which is a
// valid `{}` and gets emitted; the insert then leaves invalid text. Comparing
// against the draft, the `{}` echo reads as an external reset and wipes the
// pasted text a keystroke after it lands.
const [echoed, setEchoed] = useState(() => serialize(value));
// `getInitialValueInEffect: false` so the first render already has the real
// scheme; otherwise the editor mounts light and repaints, which is visible.
const colorScheme = useComputedColorScheme("light", {
getInitialValueInEffect: false,
});
// Re-sync only on a value this component did not produce, so an external
// reset (switching servers, a reloaded catalog) reaches the editor while an
// in-progress edit is left alone. Compared through canonical JSON because the
// parent hands back a fresh object each time.
// Text from the most recent change event in the current tick, and whether a
// flush is already queued to consume it. Two refs rather than one nullable
// one so the flush reads a plain `string` — folding them together would add
// a null check that nothing can ever satisfy. See `handleChange`.
const pendingTextRef = useRef("");
const flushScheduledRef = useRef(false);
/**
* Coalesce the change events Ace fires within one edit, and act on the last.
*
* A replace — select-all-and-retype, or a paste over a selection — is a
* *remove* followed by an *insert*, and Ace fires a change event for each.
* Acting on the first would read the momentarily-empty document as the user
* clearing the field, emit `{}` to the parent, and so wipe their saved
* metadata the instant they selected it. If the replacement text then failed
* to parse, `{}` is what they would be left with.
*
* A microtask is enough because both events are dispatched synchronously
* inside the same edit, so the flush sees only the settled text.
*/
const handleChange = (text: string) => {
pendingTextRef.current = text;
if (flushScheduledRef.current) return;
flushScheduledRef.current = true;
queueMicrotask(() => {
flushScheduledRef.current = false;
const settled = pendingTextRef.current;
setDraft(settled);
const result = parseJsonObjectDraft(settled);
if (!result.ok) return;
setEchoed(serialize(result.value));
onChange(result.value);
});
};
const wrapperId = useId();
const editorRef = useRef<Ace.Editor | null>(null);
const parsed = parseJsonObjectDraft(draft);
useValueChange(serialize(value), (next) => {
if (next === echoed) return;
setDraft(next);
// `echoed` has to advance to the value now on screen, not just to what this
// component last emitted. Leaving it behind makes an external A → B → A
// sequence stick on B: the second A matches the stale `echoed` from before
// B arrived and is dismissed as our own echo.
setEchoed(next);
});
// `Input.Wrapper` labels and describes a Mantine input through context. Ace
// is not one — it renders its own DOM — so the wrapper's label and error node
// exist but point at nothing, leaving the control unassociated with the error
// a screen reader needs and its visible label inert on click.
//
// A genuine external-system sync, so an effect rather than `useValueChange`:
// it writes to a DOM node React does not own. Giving the textarea the
// wrapper's own id is what makes the `<label for>` focus the editor; the
// error wiring follows Mantine's `${id}-error` naming.
useEffect(() => {
const element = editorRef.current?.textInput.getElement();
/* v8 ignore next -- the ref is set in `onLoad`, which react-ace calls
synchronously on mount, so this effect always sees an element. */
if (!element) return;
element.id = wrapperId;
// Both the description and the error are rendered by the wrapper and both
// describe this control, so both ids belong here — a supplied description
// is otherwise never announced, and it must not be dropped just because the
// draft went invalid. Mantine renders each only when its prop is set, so
// referencing an absent one would point at nothing.
const describedBy = [
description === undefined ? null : `${wrapperId}-description`,
parsed.ok ? null : `${wrapperId}-error`,
].filter((id) => id !== null);
if (describedBy.length > 0) {
element.setAttribute("aria-describedby", describedBy.join(" "));
} else {
element.removeAttribute("aria-describedby");
}
if (parsed.ok) {
element.removeAttribute("aria-invalid");
} else {
element.setAttribute("aria-invalid", "true");
}
}, [wrapperId, parsed.ok, description]);
return (
<Input.Wrapper
id={wrapperId}
label={label}
description={description}
error={parsed.ok ? undefined : parsed.error}
w="100%"
>
<AceEditor
mode="json"
theme={colorScheme === "dark" ? "github_dark" : "github"}
// react-ace uses `name` as the editor container's DOM id, so a fixed
// value would collide the moment two of these render — and it must also
// differ from the id given to the textarea below, which is the one the
// wrapper's `<label for>` points at.
name={`${wrapperId}-editor`}
value={draft}
onChange={handleChange}
width="100%"
minLines={minLines}
maxLines={maxLines}
tabSize={2}
showPrintMargin={false}
editorProps={{ $blockScrolling: Infinity }}
onLoad={(editor) => {
editorRef.current = editor;
// `textInputAriaLabel` alone is not enough: Ace composes the hidden
// textarea's label as "<label>, Cursor at row N" inside
// `TextInput.setAriaLabel`, and only recomputes it when the cursor
// moves — so an option applied at mount does not reach the DOM until
// the user clicks into the editor. Until then the control's only
// accessible name is the position readout, which names nothing.
// Setting it here and recomputing once is what makes the editor
// identifiable from first paint.
editor.setOption("textInputAriaLabel", ariaLabel);
editor.textInput.setAriaLabel();
}}
setOptions={{
// The JSON worker is what puts a marker on the offending line rather
// than one message for the whole document.
useWorker: true,
// Closes a brace/bracket/quote when you open one, and skips over the
// closing one you then type. On by default; named because it is half
// of why this component exists.
behavioursEnabled: true,
// Roving-tabindex arrow-key navigation plus an Esc-to-exit-trap, so
// the editor does not swallow Tab for keyboard users.
enableKeyboardAccessibility: true,
textInputAriaLabel: ariaLabel,
showFoldWidgets: true,
displayIndentGuides: true,
useSoftTabs: true,
scrollPastEnd: false,
}}
/>
</Input.Wrapper>
);
}

View file

@ -24,7 +24,7 @@ describe("SchemaForm", () => {
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
const input = screen.getByLabelText(/Name/);
const input = screen.getByRole("textbox", { name: /Name/ });
await user.type(input, "a");
expect(onChange).toHaveBeenCalledWith({ name: "a" });
});
@ -669,7 +669,7 @@ describe("SchemaForm", () => {
);
expect(screen.getByText("Address")).toBeInTheDocument();
expect(screen.getByText("Street and city")).toBeInTheDocument();
expect(screen.getByLabelText(/Street/)).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: /Street/ })).toBeInTheDocument();
});
it("propagates nested object changes back to top-level onChange", async () => {
@ -690,7 +690,7 @@ describe("SchemaForm", () => {
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.type(screen.getByLabelText(/Street/), "1");
await user.type(screen.getByRole("textbox", { name: /Street/ }), "1");
expect(onChange).toHaveBeenCalled();
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(lastCall.address).toEqual({ street: "1" });
@ -881,7 +881,7 @@ describe("SchemaForm", () => {
disabled
/>,
);
expect(screen.getByLabelText(/Name/)).toBeDisabled();
expect(screen.getByRole("textbox", { name: /Name/ })).toBeDisabled();
expect(screen.getByLabelText("Active")).toBeDisabled();
});
@ -895,7 +895,9 @@ describe("SchemaForm", () => {
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/rawField/)).toBeInTheDocument();
expect(
screen.getByRole("textbox", { name: /rawField/ }),
).toBeInTheDocument();
});
it("renders nothing inside the form when properties are missing", () => {
@ -1027,7 +1029,7 @@ describe("SchemaForm nullable unions", () => {
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.type(screen.getByLabelText(/Note/), "a");
await user.type(screen.getByRole("textbox", { name: /Note/ }), "a");
expect(onChange).toHaveBeenCalledWith({ note: "a" });
});
@ -1096,7 +1098,7 @@ describe("SchemaForm nullable unions", () => {
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.type(screen.getByLabelText(/Nick/), "z");
await user.type(screen.getByRole("textbox", { name: /Nick/ }), "z");
expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } });
});
@ -1202,7 +1204,7 @@ describe("SchemaForm nullable unions", () => {
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Mode/).tagName).toBe("INPUT");
expect(screen.getByRole("textbox", { name: /Mode/ }).tagName).toBe("INPUT");
});
it("still renders a MultiSelect when every anyOf branch has a distinct const", () => {
@ -1541,7 +1543,9 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
<EscapingHarness schema={schema} initial={{ note: "" }} />,
);
const jsonInput = screen.getByLabelText(/Note/) as HTMLTextAreaElement;
const jsonInput = screen.getByRole("textbox", {
name: /Note/,
}) as HTMLTextAreaElement;
await user.click(jsonInput);
await user.keyboard("{End}a");
@ -1790,3 +1794,179 @@ describe("SchemaForm defaulted fields (#2026)", () => {
expect(jsonInput.value).toBe('[\n "z"\n]');
});
});
// A plain `<input>` swallows Enter, so a string argument could not be given a
// value containing newlines (#2042). Each string field carries an enlarge button
// that swaps it for a text area, one-way.
describe("SchemaForm multiline strings (#2042)", () => {
const stringSchema: InspectorFormSchema = {
type: "object",
properties: {
note: { type: "string", title: "Note", maxLength: 40 },
},
};
function StringHarness({ resetKey }: { resetKey?: string }) {
const [values, setValues] = useState<Record<string, unknown>>({});
return (
<SchemaForm
schema={stringSchema}
values={values}
onChange={setValues}
resetKey={resetKey}
/>
);
}
it("renders a single-line input with an enlarge button by default", () => {
renderWithMantine(<StringHarness />);
expect(screen.getByRole("textbox", { name: /Note/ }).tagName).toBe("INPUT");
expect(
screen.getByRole("button", { name: "Enlarge Note" }),
).toBeInTheDocument();
});
it("swaps the input for a text area that accepts newlines", async () => {
const user = userEvent.setup();
renderWithMantine(<StringHarness />);
await user.click(screen.getByRole("button", { name: "Enlarge Note" }));
const textarea = screen.getByRole("textbox", {
name: /Note/,
}) as HTMLTextAreaElement;
expect(textarea.tagName).toBe("TEXTAREA");
await user.type(textarea, "one{Enter}two");
expect(textarea.value).toBe("one\ntwo");
});
it("carries the constraints of the field it replaced", async () => {
const user = userEvent.setup();
renderWithMantine(<StringHarness />);
await user.click(screen.getByRole("button", { name: "Enlarge Note" }));
expect(screen.getByRole("textbox", { name: /Note/ })).toHaveAttribute(
"maxlength",
"40",
);
});
it("is one-way — the enlarge button is gone once used", async () => {
const user = userEvent.setup();
renderWithMantine(<StringHarness />);
await user.click(screen.getByRole("button", { name: "Enlarge Note" }));
expect(
screen.queryByRole("button", { name: "Enlarge Note" }),
).not.toBeInTheDocument();
});
it("keeps the clear button working after enlarging", async () => {
const user = userEvent.setup();
renderWithMantine(<StringHarness />);
await user.type(screen.getByRole("textbox", { name: /Note/ }), "typed");
await user.click(screen.getByRole("button", { name: "Enlarge Note" }));
const textarea = screen.getByRole("textbox", {
name: /Note/,
}) as HTMLTextAreaElement;
expect(textarea.value).toBe("typed");
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(textarea.value).toBe("");
});
it("shows no clear button until the field holds a value", async () => {
const user = userEvent.setup();
renderWithMantine(<StringHarness />);
expect(
screen.queryByRole("button", { name: "Clear" }),
).not.toBeInTheDocument();
await user.type(screen.getByRole("textbox", { name: /Note/ }), "x");
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
// Enlarging belongs to the field the user enlarged, not to its name — the
// same reasoning resetKey documents for the number field's draft.
it("goes back to a single-line input when resetKey moves to another entity", async () => {
const user = userEvent.setup();
const { rerender } = renderWithMantine(<StringHarness resetKey="tool-a" />);
await user.click(screen.getByRole("button", { name: "Enlarge Note" }));
expect(screen.getByRole("textbox", { name: /Note/ }).tagName).toBe(
"TEXTAREA",
);
rerender(<StringHarness resetKey="tool-b" />);
expect(screen.getByRole("textbox", { name: /Note/ }).tagName).toBe("INPUT");
});
it("stays enlarged while resetKey is unchanged", async () => {
const user = userEvent.setup();
const { rerender } = renderWithMantine(<StringHarness resetKey="tool-a" />);
await user.click(screen.getByRole("button", { name: "Enlarge Note" }));
rerender(<StringHarness resetKey="tool-a" />);
expect(screen.getByRole("textbox", { name: /Note/ }).tagName).toBe(
"TEXTAREA",
);
});
it("gives each field's enlarge button a distinct accessible name", () => {
renderWithMantine(
<SchemaForm
schema={{
type: "object",
properties: {
note: { type: "string", title: "Note" },
summary: { type: "string", title: "Summary" },
},
}}
values={{}}
onChange={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: "Enlarge Note" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Enlarge Summary" }),
).toBeInTheDocument();
});
// The button unmounts in the same commit that mounts the text area, so
// without an explicit hand-off a keyboard user is left focused on nothing.
it("moves focus into the text area, caret last, when activated", async () => {
const user = userEvent.setup();
renderWithMantine(<StringHarness />);
await user.type(screen.getByRole("textbox", { name: /Note/ }), "typed");
await user.tab();
expect(screen.getByRole("button", { name: "Enlarge Note" })).toHaveFocus();
await user.keyboard("{Enter}");
const textarea = screen.getByRole("textbox", {
name: /Note/,
}) as HTMLTextAreaElement;
expect(textarea.tagName).toBe("TEXTAREA");
expect(textarea).toHaveFocus();
expect(textarea.selectionStart).toBe("typed".length);
// And the caret really is at the end: typing appends rather than prepends.
await user.keyboard("{Enter}more");
expect(textarea.value).toBe("typed\nmore");
});
// A disabled form (a tool call in flight) must be inert as a whole: a live
// button there would swap in a text area that mounts disabled, cannot take
// focus, and so drops keyboard focus to the document.
it("disables the enlarge button along with the field", () => {
renderWithMantine(
<SchemaForm
schema={stringSchema}
values={{}}
onChange={vi.fn()}
disabled
/>,
);
expect(screen.getByRole("button", { name: "Enlarge Note" })).toBeDisabled();
});
});

View file

@ -1,15 +1,27 @@
import {
Checkbox,
Group,
JsonInput,
MultiSelect,
NumberInput,
Select,
Stack,
Text,
rem,
Textarea,
type TextareaProps,
TextInput,
} from "@mantine/core";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ChangeEvent,
} from "react";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { EnlargeButton } from "../../elements/EnlargeButton/EnlargeButton";
import { useValueChange } from "../../../hooks/useValueChange";
import type {
InspectorFormSchema,
@ -38,6 +50,66 @@ const SchemaJsonInput = JsonInput.withProps({
autosize: true,
});
// A string field after its enlarge button has been used (#2042). `autosize`
// grows it with the text rather than fixing a height the value may not fit, and
// `maxRows` caps that growth so a long value scrolls inside the field instead of
// pushing the rest of the form off screen.
const MultilineStringInput = Textarea.withProps({
autosize: true,
minRows: 3,
maxRows: 12,
rightSectionPointerEvents: "auto",
});
/**
* A string field that has just been enlarged (#2042).
*
* Exists only to take focus on mount. This component mounts as a direct
* consequence of the user activating the enlarge button — which unmounts in the
* same commit, taking the focused element with it. Without this, a keyboard user
* who activates it is left with focus on the document body, and the next Tab
* restarts from the top of the page rather than continuing through the form.
*
* The caret is placed at the end of whatever was already typed, since focusing a
* pre-filled text control does not agree across browsers on where it lands, and
* the one answer that is never right is "before the text the user just wrote".
*/
function EnlargedStringField(props: TextareaProps) {
const inputRef = useRef<HTMLTextAreaElement>(null);
// Layout, not passive: a passive effect runs after paint, so the browser would
// present at least one frame with focus on the document body — long enough for
// a fast Tab to land somewhere else entirely.
useLayoutEffect(() => {
const node = inputRef.current;
/* v8 ignore next -- an effect runs after mount, so the ref is always set */
if (!node) return;
node.focus();
node.setSelectionRange(node.value.length, node.value.length);
}, []);
return <MultilineStringInput {...props} ref={inputRef} />;
}
// Holds the buttons a string field stacks in its `rightSection`. `nowrap` keeps
// them on one line inside a section only wide enough for the two.
const FieldActions = Group.withProps({ gap: 2, wrap: "nowrap" });
// Width of the string field's right section, which Mantine takes as a number it
// cannot derive from its content. Built from the parts rather than written as a
// pixel total, so it stays right if a button or the gap changes, and emitted as
// `rem` so it tracks a user's root font size rather than pinning to CSS pixels.
// The two button widths are what they measure at their current sizes (ActionIcon
// `sm` and CloseButton's default); the inset keeps them off the field's edge.
const ENLARGE_WIDTH = 22;
const CLEAR_WIDTH = 28;
const ACTION_GAP = 2;
const SECTION_INSET = 6;
const ONE_ACTION_WIDTH = rem(CLEAR_WIDTH + SECTION_INSET);
const TWO_ACTION_WIDTH = rem(
ENLARGE_WIDTH + ACTION_GAP + CLEAR_WIDTH + SECTION_INSET,
);
function serializeJson(value: unknown): string {
return JSON.stringify(value, null, 2);
}
@ -507,6 +579,19 @@ export function SchemaForm({
() => new Set(),
);
// The names of string fields the user has enlarged into a multiline text area
// (#2042). One-way by design — see EnlargeButton — so a name only ever enters
// this set.
const [enlargedFields, setEnlargedFields] = useState<ReadonlySet<string>>(
() => new Set(),
);
// Enlarging is a property of the field the user enlarged, not of the field
// *name*, so it must not carry across to another entity's same-named field —
// the same reasoning `resetKey` documents for the number field's draft. Reset
// during render rather than in an effect so no frame paints the wrong shape.
useValueChange(resetKey, () => setEnlargedFields(new Set()));
// Stable so a field's reporting effect subscribes once, not per render. The
// updater returns the previous set unchanged when nothing moved, which is
// what makes a nested form's inline callback safe to call repeatedly.
@ -604,24 +689,54 @@ export function SchemaForm({
// plain string
if (fieldSchema.type === "string") {
const clearButton = rawValue ? (
<ClearButton onClick={() => handleFieldChange(fieldName, "")} />
) : null;
const sharedProps = {
label,
description,
withAsterisk: isRequired,
disabled,
value: (rawValue as string) ?? "",
minLength: fieldSchema.minLength,
maxLength: fieldSchema.maxLength,
onChange: (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => handleFieldChange(fieldName, event.currentTarget.value),
};
// Already enlarged: a text area, and no way back. The enlarge button is
// gone rather than disabled — there is nothing left for it to do.
if (enlargedFields.has(fieldName)) {
return (
<EnlargedStringField
key={fieldName}
{...sharedProps}
rightSectionWidth={ONE_ACTION_WIDTH}
rightSection={clearButton}
/>
);
}
return (
<TextInput
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
disabled={disabled}
value={(rawValue as string) ?? ""}
minLength={fieldSchema.minLength}
maxLength={fieldSchema.maxLength}
onChange={(event) =>
handleFieldChange(fieldName, event.currentTarget.value)
}
{...sharedProps}
rightSectionPointerEvents="auto"
rightSectionWidth={clearButton ? TWO_ACTION_WIDTH : ONE_ACTION_WIDTH}
rightSection={
rawValue ? (
<ClearButton onClick={() => handleFieldChange(fieldName, "")} />
) : null
<FieldActions>
<EnlargeButton
ariaLabel={`Enlarge ${label}`}
disabled={disabled}
onClick={() =>
setEnlargedFields(
(previous) => new Set([...previous, fieldName]),
)
}
/>
{clearButton}
</FieldActions>
}
/>
);

View file

@ -10,7 +10,7 @@ import {
const defaultSettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -57,34 +57,9 @@ function InteractiveRender(args: ServerSettingsFormProps) {
},
});
}}
onAddMetadata={() => {
args.onAddMetadata();
updateArgs({
settings: {
...args.settings,
metadata: [...args.settings.metadata, { key: "", value: "" }],
},
});
}}
onRemoveMetadata={(index) => {
args.onRemoveMetadata(index);
updateArgs({
settings: {
...args.settings,
metadata: args.settings.metadata.filter((_, i) => i !== index),
},
});
}}
onMetadataChange={(index, key, value) => {
args.onMetadataChange(index, key, value);
updateArgs({
settings: {
...args.settings,
metadata: args.settings.metadata.map((m, i) =>
i === index ? { key, value } : m,
),
},
});
onMetadataChange={(metadata) => {
args.onMetadataChange(metadata);
updateArgs({ settings: { ...args.settings, metadata } });
}}
onAddEnv={() => {
args.onAddEnv();
@ -197,8 +172,6 @@ const meta: Meta<typeof ServerSettingsForm> = {
onRemoveEnv: fn(),
onEnvChange: fn(),
onCwdChange: fn(),
onAddMetadata: fn(),
onRemoveMetadata: fn(),
onMetadataChange: fn(),
onTimeoutChange: fn(),
onAutoRefreshChange: fn(),
@ -278,10 +251,13 @@ export const AllConfigured: Story = {
{ key: "X-Request-Id", value: "req-456" },
],
env: [],
metadata: [
{ key: "userId", value: "user-789" },
{ key: "sessionId", value: "session-012" },
],
metadata: {
userId: "user-789",
sessionId: "session-012",
// A structured value — the case a `{ key, value }` row editor could
// not express (#1910).
client: { name: "inspector", features: ["apps", "tasks"], retries: 3 },
},
connectionTimeout: 15000,
requestTimeout: 45000,
taskTtl: 45000,

View file

@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { within } from "@testing-library/react";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText, setAceText } from "../../../test/aceEditor";
import {
ServerSettingsForm,
type ServerSettingsSection,
@ -22,7 +23,7 @@ function clearButtonFor(input: HTMLElement): HTMLElement {
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -33,7 +34,7 @@ const emptySettings: InspectorServerSettings = {
const populatedSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
metadata: { userId: "u-1", tenant: { id: 7, tags: ["a"] } },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -63,8 +64,6 @@ const baseHandlers = {
onRemoveEnv: vi.fn(),
onEnvChange: vi.fn(),
onCwdChange: vi.fn(),
onAddMetadata: vi.fn(),
onRemoveMetadata: vi.fn(),
onMetadataChange: vi.fn(),
onTimeoutChange: vi.fn(),
onAutoRefreshChange: vi.fn(),
@ -239,7 +238,7 @@ describe("ServerSettingsForm", () => {
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
});
it("shows empty hints for headers and metadata when no entries exist", () => {
it("shows the empty hint for headers, and an empty JSON object for metadata", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
@ -250,9 +249,9 @@ describe("ServerSettingsForm", () => {
expect(
screen.getByText("No custom headers configured"),
).toBeInTheDocument();
expect(
screen.getByText("No request metadata configured"),
).toBeInTheDocument();
// Metadata has no rows to be absent — the editor is always present, and
// "none configured" is spelled `{}` in it.
expect(getAceText()).toBe("{}");
});
it("invokes onAddHeader when + Add Header is clicked", async () => {
@ -270,21 +269,6 @@ describe("ServerSettingsForm", () => {
expect(onAddHeader).toHaveBeenCalledTimes(1);
});
it("invokes onAddMetadata when + Add Metadata is clicked", async () => {
const user = userEvent.setup();
const onAddMetadata = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onAddMetadata={onAddMetadata}
settings={emptySettings}
expandedSections={["metadata"]}
/>,
);
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
expect(onAddMetadata).toHaveBeenCalledTimes(1);
});
it("invokes onHeaderChange when typing in header value input", async () => {
const user = userEvent.setup();
const onHeaderChange = vi.fn();
@ -340,29 +324,34 @@ describe("ServerSettingsForm", () => {
expect(onRemoveHeader).toHaveBeenCalledWith(0);
});
it("invokes onMetadataChange and onRemoveMetadata for metadata rows", async () => {
const user = userEvent.setup();
const onMetadataChange = vi.fn();
const onRemoveMetadata = vi.fn();
it("renders the configured metadata as formatted JSON, nested values included", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
onRemoveMetadata={onRemoveMetadata}
settings={populatedSettings}
expandedSections={["metadata"]}
/>,
);
const valueInput = screen.getByDisplayValue("u-1");
await user.type(valueInput, "Z");
expect(onMetadataChange).toHaveBeenCalled();
// The point of #1910: a nested object survives into the editor instead of
// being flattened into a `{ key, value }` string row.
expect(JSON.parse(getAceText())).toEqual({
userId: "u-1",
tenant: { id: 7, tags: ["a"] },
});
});
await user.click(
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
it("reports the whole edited object through onMetadataChange", async () => {
const onMetadataChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
settings={emptySettings}
expandedSections={["metadata"]}
/>,
);
expect(onRemoveMetadata).toHaveBeenCalledWith(0);
await setAceText('{"n":[1,2]}');
expect(onMetadataChange).toHaveBeenLastCalledWith({ n: [1, 2] });
});
it("invokes onTimeoutChange when typing in connection timeout", async () => {
@ -1470,23 +1459,6 @@ describe("ServerSettingsForm", () => {
expect(onClearStoredOAuth).toHaveBeenCalledTimes(1);
});
it("clears a metadata value via its Clear button (onMetadataChange with empty value)", async () => {
const user = userEvent.setup();
const onMetadataChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
settings={populatedSettings}
expandedSections={["metadata"]}
/>,
);
// populatedSettings has one metadata { key: "userId", value: "u-1" }
const valueInput = screen.getByDisplayValue("u-1");
await user.click(clearButtonFor(valueInput));
expect(onMetadataChange).toHaveBeenCalledWith(0, "userId", "");
});
it("omits the Clear buttons for an empty header key/value row", () => {
// A row whose key and value are both empty renders no Clear button in
// either field (the null branch of `item.key ?` / `item.value ?`).

View file

@ -4,6 +4,7 @@ import {
Alert,
Button,
Checkbox,
Code,
Flex,
Group,
NumberInput,
@ -13,11 +14,13 @@ import {
TextInput,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { JsonObjectInput } from "../../elements/JsonObjectInput/JsonObjectInput";
import type { ProtocolEra } from "@modelcontextprotocol/client";
import type {
InspectorServerSettings,
ModernLogLevel,
OAuthSettings,
RequestMetadata,
ServerProtocolEra,
ServerType,
} from "@inspector/core/mcp/types.js";
@ -78,9 +81,12 @@ export interface ServerSettingsFormProps {
onRemoveEnv: (index: number) => void;
onEnvChange: (index: number, key: string, value: string) => void;
onCwdChange: (value: string) => void;
onAddMetadata: () => void;
onRemoveMetadata: (index: number) => void;
onMetadataChange: (index: number, key: string, value: string) => void;
/**
* Replace the whole default-`_meta` payload. Not the `(index, key, value)`
* row callback the header/env editors use — metadata is edited as a JSON
* object, since a `_meta` value may be any JSON (#1910).
*/
onMetadataChange: (metadata: RequestMetadata) => void;
onTimeoutChange: (
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
value: number,
@ -434,8 +440,6 @@ export function ServerSettingsForm({
onRemoveEnv,
onEnvChange,
onCwdChange,
onAddMetadata,
onRemoveMetadata,
onMetadataChange,
onTimeoutChange,
onAutoRefreshChange,
@ -724,22 +728,23 @@ export function ServerSettingsForm({
<Accordion.Control>Request Metadata</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Group justify="space-between">
<HintText>
Metadata sent with every MCP request (included in _meta field)
</HintText>
<AddButton onClick={onAddMetadata}>+ Add Metadata</AddButton>
</Group>
{settings.metadata.length === 0 ? (
<EmptyHint>No request metadata configured</EmptyHint>
) : (
<KeyValueRows
items={settings.metadata}
entityLabel="metadata entry"
onChange={onMetadataChange}
onRemove={onRemoveMetadata}
/>
)}
<HintText>
Metadata merged into the <Code>_meta</Code> field of every MCP
request to this server, as a JSON object. Values may be any JSON —
objects, arrays, numbers, booleans, <Code>null</Code> — not just
strings. Leave it as <Code>{"{}"}</Code> to send none. The keys
the protocol reserves keep their own shape:{" "}
<Code>progressToken</Code> must be a string or a safe integer (one
JavaScript can represent exactly), and{" "}
<Code>io.modelcontextprotocol/related-task</Code> an object with a
string <Code>taskId</Code>. A reserved key that does not match is
dropped from the request rather than sent.
</HintText>
<JsonObjectInput
ariaLabel="Request metadata JSON"
value={settings.metadata}
onChange={onMetadataChange}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>

View file

@ -14,7 +14,7 @@ const initialSettings: InspectorServerSettings = {
{ key: "X-Request-Id", value: "req-456" },
],
env: [],
metadata: [{ key: "userId", value: "user-789" }],
metadata: { userId: "user-789", client: { name: "inspector" } },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -74,7 +74,7 @@ export const EmptySettings: Story = {
settings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -96,7 +96,7 @@ export const StdioServer: Story = {
{ key: "API_KEY", value: "abc-123" },
{ key: "LOG_LEVEL", value: "debug" },
],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,

View file

@ -2,12 +2,13 @@ import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { setAceText } from "../../../test/aceEditor";
import { ServerSettingsModal } from "./ServerSettingsModal";
const initialSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
metadata: { userId: "u-1" },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -21,7 +22,7 @@ const initialSettings: InspectorServerSettings = {
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -310,76 +311,6 @@ describe("ServerSettingsModal", () => {
expect(lastCall.headers[0].value).toContain("Bearer abc");
});
it("calls onSettingsChange when adding metadata after expanding the section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
metadata: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing metadata", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
// Both the header and metadata rows have a remove button; each is named
// for the row it belongs to, so no positional guess is needed.
await user.click(
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
metadata: [],
});
});
it("calls onSettingsChange when typing in metadata key/value", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
const valueInput = screen.getByDisplayValue("u-1");
await user.type(valueInput, "9");
expect(onSettingsChange).toHaveBeenCalled();
const lastCall =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(lastCall.metadata[0].key).toBe("userId");
});
it("calls onSettingsChange when changing timeouts", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
@ -703,20 +634,13 @@ describe("ServerSettingsModal", () => {
expect(lastCall.headers[1]).toEqual({ key: "B", value: "2" });
});
it("preserves sibling rows when editing one of several metadata entries", async () => {
it("replaces the whole metadata object when the JSON editor changes", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const twoMeta: InspectorServerSettings = {
...emptySettings,
metadata: [
{ key: "m1", value: "v1" },
{ key: "m2", value: "v2" },
],
};
renderWithMantine(
<ServerSettingsModal
opened
settings={twoMeta}
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
@ -724,9 +648,13 @@ describe("ServerSettingsModal", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
await user.type(screen.getByDisplayValue("v1"), "x");
await setAceText('{"trace":{"id":"abc"}}');
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.metadata[1]).toEqual({ key: "m2", value: "v2" });
// The nested object arrives as an object, not a stringified one — the
// whole point of #1910.
expect(lastCall.metadata).toEqual({ trace: { id: "abc" } });
// Every other setting is carried through untouched.
expect(lastCall.headers).toEqual(emptySettings.headers);
});
it("preserves sibling rows when editing one of several roots", async () => {

View file

@ -5,6 +5,7 @@ import type {
InspectorServerSettings,
ModernLogLevel,
OAuthSettings,
RequestMetadata,
ServerProtocolEra,
ServerType,
} from "@inspector/core/mcp/types.js";
@ -155,24 +156,7 @@ export function ServerSettingsModal({
onSettingsChange({ ...settings, cwd: value });
}
function handleAddMetadata() {
onSettingsChange({
...settings,
metadata: [...settings.metadata, { key: "", value: "" }],
});
}
function handleRemoveMetadata(index: number) {
onSettingsChange({
...settings,
metadata: settings.metadata.filter((_, i) => i !== index),
});
}
function handleMetadataChange(index: number, key: string, value: string) {
const metadata = settings.metadata.map((m, i) =>
i === index ? { key, value } : m,
);
function handleMetadataChange(metadata: RequestMetadata) {
onSettingsChange({ ...settings, metadata });
}
@ -303,8 +287,6 @@ export function ServerSettingsModal({
onRemoveEnv={handleRemoveEnv}
onEnvChange={handleEnvChange}
onCwdChange={handleCwdChange}
onAddMetadata={handleAddMetadata}
onRemoveMetadata={handleRemoveMetadata}
onMetadataChange={handleMetadataChange}
onTimeoutChange={handleTimeoutChange}
onAutoRefreshChange={handleAutoRefreshChange}

Some files were not shown because too many files have changed in this diff Show more