Merge pull request #2087 from modelcontextprotocol/v2/fix/1914-surface-post-add-refresh-failure
fix(web): surface a failed post-write server-list reload
This commit is contained in:
commit
8b9839cc93
6 changed files with 683 additions and 59 deletions
|
|
@ -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(),
|
||||
})),
|
||||
|
|
@ -412,6 +425,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 +553,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,7 +608,10 @@ 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,
|
||||
|
|
@ -2963,6 +2990,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
|
||||
|
|
|
|||
|
|
@ -98,7 +98,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";
|
||||
|
|
@ -3831,30 +3834,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
|
||||
|
|
@ -4182,8 +4201,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],
|
||||
);
|
||||
|
|
@ -4193,10 +4224,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
|
||||
|
|
@ -4221,6 +4266,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);
|
||||
|
|
@ -4271,12 +4340,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).
|
||||
|
|
@ -4303,11 +4387,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
|
||||
|
|
@ -4315,7 +4395,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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "./useImportClientConfig";
|
||||
import type { ImportSourceResult } from "@inspector/core/mcp/import/index.js";
|
||||
import type { MCPConfig } from "@inspector/core/mcp/types.js";
|
||||
import { ServerListReloadError } from "@inspector/core/react/useServers.js";
|
||||
|
||||
function config(...ids: string[]): MCPConfig {
|
||||
const mcpServers: MCPConfig["mcpServers"] = {};
|
||||
|
|
@ -207,6 +208,31 @@ describe("useImportClientConfig", () => {
|
|||
expect(outcome.detail).toBe("add failed");
|
||||
});
|
||||
|
||||
it("reports a reload failure as imported-with-a-warning, not failed (#1914)", async () => {
|
||||
// A `ServerListReloadError` rejects *after* the write landed, so the entry
|
||||
// really was added. A red "Failed" badge would contradict the error's own
|
||||
// message and invite a retry that then collides with the row it claims
|
||||
// wasn't created. The reason still shows, as `detail`.
|
||||
const onAddServer = vi.fn(async () => {
|
||||
throw new ServerListReloadError(
|
||||
"The server was added, but the server list could not be reloaded: disk full",
|
||||
);
|
||||
});
|
||||
const { result } = setup({
|
||||
onFetchSource: vi.fn(async () => foundSource(config("alpha"))),
|
||||
onAddServer,
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.pickSource("claude");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.runImport();
|
||||
});
|
||||
const outcome = result.current.outcomes[0];
|
||||
expect(outcome.status).toBe("added");
|
||||
expect(outcome.detail).toMatch(/could not be reloaded: disk full/);
|
||||
});
|
||||
|
||||
it("resolves conflicts by overwrite, skip, and rename", async () => {
|
||||
const onAddServer = vi.fn(async () => {});
|
||||
const onUpdateServer = vi.fn(async () => {});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type ImportSourceResult,
|
||||
} from "@inspector/core/mcp/import/index.js";
|
||||
import { validateStoreId } from "@inspector/core/storage/store-id.js";
|
||||
import { ServerListReloadError } from "@inspector/core/react/useServers.js";
|
||||
import type { MCPConfig, MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
export type ImportPhase = "select" | "loading" | "review" | "summary";
|
||||
|
|
@ -198,11 +199,17 @@ export function useImportClientConfig({
|
|||
await write();
|
||||
return { id, status: outcome };
|
||||
} catch (err) {
|
||||
return {
|
||||
id,
|
||||
status: "failed",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
// A `ServerListReloadError` means the write landed and only reading the
|
||||
// list back failed, so this entry really was imported. Reporting it as
|
||||
// `failed` would contradict the error's own message and invite a retry
|
||||
// that then collides with the row it says wasn't created (#1914 r3).
|
||||
// The status is the truth about the write; `detail` renders in red
|
||||
// beneath it either way, so the reload failure is still visible.
|
||||
if (err instanceof ServerListReloadError) {
|
||||
return { id, status: outcome, detail };
|
||||
}
|
||||
return { id, status: "failed", detail };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { useServers } from "@inspector/core/react/useServers";
|
||||
import {
|
||||
ServerListReloadError,
|
||||
useServers,
|
||||
} from "@inspector/core/react/useServers";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server";
|
||||
import { DEFAULT_SEED_CONFIG } from "@inspector/core/mcp/serverList";
|
||||
import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store";
|
||||
|
|
@ -1201,6 +1204,186 @@ describe("useServers", () => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* #1914 — a write that lands but whose list reload fails must reject.
|
||||
*
|
||||
* `ServerConfigModal` renders `submitError` only for something `onSubmit`
|
||||
* threw, so while the post-write refresh swallowed its failure into the
|
||||
* `error` state the Add Server modal closed as though nothing had gone
|
||||
* wrong. That is the "silent failure" in the report: the POST succeeded,
|
||||
* the GET 500'd, and the user saw neither the new row nor an error.
|
||||
*
|
||||
* `failGetAfterWrite` lets the seed load and the write itself through, then
|
||||
* fails every subsequent list read — the shape a container with no
|
||||
* reachable keychain produced before #1848.
|
||||
*/
|
||||
function failGetAfterWrite(message: string): {
|
||||
fetchFn: typeof fetch;
|
||||
startFailing: () => void;
|
||||
} {
|
||||
let failing = false;
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const req =
|
||||
input instanceof Request
|
||||
? input
|
||||
: new Request(input as string | URL, init);
|
||||
if (failing && req.method === "GET" && req.url.endsWith("/api/servers")) {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return h.fetchFn(req);
|
||||
};
|
||||
return {
|
||||
fetchFn,
|
||||
startFailing: () => {
|
||||
failing = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("addServer rejects when the post-write list reload fails (#1914)", async () => {
|
||||
const { fetchFn, startFailing } = failGetAfterWrite("keyring unavailable");
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
startFailing();
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.addServer("alpha", { type: "stdio", command: "node" }),
|
||||
).rejects.toThrow(
|
||||
/The server was added, but the server list could not be reloaded: keyring unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
// The write itself landed, and the message says so — a bare failure would
|
||||
// send the user into a retry that trips the duplicate-id check.
|
||||
expect(readConfig(h.configPath).mcpServers.alpha).toEqual({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
});
|
||||
// Still recorded in `error` for any list-level UI reading it.
|
||||
await waitFor(() =>
|
||||
expect(result.current.error).toBe("keyring unavailable"),
|
||||
);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("updateServer rejects when the post-write list reload fails (#1914)", async () => {
|
||||
const { fetchFn, startFailing } = failGetAfterWrite("disk full");
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const existing = result.current.servers[0]!.id;
|
||||
|
||||
startFailing();
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.updateServer(existing, existing, {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/The server was saved, but the server list could not be reloaded: disk full/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("updateServerSettings rejects when the post-write list reload fails (#1914)", async () => {
|
||||
const { fetchFn, startFailing } = failGetAfterWrite("disk full");
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const existing = result.current.servers[0]!.id;
|
||||
|
||||
startFailing();
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.updateServerSettings(existing, {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
roots: [],
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
taskTtl: 30000,
|
||||
maxFetchRequests: 1000,
|
||||
paginatedLists: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/The server was saved, but the server list could not be reloaded: disk full/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("tags a post-write reload failure as ServerListReloadError, not a failed write (#1914)", async () => {
|
||||
// The pagination toggle updates optimistically and reverts the live
|
||||
// client on a rejection. That is right for a failed PUT and wrong for a
|
||||
// failed reload — the setting IS on disk — so the two must be tellable
|
||||
// apart by type, not by message matching.
|
||||
const { fetchFn, startFailing } = failGetAfterWrite("disk full");
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
startFailing();
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.addServer("alpha", { type: "stdio", command: "node" }),
|
||||
).rejects.toBeInstanceOf(ServerListReloadError);
|
||||
});
|
||||
|
||||
// A rejected *write* stays a plain Error, so a caller branching on the
|
||||
// type still rolls back for the case the rollback was written for.
|
||||
await act(async () => {
|
||||
const err = await result.current
|
||||
.addServer("alpha", { type: "stdio", command: "node" })
|
||||
.catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(ServerListReloadError);
|
||||
});
|
||||
});
|
||||
|
||||
it("removeServer rejects when the post-write list reload fails (#1914)", async () => {
|
||||
const { fetchFn, startFailing } = failGetAfterWrite("disk full");
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const existing = result.current.servers[0]!.id;
|
||||
|
||||
startFailing();
|
||||
await act(async () => {
|
||||
await expect(result.current.removeServer(existing)).rejects.toThrow(
|
||||
/The server was removed, but the server list could not be reloaded: disk full/,
|
||||
);
|
||||
});
|
||||
expect(readConfig(h.configPath).mcpServers[existing]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a plain refresh() still resolves on a failed list read, recording the error (#1914)", async () => {
|
||||
// The rethrow is scoped to post-write reloads. `refresh()` is awaited by
|
||||
// the mount effect and the SSE loop, neither of which has anywhere to put
|
||||
// a rejection, so it must keep swallowing into `error`.
|
||||
const { fetchFn, startFailing } = failGetAfterWrite("keyring unavailable");
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
startFailing();
|
||||
await act(async () => {
|
||||
await expect(result.current.refresh()).resolves.toBeUndefined();
|
||||
});
|
||||
expect(result.current.error).toBe("keyring unavailable");
|
||||
});
|
||||
|
||||
it("uses DEFAULT_SEED_CONFIG keys on the first load (seed-write contract)", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,25 @@ export interface UseServersResult {
|
|||
importSource: (type: string) => Promise<ImportSourceResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a mutation's *write* landed but reading the list back
|
||||
* afterwards failed — see `refreshAfterWrite`.
|
||||
*
|
||||
* Distinct from a rejected write because the two call for opposite handling
|
||||
* in an optimistic caller. The pagination toggle in `App.tsx` reverts its
|
||||
* optimistic override and rolls the live client back on a rejection, which is
|
||||
* right for a failed PUT and wrong here: the setting *is* on disk, so the
|
||||
* rollback would show the user a value that no longer matches it. Callers
|
||||
* that update optimistically must branch on this; callers that only report
|
||||
* (the modals) can treat it as any other error.
|
||||
*/
|
||||
export class ServerListReloadError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ServerListReloadError";
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(
|
||||
authToken: string | undefined,
|
||||
includeJsonBody: boolean,
|
||||
|
|
@ -132,33 +151,78 @@ export function useServers(opts: UseServersOptions): UseServersResult {
|
|||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// The list read itself, throwing on failure. Every refresh path goes
|
||||
// through it; they differ only in what they do with a rejection.
|
||||
const loadServers = useCallback(async (): Promise<ServerEntry[]> => {
|
||||
const res = await doFetch(`${base}/api/servers`, {
|
||||
method: "GET",
|
||||
headers: buildHeaders(authToken, false),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
const body = (await res.json()) as MCPConfig;
|
||||
return mcpConfigToServerEntries(body);
|
||||
}, [base, authToken, doFetch]);
|
||||
|
||||
// Inner refresh that the public `refresh` and the SSE-triggered background
|
||||
// refresh both share. `background` skips the loading-state toggle so
|
||||
// consumers rendering a spinner or skeleton don't flash on every external
|
||||
// mcp.json edit — the existing list stays on screen while the re-fetch
|
||||
// resolves. `error` is still reset / set either way so a real error
|
||||
// surfaces even from a background refresh.
|
||||
//
|
||||
// A failure is recorded in `error` and NOT rethrown: the two callers are
|
||||
// the mount effect and the SSE loop, neither of which has anywhere to put
|
||||
// a rejection. Post-write reloads use `refreshAfterWrite` instead.
|
||||
const refreshInternal = useCallback(
|
||||
async (background: boolean): Promise<void> => {
|
||||
if (!background) setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const res = await doFetch(`${base}/api/servers`, {
|
||||
method: "GET",
|
||||
headers: buildHeaders(authToken, false),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
const body = (await res.json()) as MCPConfig;
|
||||
setServers(mcpConfigToServerEntries(body));
|
||||
setServers(await loadServers());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!background) setLoading(false);
|
||||
}
|
||||
},
|
||||
[base, authToken, doFetch],
|
||||
[loadServers],
|
||||
);
|
||||
|
||||
// Post-write reload, used by every mutator. Unlike `refreshInternal` it
|
||||
// rethrows, because a mutator's caller learns that something went wrong
|
||||
// only from a rejection — `ServerConfigModal` renders `submitError` for a
|
||||
// thrown error and nothing else, so a successful POST followed by a failed
|
||||
// GET closed the Add Server modal as though the whole thing had worked
|
||||
// (#1914). The reported failure was silent for exactly this reason.
|
||||
//
|
||||
// The message says the write landed, because it did: only reading the list
|
||||
// back failed. A bare "could not add server" would send the user straight
|
||||
// into a retry that trips the duplicate-id check, which is the dead end the
|
||||
// original report described.
|
||||
//
|
||||
// The rejection is a `ServerListReloadError` rather than a bare `Error` so
|
||||
// an optimistic caller can tell it from a failed write and skip its
|
||||
// rollback — reverting here would contradict what is actually on disk.
|
||||
const refreshAfterWrite = useCallback(
|
||||
async (verb: string): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setServers(await loadServers());
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw new ServerListReloadError(
|
||||
`The server was ${verb}, but the server list could not be reloaded: ${message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[loadServers],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
|
|
@ -241,9 +305,9 @@ export function useServers(opts: UseServersOptions): UseServersResult {
|
|||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
await refresh();
|
||||
await refreshAfterWrite("added");
|
||||
},
|
||||
[base, authToken, doFetch, refresh],
|
||||
[base, authToken, doFetch, refreshAfterWrite],
|
||||
);
|
||||
|
||||
const importSource = useCallback(
|
||||
|
|
@ -282,9 +346,9 @@ export function useServers(opts: UseServersOptions): UseServersResult {
|
|||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
await refresh();
|
||||
await refreshAfterWrite("saved");
|
||||
},
|
||||
[base, authToken, doFetch, refresh],
|
||||
[base, authToken, doFetch, refreshAfterWrite],
|
||||
);
|
||||
|
||||
const updateServerSettings = useCallback(
|
||||
|
|
@ -307,9 +371,9 @@ export function useServers(opts: UseServersOptions): UseServersResult {
|
|||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
await refresh();
|
||||
await refreshAfterWrite("saved");
|
||||
},
|
||||
[base, authToken, doFetch, refresh],
|
||||
[base, authToken, doFetch, refreshAfterWrite],
|
||||
);
|
||||
|
||||
const removeServer = useCallback(
|
||||
|
|
@ -324,9 +388,9 @@ export function useServers(opts: UseServersOptions): UseServersResult {
|
|||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
await refresh();
|
||||
await refreshAfterWrite("removed");
|
||||
},
|
||||
[base, authToken, doFetch, refresh],
|
||||
[base, authToken, doFetch, refreshAfterWrite],
|
||||
);
|
||||
|
||||
const reorderServers = useCallback(
|
||||
|
|
|
|||
Loading…
Reference in a new issue