Skip to content

Commit 60189ff

Browse files
cliffhallclaude
andcommitted
fix(servers): pass-3 review — patch-style PUT, toast on close-flush fail
Pass-3 review at #1353 (comment). (b) `PUT /api/servers/:id` now treats both `config` and `settings` as optional patches: - field omitted → preserve the on-disk value - explicit body value → apply - settings: null → clear `updateServerSettings` stops reading `existing.config` from the in-memory `servers` snapshot, which closes the closure-capture hazard where a refresh between debounce-schedule and flush could silently revert a separate edit. The route is the single source of truth for transport config now. New tests cover settings-only PUT, rejected non-object config, and the empty-body no-op patch case. The previous "rejects a missing config" test is rewritten to document the new no-op-preserve semantics. (c) Inline comment near the PUT rebuild loop documents that writing the full mcpServers map back is what gives the route its desirable self-healing property for malformed settings on *other* entries — deliberate side-effect, not an accident of normalization. (d) `flushPendingSettings` surfaces failures via @mantine/notifications instead of swallowing them. The modal already closed by the time the flush PUT resolves, so a silent fail would leave the user thinking their last edits saved (especially painful for the OAuth client secret). The notifications wiring was already mounted in main.tsx. Pass-3 (a) — settings form being effectively uncontrolled mid-edit — is acknowledged in the reply: working as intended today; the proper fix is internal modal state when file-watching (#1345) forces the issue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 425b625 commit 60189ff

5 files changed

Lines changed: 113 additions & 22 deletions

File tree

clients/web/src/App.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
3+
import { notifications } from "@mantine/notifications";
34
import type {
45
InitializeResult,
56
LoggingLevel,
@@ -801,7 +802,17 @@ function App() {
801802
const pending = pendingSettingsRef.current;
802803
if (!pending) return;
803804
pendingSettingsRef.current = null;
804-
void updateServerSettings(pending.id, pending.settings);
805+
// Fire-and-forget — but surface failures via toast. The modal closes
806+
// immediately on user dismiss, so a silent fail-on-flush would leave
807+
// the user thinking their last edits saved when they didn't (especially
808+
// painful for the OAuth client secret).
809+
updateServerSettings(pending.id, pending.settings).catch((err) => {
810+
notifications.show({
811+
title: `Failed to save settings for "${pending.id}"`,
812+
message: err instanceof Error ? err.message : String(err),
813+
color: "red",
814+
});
815+
});
805816
}, [updateServerSettings]);
806817

