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:
Cliff Hall 2026-08-23 20:48:01 -04:00 • committed by GitHub
commit 8b9839cc93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 683 additions and 59 deletions

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(),
})),
@ -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

View file

@ -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",
});

View file

@ -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 () => {});

View file

@ -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 };
}
}

View file

@ -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 }),

View file

@ -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(