807818
const onSettingsChange = useCallback(

clients/web/src/test/core/react/useServers.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ describe("useServers", () => {
323323
expect(stored.settings).toBeDefined();
324324
});
325325

326-
it("updateServerSettings throws when the target id does not exist", async () => {
326+
it("updateServerSettings throws when the target id does not exist (server-side 404)", async () => {
327327
const { result } = renderHook(() =>
328328
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
329329
);

clients/web/src/test/integration/mcp/remote/servers-route.test.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,13 +344,19 @@ describe("/api/servers routes", () => {
344344
expect(res.status).toBe(400);
345345
});
346346

347-
it("rejects a missing config", async () => {
347+
it("accepts a body with neither config nor settings (no-op patch)", async () => {
348+
// Both fields are now optional patches. An empty body is a degenerate
349+
// but valid request — it preserves both config and settings on disk.
348350
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
349351
method: "PUT",
350352
headers: { "Content-Type": "application/json" },
351353
body: JSON.stringify({ id: "alpha" }),
352354
});
353-
expect(res.status).toBe(400);
355+
expect(res.status).toBe(200);
356+
expect(readConfig(h.configPath).mcpServers.alpha).toEqual({
357+
type: "stdio",
358+
command: "old",
359+
});
354360
});
355361

356362
it("rejects malformed JSON body", async () => {
@@ -541,6 +547,64 @@ describe("/api/servers routes", () => {
541547
expect(body.error).toMatch(/settings\.headers/);
542548
});
543549

550+
it("accepts a settings-only PUT and preserves config from disk", async () => {
551+
writeFileSync(
552+
h.configPath,
553+
JSON.stringify({
554+
mcpServers: {
555+
theta: {
556+
type: "streamable-http",
557+
url: "https://x.test/mcp",
558+
},
559+
},
560+
}),
561+
);
562+
const res = await fetch(`${h.baseUrl}/api/servers/theta`, {
563+
method: "PUT",
564+
headers: { "Content-Type": "application/json" },
565+
body: JSON.stringify({
566+
// No config — server should preserve the existing one inside its
567+
// write lock and apply only the settings patch.
568+
settings: {
569+
headers: [{ key: "X-Tenant", value: "acme" }],
570+
metadata: [],
571+
connectionTimeout: 0,
572+
requestTimeout: 0,
573+
},
574+
}),
575+
});
576+
expect(res.status).toBe(200);
577+
const stored = readConfig(h.configPath).mcpServers.theta as {
578+
type?: string;
579+
url?: string;
580+
settings?: { headers: { key: string; value: string }[] };
581+
};
582+
expect(stored.type).toBe("streamable-http");
583+
expect(stored.url).toBe("https://x.test/mcp");
584+
expect(stored.settings?.headers).toEqual([
585+
{ key: "X-Tenant", value: "acme" },
586+
]);
587+
});
588+
589+
it("rejects a non-object config on PUT with 400", async () => {
590+
writeFileSync(
591+
h.configPath,
592+
JSON.stringify({
593+
mcpServers: {
594+
iota: { type: "stdio", command: "node" },
595+
},
596+
}),
597+
);
598+
const res = await fetch(`${h.baseUrl}/api/servers/iota`, {
599+
method: "PUT",
600+
headers: { "Content-Type": "application/json" },
601+
body: JSON.stringify({
602+
config: "not-an-object",
603+
}),
604+
});
605+
expect(res.status).toBe(400);
606+
});
607+
544608
it("rejects a settings array (not an object) with 400", async () => {
545609
const res = await fetch(`${h.baseUrl}/api/servers`, {
546610
method: "POST",

core/mcp/remote/node/server.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -853,8 +853,18 @@ export function createRemoteApp(
853853
} catch {
854854
return c.json({ error: "Invalid JSON body" }, 400);
855855
}
856-
if (!body.config || typeof body.config !== "object") {
857-
return c.json({ error: "Missing or invalid config" }, 400);
856+
// Config on PUT: optional. If the field is omitted, preserve the
857+
// existing transport config from disk. This makes "patch only settings"
858+
// a first-class shape — callers like updateServerSettings don't have to
859+
// snapshot the current config off in-memory state (which could race
860+
// against a concurrent refresh and silently revert a separate edit).
861+
// A provided config must structurally be an object; we let
862+
// `normalizeServerType` do the lenient type coercion downstream.
863+
if (
864+
body.config !== undefined &&
865+
(body.config === null || typeof body.config !== "object")
866+
) {
867+
return c.json({ error: "Invalid config" }, 400);
858868
}
859869
// Settings on PUT have three intents:
860870
// - field omitted (`undefined`) → preserve the existing settings node
@@ -890,9 +900,19 @@ export function createRemoteApp(
890900
if (newId !== originalId && newId in current.mcpServers) {
891901
return c.json({ error: `Server '${newId}' already exists` }, 409);
892902
}
893-
// Rebuild preserving insertion order; replace the original key in place
894-
// so the file diff stays minimal when not renaming.
903+
// Rebuild preserving insertion order; replace the original key in
904+
// place so the file diff stays minimal when not renaming. Writing
905+
// the full map back also means normalize-on-read self-heals any
906+
// malformed settings node on *other* servers in the file — a
907+
// deliberate side-effect of using `readMcpConfig` + full rewrite
908+
// here.
895909
const existing = current.mcpServers[originalId]!;
910+
// Split the existing entry into its config (everything except
911+
// settings) and its settings, then apply patch semantics from the
912+
// body to each.
913+
const { settings: _existingSettings, ...existingConfig } = existing;
914+
const nextConfig =
915+
body.config !== undefined ? body.config : existingConfig;
896916
let nextSettings: InspectorServerSettings | undefined;
897917
switch (settingsIntent.kind) {
898918
case "preserve":
@@ -908,10 +928,7 @@ export function createRemoteApp(
908928
const next: MCPConfig = { mcpServers: {} };
909929
for (const [key, val] of Object.entries(current.mcpServers)) {
910930
if (key === originalId) {
911-
next.mcpServers[newId] = buildStoredEntry(
912-
body.config,
913-
nextSettings,
914-
);
931+
next.mcpServers[newId] = buildStoredEntry(nextConfig, nextSettings);
915932
} else {
916933
next.mcpServers[key] = val;
917934
}

core/react/useServers.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,28 +144,27 @@ export function useServers(opts: UseServersOptions): UseServersResult {
144144

145145
const updateServerSettings = useCallback(
146146
async (id: string, settings: InspectorServerSettings): Promise<void> => {
147-
const existing = servers.find((s) => s.id === id);
148-
if (!existing) {
149-
throw new Error(`Server '${id}' not found`);
150-
}
147+
// Settings-only PUT — we deliberately omit `config` so the route
148+
// preserves the on-disk transport config inside its write lock.
149+
// Reading `existing.config` from in-memory `servers` here would pin a
150+
// stale snapshot at scheduling time and could silently revert a
151+
// separate concurrent edit (e.g. a future file-watcher refreshing
152+
// `servers` between debounce schedule and flush). The server is the
153+
// single source of truth for config.
151154
const res = await doFetch(
152155
`${base}/api/servers/${encodeURIComponent(id)}`,
153156
{
154157
method: "PUT",
155158
headers: buildHeaders(authToken, true),
156-
body: JSON.stringify({
157-
id,
158-
config: existing.config,
159-
settings,
160-
}),
159+
body: JSON.stringify({ id, settings }),
161160
},
162161
);
163162
if (!res.ok) {
164163
throw new Error(await readErrorMessage(res));
165164
}
166165
await refresh();
167166
},
168-
[base, authToken, doFetch, refresh, servers],
167+
[base, authToken, doFetch, refresh],
169168
);
170169

171170
const removeServer = useCallback(

0 commit comments

Comments
 (0)