* feat(servers): flatten mcp.json settings to top-level fields (#1358) Eliminate the nested `settings` block introduced in #1352 so each server entry's Inspector-extension fields live as direct keys alongside `type` / `url`, matching the `.mcp.json` shape Claude Code / Cursor / Cline write: - `headers` becomes a `Record<string, string>` on disk (was nested `settings.headers` as a pair-array) - `oauth` becomes a nested `{ clientId?, clientSecret?, scopes? }` object (was flat `settings.oauthClientId` / `oauthClientSecret` / `oauthScopes`) - `metadata`, `connectionTimeout`, `requestTimeout` move to the entry's top level as-is (Inspector-only fields) The in-memory + wire shape is unchanged from #1352: `InspectorServerSettings` keeps pair-array `headers` and flat `oauth*` fields because the form needs them in that shape to drive controlled-component editing. The bidirectional conversion lives in `serverList.ts` (`storedFieldsToInspectorSettings` / `inspectorSettingsToStoredFields`) and is invoked by `mcpConfigToServerEntries` / `serverEntriesToMcpConfig` and by the server route's `buildStoredEntry`. The `PUT /api/servers/:id` wire envelope keeps the preserve/clear/apply semantics from #1353 — the backend splats validated `settings.*` onto top-level disk keys when assembling the next on-disk shape. Hard-cutover migration per the issue's decision 4: files written by the one pre-#1358 v2/main build have a nested `settings` block; we drop the node on read with a one-line warn including the server id. The persisted headers / metadata / timeouts / OAuth credentials are intentionally lost on first read — users re-enter them via the form (or hand-edit the file into the flat shape). v2 has not shipped a stable release with the nested shape, so the blast radius is dogfooders only. The smuggle-guard in `buildStoredEntry` widens from just `settings` to all six Inspector-extension keys (`settings`, `headers`, `metadata`, `connectionTimeout`, `requestTimeout`, `oauth`), so a caller can't bypass `validateSettings` by nesting any of them inside `config`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR review on flatten (#1359) - Single-source the Inspector-extension key list with `satisfies` (finding 1). `INSPECTOR_FIELD_KEY_MAP satisfies Record<keyof StoredInspectorFields, true>` in serverList.ts means a new field on the type forces a compile error here. The set is exported and re-used by the server route's smuggle guard, so adding `proxy` or similar in the future updates all three sites in lockstep. The PUT preserve path now uses a new `stripInspectorFields` helper that derives from the same source instead of hand-destructuring. - Add read-path shape validation in `normalizeMcpServers` (finding 2): `headers: "oops"` / `metadata: 42` / `oauth: []` / non-numeric timeouts are dropped individually with a warn, so a hand-edited malformed file can't put garbage rows into the form. Mirrors the write-side `validateSettings` symmetry. New integration test covers each malformed shape with a partial fixture so a single bad key doesn't take out the whole entry. - Align new warns to the `fileLogger || console.warn` fallback pattern (finding 3) via a small `logWarn` helper, so the legacy-drop and smuggle warns are visible in `npm run dev` where the logger is often unconfigured. - Spec doc tweak: the CLI/TUI out-of-scope note's "writes to `settings.headers`" reference now reads "writes to the entry's top-level `headers` field on disk (post-#1358 flat shape)" (finding 4). Avoids confusion for future readers. - Nits: replace `current.mcpServers[originalId]!` with explicit narrowing; add a comment on the OAuth truthiness drop in `storedFieldsToInspectorSettings` mirroring the write-side comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address pass-2 review on flatten (#1359) - Tighten `isNonNegNumber` with `Number.isFinite` so `Infinity` and `NaN` are rejected at the read boundary, matching the write-side intent (finding D). Hand-edited `connectionTimeout: Infinity` would have passed the prior check and propagated to the form. - Add a one-line comment in `normalizeMcpServers` noting that the per-entry object is mutated in place via the `delete` calls — safe for today's callers (freshly parsed JSON, no other reference held) but worth flagging for the next reader (finding A). Skipping findings B (move type guards to module scope — single call site) and C (`isOauthObject` lenient on extra keys — reviewer confirmed this is the intended forward-compat behavior, matching `normalizeServerType`'s unknown-key passthrough). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(servers): fix mutation-comment caller branch label (#1359) The previous comment named the GET handler's "seed branch" as a caller of normalizeMcpServers, but that branch returns DEFAULT_SEED_CONFIG directly without normalizing. The actual second caller is the file-present branch (after parseStore). Correcting the label so a future reader looking for the seed branch caller doesn't waste time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f9bfdc490
commit
a9df501856
7 changed files with 808 additions and 242 deletions
|
|
@ -147,20 +147,23 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
expect(round).toEqual(original);
|
expect(round).toEqual(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips a populated settings node through both converters", () => {
|
it("round-trips a populated set of Inspector-extension fields (post-#1358 flat shape)", () => {
|
||||||
|
// Disk shape: top-level `headers` (Record), `metadata` (pair-array),
|
||||||
|
// numeric timeouts, nested `oauth`. Round-trip must preserve the on-
|
||||||
|
// disk shape byte-equivalent so a hand-edited file is stable.
|
||||||
const original: MCPConfig = {
|
const original: MCPConfig = {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
gamma: {
|
gamma: {
|
||||||
type: "streamable-http",
|
type: "streamable-http",
|
||||||
url: "https://x.test/mcp",
|
url: "https://x.test/mcp",
|
||||||
settings: {
|
headers: { Authorization: "Bearer xyz" },
|
||||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
metadata: [{ key: "tenant", value: "acme" }],
|
||||||
metadata: [{ key: "tenant", value: "acme" }],
|
connectionTimeout: 30000,
|
||||||
connectionTimeout: 30000,
|
requestTimeout: 60000,
|
||||||
requestTimeout: 60000,
|
oauth: {
|
||||||
oauthClientId: "client-abc",
|
clientId: "client-abc",
|
||||||
oauthClientSecret: "secret-def",
|
clientSecret: "secret-def",
|
||||||
oauthScopes: "read:tools",
|
scopes: "read:tools",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -169,36 +172,58 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
expect(round).toEqual(original);
|
expect(round).toEqual(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lifts the settings node from the stored entry onto ServerEntry.settings", () => {
|
it("lifts top-level Inspector-extension fields onto ServerEntry.settings (form shape)", () => {
|
||||||
const cfg: MCPConfig = {
|
const cfg: MCPConfig = {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
alpha: {
|
alpha: {
|
||||||
type: "streamable-http",
|
type: "streamable-http",
|
||||||
url: "https://x.test/mcp",
|
url: "https://x.test/mcp",
|
||||||
settings: {
|
headers: { "X-Tenant": "acme" },
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
oauth: { clientId: "the-client" },
|
||||||
metadata: [],
|
|
||||||
connectionTimeout: 0,
|
|
||||||
requestTimeout: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const [entry] = mcpConfigToServerEntries(cfg);
|
const [entry] = mcpConfigToServerEntries(cfg);
|
||||||
expect(entry?.settings).toEqual({
|
expect(entry?.settings).toEqual({
|
||||||
|
// Object headers on disk → pair-array headers in memory
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
// Nested oauth on disk → flat oauthClientId in memory
|
||||||
|
oauthClientId: "the-client",
|
||||||
});
|
});
|
||||||
// `settings` should not leak back into config — the SDK transport must
|
// None of the Inspector-extension keys leak back into config —
|
||||||
// see a clean MCPServerConfig.
|
// the SDK transport must see a clean MCPServerConfig.
|
||||||
expect(
|
const configKeys = Object.keys(
|
||||||
(entry?.config as unknown as Record<string, unknown>).settings,
|
entry?.config as unknown as Record<string, unknown>,
|
||||||
).toBeUndefined();
|
);
|
||||||
|
expect(configKeys).not.toContain("headers");
|
||||||
|
expect(configKeys).not.toContain("metadata");
|
||||||
|
expect(configKeys).not.toContain("oauth");
|
||||||
|
expect(configKeys).not.toContain("connectionTimeout");
|
||||||
|
expect(configKeys).not.toContain("requestTimeout");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits the settings key on disk when ServerEntry.settings is undefined", () => {
|
it("lifts the headers field for a streamable-http entry written by Claude Code", () => {
|
||||||
|
// A pasted-in `.mcp.json` example from the Claude Code docs — top-level
|
||||||
|
// headers, no nested settings node.
|
||||||
|
const cfg: MCPConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
"api-server": {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://api.example.com/mcp",
|
||||||
|
headers: { Authorization: "Bearer the-token" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const [entry] = mcpConfigToServerEntries(cfg);
|
||||||
|
expect(entry?.settings?.headers).toEqual([
|
||||||
|
{ key: "Authorization", value: "Bearer the-token" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits all Inspector-extension keys on disk when ServerEntry.settings is undefined", () => {
|
||||||
const entries: ServerEntry[] = [
|
const entries: ServerEntry[] = [
|
||||||
{
|
{
|
||||||
id: "alpha",
|
id: "alpha",
|
||||||
|
|
@ -208,7 +233,67 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const cfg = serverEntriesToMcpConfig(entries);
|
const cfg = serverEntriesToMcpConfig(entries);
|
||||||
expect(cfg.mcpServers.alpha).not.toHaveProperty("settings");
|
const stored = cfg.mcpServers.alpha;
|
||||||
|
expect(stored).not.toHaveProperty("settings");
|
||||||
|
expect(stored).not.toHaveProperty("headers");
|
||||||
|
expect(stored).not.toHaveProperty("metadata");
|
||||||
|
expect(stored).not.toHaveProperty("oauth");
|
||||||
|
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||||
|
expect(stored).not.toHaveProperty("requestTimeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty-key header rows when serializing to disk", () => {
|
||||||
|
// The form leaves a blank row when the user clicks 'Add header' but
|
||||||
|
// hasn't typed yet. Those should not reach disk — the round-trip
|
||||||
|
// would otherwise produce `{ "": "..." }` which neither we nor the
|
||||||
|
// ecosystem can sensibly send as an HTTP header.
|
||||||
|
const entries: ServerEntry[] = [
|
||||||
|
{
|
||||||
|
id: "alpha",
|
||||||
|
name: "alpha",
|
||||||
|
config: { type: "streamable-http", url: "https://x.test" },
|
||||||
|
settings: {
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Tenant", value: "acme" },
|
||||||
|
{ key: "", value: "stub" },
|
||||||
|
{ key: " ", value: "whitespace" },
|
||||||
|
],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
},
|
||||||
|
connection: { status: "disconnected" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||||
|
expect(stored?.headers).toEqual({ "X-Tenant": "acme" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits zero-valued timeouts and empty oauth fields on serialize", () => {
|
||||||
|
// The form keeps numeric defaults at 0 and empty-string OAuth values.
|
||||||
|
// Round-tripping them onto disk would leave noisy `connectionTimeout: 0`
|
||||||
|
// / `oauth: {}` keys; suppress them so the diff stays minimal for
|
||||||
|
// entries the user never customized.
|
||||||
|
const entries: ServerEntry[] = [
|
||||||
|
{
|
||||||
|
id: "alpha",
|
||||||
|
name: "alpha",
|
||||||
|
config: { type: "streamable-http", url: "https://x.test" },
|
||||||
|
settings: {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
},
|
||||||
|
connection: { status: "disconnected" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||||
|
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||||
|
expect(stored).not.toHaveProperty("requestTimeout");
|
||||||
|
expect(stored).not.toHaveProperty("oauth");
|
||||||
|
expect(stored).not.toHaveProperty("headers");
|
||||||
|
expect(stored).not.toHaveProperty("metadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves insertion order on serialize", () => {
|
it("preserves insertion order on serialize", () => {
|
||||||
|
|
|
||||||
|
|
@ -397,14 +397,18 @@ describe("useServers", () => {
|
||||||
requestTimeout: 30000,
|
requestTimeout: 30000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const stored = readConfig(h.configPath).mcpServers.alpha as {
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
type?: string;
|
.alpha as unknown as Record<string, unknown>;
|
||||||
url?: string;
|
// Post-#1358: settings round-trip onto top-level keys on disk, no
|
||||||
settings?: unknown;
|
// nested `settings` wrapper. Each non-zero/non-empty field surfaces
|
||||||
};
|
// as a sibling of `type` / `url`.
|
||||||
expect(stored.type).toBe("streamable-http");
|
expect(stored.type).toBe("streamable-http");
|
||||||
expect(stored.url).toBe("https://x.test/mcp");
|
expect(stored.url).toBe("https://x.test/mcp");
|
||||||
expect(stored.settings).toBeDefined();
|
expect(stored).not.toHaveProperty("settings");
|
||||||
|
expect(stored.headers).toEqual({ "X-Tenant": "acme" });
|
||||||
|
expect(stored.metadata).toEqual([{ key: "trace", value: "abc" }]);
|
||||||
|
expect(stored.connectionTimeout).toBe(5000);
|
||||||
|
expect(stored.requestTimeout).toBe(30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateServerSettings throws when the target id does not exist (server-side 404)", async () => {
|
it("updateServerSettings throws when the target id does not exist (server-side 404)", async () => {
|
||||||
|
|
@ -433,12 +437,9 @@ describe("useServers", () => {
|
||||||
alpha: {
|
alpha: {
|
||||||
type: "streamable-http",
|
type: "streamable-http",
|
||||||
url: "https://x.test/mcp",
|
url: "https://x.test/mcp",
|
||||||
settings: {
|
// Post-#1358 flat shape on disk; the hook lifts `headers` into
|
||||||
headers: [{ key: "X-Keep", value: "yes" }],
|
// the pair-array `settings.headers` it exposes.
|
||||||
metadata: [],
|
headers: { "X-Keep": "yes" },
|
||||||
connectionTimeout: 0,
|
|
||||||
requestTimeout: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -370,13 +370,17 @@ describe("/api/servers routes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("settings round-trip", () => {
|
describe("settings round-trip", () => {
|
||||||
it("persists a settings node on POST", async () => {
|
it("persists Inspector-extension fields at the top level on POST (post-#1358 flat shape)", async () => {
|
||||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: "gamma",
|
id: "gamma",
|
||||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||||
|
// Wire envelope unchanged from #1353: pair-array headers, flat
|
||||||
|
// oauth* fields. Backend splats these into the flat disk shape:
|
||||||
|
// object headers, nested oauth, plus the inspector-only fields
|
||||||
|
// at the top level.
|
||||||
settings: {
|
settings: {
|
||||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
||||||
metadata: [{ key: "tenant", value: "acme" }],
|
metadata: [{ key: "tenant", value: "acme" }],
|
||||||
|
|
@ -388,20 +392,21 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers.gamma as {
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
settings?: unknown;
|
.gamma as unknown as Record<string, unknown>;
|
||||||
};
|
// Disk shape: flat, no `settings` wrapper, object headers, nested oauth.
|
||||||
expect(stored.settings).toEqual({
|
expect(stored).not.toHaveProperty("settings");
|
||||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
expect(stored.headers).toEqual({ Authorization: "Bearer xyz" });
|
||||||
metadata: [{ key: "tenant", value: "acme" }],
|
expect(stored.metadata).toEqual([{ key: "tenant", value: "acme" }]);
|
||||||
connectionTimeout: 30000,
|
expect(stored.connectionTimeout).toBe(30000);
|
||||||
requestTimeout: 60000,
|
expect(stored.requestTimeout).toBe(60000);
|
||||||
oauthClientId: "client-abc",
|
expect(stored.oauth).toEqual({
|
||||||
oauthScopes: "read:tools",
|
clientId: "client-abc",
|
||||||
|
scopes: "read:tools",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates a settings node on PUT", async () => {
|
it("updates Inspector-extension fields at the top level on PUT", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
h.configPath,
|
h.configPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
@ -424,15 +429,14 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers.delta as {
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
settings?: unknown;
|
.delta as unknown as Record<string, unknown>;
|
||||||
};
|
expect(stored).not.toHaveProperty("settings");
|
||||||
expect(stored.settings).toEqual({
|
expect(stored.headers).toEqual({ "X-Tenant": "acme" });
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
expect(stored.requestTimeout).toBe(45000);
|
||||||
metadata: [],
|
// Zero/empty values are suppressed on disk to keep the diff minimal.
|
||||||
connectionTimeout: 0,
|
expect(stored).not.toHaveProperty("metadata");
|
||||||
requestTimeout: 45000,
|
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a non-object settings field", async () => {
|
it("rejects a non-object settings field", async () => {
|
||||||
|
|
@ -448,7 +452,7 @@ describe("/api/servers routes", () => {
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the settings node when PUT omits it (no clobber on config-only save)", async () => {
|
it("preserves Inspector-extension fields when PUT omits settings (no clobber on config-only save)", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
h.configPath,
|
h.configPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
@ -456,17 +460,13 @@ describe("/api/servers routes", () => {
|
||||||
epsilon: {
|
epsilon: {
|
||||||
type: "streamable-http",
|
type: "streamable-http",
|
||||||
url: "https://x.test/mcp",
|
url: "https://x.test/mcp",
|
||||||
settings: {
|
// Post-#1358 flat shape on disk.
|
||||||
headers: [{ key: "X-Keep", value: "yes" }],
|
headers: { "X-Keep": "yes" },
|
||||||
metadata: [],
|
|
||||||
connectionTimeout: 0,
|
|
||||||
requestTimeout: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// PUT without a settings field: the existing settings node must
|
// PUT without a settings field: the existing top-level headers must
|
||||||
// survive. A caller updating only the transport config (e.g. the
|
// survive. A caller updating only the transport config (e.g. the
|
||||||
// server config modal) must not silently wipe persisted headers.
|
// server config modal) must not silently wipe persisted headers.
|
||||||
const res = await fetch(`${h.baseUrl}/api/servers/epsilon`, {
|
const res = await fetch(`${h.baseUrl}/api/servers/epsilon`, {
|
||||||
|
|
@ -477,16 +477,14 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers.epsilon as {
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
settings?: { headers: { key: string; value: string }[] };
|
.epsilon as unknown as Record<string, unknown>;
|
||||||
};
|
expect(stored.headers).toEqual({ "X-Keep": "yes" });
|
||||||
expect(stored.settings).toBeDefined();
|
// URL update must have applied.
|
||||||
expect(stored.settings?.headers).toEqual([
|
expect(stored.url).toBe("https://x.test/other");
|
||||||
{ key: "X-Keep", value: "yes" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears the settings node when PUT sends settings: null (explicit intent)", async () => {
|
it("clears Inspector-extension fields when PUT sends settings: null (explicit intent)", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
h.configPath,
|
h.configPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
@ -494,12 +492,9 @@ describe("/api/servers routes", () => {
|
||||||
zeta: {
|
zeta: {
|
||||||
type: "streamable-http",
|
type: "streamable-http",
|
||||||
url: "https://x.test/mcp",
|
url: "https://x.test/mcp",
|
||||||
settings: {
|
headers: { "X-Tenant": "acme" },
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
metadata: [{ key: "trace", value: "abc" }],
|
||||||
metadata: [],
|
connectionTimeout: 5000,
|
||||||
connectionTimeout: 0,
|
|
||||||
requestTimeout: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -513,10 +508,14 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers.zeta as {
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
settings?: unknown;
|
.zeta as unknown as Record<string, unknown>;
|
||||||
};
|
// All Inspector-extension fields gone.
|
||||||
expect(stored.settings).toBeUndefined();
|
expect(stored).not.toHaveProperty("headers");
|
||||||
|
expect(stored).not.toHaveProperty("metadata");
|
||||||
|
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||||
|
expect(stored).not.toHaveProperty("requestTimeout");
|
||||||
|
expect(stored).not.toHaveProperty("oauth");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a malformed settings shape with 400", async () => {
|
it("rejects a malformed settings shape with 400", async () => {
|
||||||
|
|
@ -574,16 +573,11 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers.theta as {
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
type?: string;
|
.theta as unknown as Record<string, unknown>;
|
||||||
url?: string;
|
|
||||||
settings?: { headers: { key: string; value: string }[] };
|
|
||||||
};
|
|
||||||
expect(stored.type).toBe("streamable-http");
|
expect(stored.type).toBe("streamable-http");
|
||||||
expect(stored.url).toBe("https://x.test/mcp");
|
expect(stored.url).toBe("https://x.test/mcp");
|
||||||
expect(stored.settings?.headers).toEqual([
|
expect(stored.headers).toEqual({ "X-Tenant": "acme" });
|
||||||
{ key: "X-Tenant", value: "acme" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a non-object config on PUT with 400", async () => {
|
it("rejects a non-object config on PUT with 400", async () => {
|
||||||
|
|
@ -605,7 +599,7 @@ describe("/api/servers routes", () => {
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validateSettings coerces empty-string OAuth fields to absent (cleared inputs don't read as 'configured')", async () => {
|
it("validateSettings coerces empty-string OAuth fields to absent (cleared inputs don't produce an oauth node on disk)", async () => {
|
||||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -624,13 +618,11 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers["empty-oauth"] as {
|
const stored = readConfig(h.configPath).mcpServers[
|
||||||
settings?: Record<string, unknown>;
|
"empty-oauth"
|
||||||
};
|
] as unknown as Record<string, unknown>;
|
||||||
expect(stored.settings).toBeDefined();
|
// No `oauth` node on disk — every field was empty.
|
||||||
expect(stored.settings).not.toHaveProperty("oauthClientId");
|
expect(stored).not.toHaveProperty("oauth");
|
||||||
expect(stored.settings).not.toHaveProperty("oauthClientSecret");
|
|
||||||
expect(stored.settings).not.toHaveProperty("oauthScopes");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validateSettings drops unknown keys (explicit pick-and-build, not spread)", async () => {
|
it("validateSettings drops unknown keys (explicit pick-and-build, not spread)", async () => {
|
||||||
|
|
@ -651,24 +643,22 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers["unknown-keys"] as {
|
const stored = readConfig(h.configPath).mcpServers[
|
||||||
settings?: Record<string, unknown>;
|
"unknown-keys"
|
||||||
};
|
] as unknown as Record<string, unknown>;
|
||||||
expect(stored.settings).toBeDefined();
|
expect(stored).not.toHaveProperty("stowaway");
|
||||||
expect(stored.settings).not.toHaveProperty("stowaway");
|
expect(stored).not.toHaveProperty("settings");
|
||||||
expect(stored.settings).toEqual({
|
// Only the non-empty/non-zero settings field round-tripped to disk.
|
||||||
headers: [{ key: "X-A", value: "1" }],
|
expect(stored.headers).toEqual({ "X-A": "1" });
|
||||||
metadata: [],
|
|
||||||
connectionTimeout: 0,
|
|
||||||
requestTimeout: 0,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips smuggled config.settings on POST (validateSettings is the only write path)", async () => {
|
it("strips smuggled Inspector-extension keys from config on POST (envelope is the only write path)", async () => {
|
||||||
// `normalizeServerType` spreads unknown keys verbatim. Without the
|
// `normalizeServerType` spreads unknown keys verbatim. Without the
|
||||||
// strip in buildStoredEntry, a body that nests `settings` inside
|
// strip in buildStoredEntry, a body that nests Inspector-extension
|
||||||
// `config` would land it on the stored entry without ever passing
|
// keys inside `config` would land them on the stored entry without
|
||||||
// through `validateSettings`. Pin the strip.
|
// ever passing through `validateSettings`. Pin the strip for both
|
||||||
|
// the legacy `settings` key and the new flat keys (`headers`,
|
||||||
|
// `metadata`, `connectionTimeout`, `requestTimeout`, `oauth`).
|
||||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -678,17 +668,21 @@ describe("/api/servers routes", () => {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "node",
|
command: "node",
|
||||||
settings: { bogus: true },
|
settings: { bogus: true },
|
||||||
|
headers: { Smuggled: "yes" },
|
||||||
|
oauth: { clientId: "smuggled" },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers["smuggle-post"] as {
|
const stored = readConfig(h.configPath).mcpServers[
|
||||||
settings?: unknown;
|
"smuggle-post"
|
||||||
};
|
] as unknown as Record<string, unknown>;
|
||||||
expect(stored.settings).toBeUndefined();
|
expect(stored).not.toHaveProperty("settings");
|
||||||
|
expect(stored).not.toHaveProperty("headers");
|
||||||
|
expect(stored).not.toHaveProperty("oauth");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips smuggled config.settings on PUT even when settings:null clears the real node", async () => {
|
it("strips smuggled Inspector-extension keys from config on PUT even when settings:null clears the real fields", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
h.configPath,
|
h.configPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
@ -696,18 +690,14 @@ describe("/api/servers routes", () => {
|
||||||
smuggle: {
|
smuggle: {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "node",
|
command: "node",
|
||||||
settings: {
|
headers: { "X-Real": "yes" },
|
||||||
headers: [{ key: "X-Real", value: "yes" }],
|
|
||||||
metadata: [],
|
|
||||||
connectionTimeout: 0,
|
|
||||||
requestTimeout: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// settings: null clears the real settings node; the bogus key nested
|
// settings: null clears the real Inspector-extension fields; the bogus
|
||||||
// under config must not re-attach via the spread inside normalizeServerType.
|
// keys nested under config must not re-attach via the spread inside
|
||||||
|
// normalizeServerType.
|
||||||
const res = await fetch(`${h.baseUrl}/api/servers/smuggle`, {
|
const res = await fetch(`${h.baseUrl}/api/servers/smuggle`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -716,15 +706,18 @@ describe("/api/servers routes", () => {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "node",
|
command: "node",
|
||||||
settings: { bogus: true },
|
settings: { bogus: true },
|
||||||
|
headers: { Smuggled: "yes" },
|
||||||
|
connectionTimeout: 9999,
|
||||||
},
|
},
|
||||||
settings: null,
|
settings: null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers.smuggle as {
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
settings?: unknown;
|
.smuggle as unknown as Record<string, unknown>;
|
||||||
};
|
expect(stored).not.toHaveProperty("settings");
|
||||||
expect(stored.settings).toBeUndefined();
|
expect(stored).not.toHaveProperty("headers");
|
||||||
|
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a settings array (not an object) with 400", async () => {
|
it("rejects a settings array (not an object) with 400", async () => {
|
||||||
|
|
@ -742,42 +735,51 @@ describe("/api/servers routes", () => {
|
||||||
expect(body.error).toMatch(/object/);
|
expect(body.error).toMatch(/object/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips a malformed settings node on read (lenient-on-read; preserve-on-PUT cannot propagate it)", async () => {
|
it("drops a legacy nested `settings` node on read (hard cutover per #1358 decision 4)", async () => {
|
||||||
// Hand-edited mcp.json with a malformed `settings.headers` (string
|
// A user upgrading from the one-#1352 v2/main build has a file with a
|
||||||
// instead of array). The write-path validator would reject it, but a
|
// nested `settings` block. Per the hard-cutover decision the persisted
|
||||||
// user could write it directly. GET should surface the entry with
|
// headers / metadata / timeouts / OAuth credentials are intentionally
|
||||||
// settings dropped, and a subsequent config-only PUT should not
|
// lost on first read — users re-enter them through the form or hand-
|
||||||
// re-attach the broken node via the preserve branch.
|
// edit the file into the flat shape. GET surfaces the entry with
|
||||||
|
// settings dropped, and a subsequent config-only PUT does not
|
||||||
|
// re-attach the legacy node via the preserve branch.
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
h.configPath,
|
h.configPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
broken: {
|
legacy: {
|
||||||
type: "streamable-http",
|
type: "streamable-http",
|
||||||
url: "https://x.test/mcp",
|
url: "https://x.test/mcp",
|
||||||
settings: {
|
settings: {
|
||||||
headers: "oops",
|
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
oauthClientId: "client-abc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET surfaces the entry without the malformed settings.
|
// GET surfaces the entry with the legacy settings dropped — no flat
|
||||||
|
// fields lifted in either (this is hard cutover, not migration).
|
||||||
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
||||||
expect(getRes.status).toBe(200);
|
expect(getRes.status).toBe(200);
|
||||||
const getBody = (await getRes.json()) as {
|
const getBody = (await getRes.json()) as {
|
||||||
mcpServers: Record<string, { settings?: unknown }>;
|
mcpServers: Record<string, Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
expect(getBody.mcpServers.broken).toBeDefined();
|
const fetched = getBody.mcpServers.legacy!;
|
||||||
expect(getBody.mcpServers.broken!.settings).toBeUndefined();
|
expect(fetched).toBeDefined();
|
||||||
|
expect(fetched).not.toHaveProperty("settings");
|
||||||
|
expect(fetched).not.toHaveProperty("headers");
|
||||||
|
expect(fetched).not.toHaveProperty("metadata");
|
||||||
|
expect(fetched).not.toHaveProperty("connectionTimeout");
|
||||||
|
expect(fetched).not.toHaveProperty("oauth");
|
||||||
|
|
||||||
// PUT without settings preserves the (now-stripped) absence, not the
|
// PUT without settings preserves the (now-cleared) absence; the next
|
||||||
// original malformed node.
|
// save persists the flat shape with no `settings` field.
|
||||||
const putRes = await fetch(`${h.baseUrl}/api/servers/broken`, {
|
const putRes = await fetch(`${h.baseUrl}/api/servers/legacy`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -785,10 +787,108 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(putRes.status).toBe(200);
|
expect(putRes.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers.broken as {
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
settings?: unknown;
|
.legacy as unknown as Record<string, unknown>;
|
||||||
|
expect(stored).not.toHaveProperty("settings");
|
||||||
|
expect(stored).not.toHaveProperty("headers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops individually malformed Inspector-extension fields on read (read-path shape validation)", async () => {
|
||||||
|
// A hand-edited mcp.json with a mix of valid and malformed
|
||||||
|
// Inspector-extension fields. `normalizeMcpServers` drops each bad
|
||||||
|
// field independently with a warn — so one wrong key doesn't take
|
||||||
|
// out the whole entry, and garbage values can't reach the form via
|
||||||
|
// the disk → memory converter.
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
bad: {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://x.test/mcp",
|
||||||
|
// Good: should round-trip
|
||||||
|
headers: { "X-Keep": "yes" },
|
||||||
|
// Bad: string instead of pair-array → drop
|
||||||
|
metadata: "oops",
|
||||||
|
// Bad: non-number timeout → drop
|
||||||
|
connectionTimeout: "30s",
|
||||||
|
// Good: valid number
|
||||||
|
requestTimeout: 60000,
|
||||||
|
// Bad: array instead of object → drop
|
||||||
|
oauth: ["not", "an", "object"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const getBody = (await getRes.json()) as {
|
||||||
|
mcpServers: Record<string, Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
expect(stored.settings).toBeUndefined();
|
const fetched = getBody.mcpServers.bad!;
|
||||||
|
// Good fields survive.
|
||||||
|
expect(fetched.headers).toEqual({ "X-Keep": "yes" });
|
||||||
|
expect(fetched.requestTimeout).toBe(60000);
|
||||||
|
// Bad fields are dropped.
|
||||||
|
expect(fetched).not.toHaveProperty("metadata");
|
||||||
|
expect(fetched).not.toHaveProperty("connectionTimeout");
|
||||||
|
expect(fetched).not.toHaveProperty("oauth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a hand-edited file with top-level Claude Code-style `headers` (interop with `.mcp.json`)", async () => {
|
||||||
|
// A user pastes a server entry copied from the Claude Code docs:
|
||||||
|
// top-level `headers: { ... }`, no settings wrapper. GET should
|
||||||
|
// surface the entry verbatim (flat disk shape) and a subsequent
|
||||||
|
// settings-only PUT must preserve the headers when omitted.
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
"api-server": {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://api.example.com/mcp",
|
||||||
|
headers: { Authorization: "Bearer the-token" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const getBody = (await getRes.json()) as {
|
||||||
|
mcpServers: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
expect(getBody.mcpServers["api-server"]!.headers).toEqual({
|
||||||
|
Authorization: "Bearer the-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings-only PUT (no `config`) preserves the existing url AND the
|
||||||
|
// existing headers per the preserve-on-omit branch.
|
||||||
|
const putRes = await fetch(`${h.baseUrl}/api/servers/api-server`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
headers: [
|
||||||
|
{ key: "Authorization", value: "Bearer the-token" },
|
||||||
|
{ key: "X-Tenant", value: "acme" },
|
||||||
|
],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(putRes.status).toBe(200);
|
||||||
|
const stored = readConfig(h.configPath).mcpServers[
|
||||||
|
"api-server"
|
||||||
|
] as unknown as Record<string, unknown>;
|
||||||
|
expect(stored.url).toBe("https://api.example.com/mcp");
|
||||||
|
expect(stored.headers).toEqual({
|
||||||
|
Authorization: "Bearer the-token",
|
||||||
|
"X-Tenant": "acme",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,11 @@ import type {
|
||||||
} from "../../types.js";
|
} from "../../types.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SEED_CONFIG,
|
DEFAULT_SEED_CONFIG,
|
||||||
|
INSPECTOR_FIELD_KEYS,
|
||||||
|
inspectorSettingsToStoredFields,
|
||||||
normalizeServerType,
|
normalizeServerType,
|
||||||
|
storedFieldsToInspectorSettings,
|
||||||
|
stripInspectorFields,
|
||||||
} from "../../serverList.js";
|
} from "../../serverList.js";
|
||||||
import { RemoteSession } from "./remote-session.js";
|
import { RemoteSession } from "./remote-session.js";
|
||||||
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
|
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
|
||||||
|
|
@ -790,46 +794,171 @@ export function createRemoteApp(
|
||||||
|
|
||||||
// --- /api/servers (server list backed by mcp.json) ---
|
// --- /api/servers (server list backed by mcp.json) ---
|
||||||
|
|
||||||
|
// Match handleWatcherError's pattern: log to the configured logger if
|
||||||
|
// present, fall back to console.warn for visibility in `npm run dev`
|
||||||
|
// where no logger is wired up. Both gates below feed user-visible
|
||||||
|
// signals (legacy on-disk shape, client bug smuggling fields), so a
|
||||||
|
// silent drop is worse than a console line.
|
||||||
|
const logWarn = (bindings: Record<string, unknown>, msg: string): void => {
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.warn(bindings, msg);
|
||||||
|
} else {
|
||||||
|
console.warn("[mcp.json]", msg, bindings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Defensive normalize so editor-edited files with type:"http" or missing
|
// Defensive normalize so editor-edited files with type:"http" or missing
|
||||||
// type round-trip into the canonical form on every read. Settings that
|
// type round-trip into the canonical form on every read. Inspector-
|
||||||
// fail `validateSettings` are silently stripped (lenient-on-read pattern
|
// extension fields (headers / metadata / connectionTimeout / requestTimeout
|
||||||
// matching `normalizeServerType`'s unknown→stdio fallback) so a malformed
|
// / oauth) sit at the top level of each entry post-#1358; this function
|
||||||
// hand-edit can't propagate through preserve-on-PUT.
|
// is the read-side gate — it validates each field's shape and drops
|
||||||
const normalizeMcpServers = (raw: unknown): Record<string, StoredMCPServer> => {
|
// anything malformed with a logged warn, so a hand-edited file with
|
||||||
|
// `headers: "oops"` can't put garbage rows into the form.
|
||||||
|
//
|
||||||
|
// Legacy `settings` nodes written by the pre-#1358 build (one #1352
|
||||||
|
// release on v2/main that never shipped stable) are dropped with a
|
||||||
|
// warn — those persisted headers / metadata / timeouts / OAuth
|
||||||
|
// credentials are intentionally lost on first read (hard cutover per
|
||||||
|
// #1358 decision 4). Users re-enter via the form or hand-edit into the
|
||||||
|
// flat shape.
|
||||||
|
const isStringRecord = (v: unknown): v is Record<string, string> => {
|
||||||
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||||
|
for (const val of Object.values(v as Record<string, unknown>)) {
|
||||||
|
if (typeof val !== "string") return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const isKvArray = (v: unknown): v is { key: string; value: string }[] => {
|
||||||
|
if (!Array.isArray(v)) return false;
|
||||||
|
return v.every(
|
||||||
|
(e) =>
|
||||||
|
e !== null &&
|
||||||
|
typeof e === "object" &&
|
||||||
|
typeof (e as Record<string, unknown>).key === "string" &&
|
||||||
|
typeof (e as Record<string, unknown>).value === "string",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const isOauthObject = (
|
||||||
|
v: unknown,
|
||||||
|
): v is { clientId?: string; clientSecret?: string; scopes?: string } => {
|
||||||
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
|
||||||
|
if (o[k] !== undefined && typeof o[k] !== "string") return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// `Number.isFinite` rejects `Infinity` and `NaN` as well as non-numbers,
|
||||||
|
// matching the write-side semantics in `validateSettings`. A hand-edited
|
||||||
|
// file with `connectionTimeout: Infinity` would otherwise pass the guard
|
||||||
|
// and propagate to the form (where it has no useful meaning).
|
||||||
|
const isNonNegNumber = (v: unknown): v is number =>
|
||||||
|
typeof v === "number" && Number.isFinite(v) && v >= 0;
|
||||||
|
|
||||||
|
const normalizeMcpServers = (
|
||||||
|
raw: unknown,
|
||||||
|
): Record<string, StoredMCPServer> => {
|
||||||
if (!raw || typeof raw !== "object") return {};
|
if (!raw || typeof raw !== "object") return {};
|
||||||
const out: Record<string, StoredMCPServer> = {};
|
const out: Record<string, StoredMCPServer> = {};
|
||||||
for (const [id, val] of Object.entries(raw as Record<string, unknown>)) {
|
for (const [id, val] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
if (!val || typeof val !== "object") continue;
|
if (!val || typeof val !== "object") continue;
|
||||||
const normalized = normalizeServerType(
|
// `valObj` is the per-entry object we'll mutate in place via the
|
||||||
val as Record<string, unknown> & { type?: string },
|
// `delete` calls below. Safe because the only callers
|
||||||
) as StoredMCPServer;
|
// (`readMcpConfig` and the GET handler's file-present branch — the
|
||||||
if (normalized.settings !== undefined) {
|
// seed branch returns `DEFAULT_SEED_CONFIG` without normalizing)
|
||||||
const checked = validateSettings(normalized.settings);
|
// pass in freshly-parsed JSON they don't retain a reference to
|
||||||
if (checked.ok) {
|
// elsewhere.
|
||||||
normalized.settings = checked.value;
|
const valObj = val as Record<string, unknown>;
|
||||||
} else {
|
|
||||||
delete normalized.settings;
|
// Strip a legacy nested `settings` node before normalizeServerType
|
||||||
}
|
// would spread it through. Hard cutover per decision 4.
|
||||||
|
if ("settings" in valObj) {
|
||||||
|
logWarn(
|
||||||
|
{ route: "/api/servers", id, droppedKey: "settings" },
|
||||||
|
"Dropping legacy `settings` node from mcp.json entry — fields now live at the top level. Re-enter via the settings form or hand-edit the file into the flat shape.",
|
||||||
|
);
|
||||||
|
delete valObj.settings;
|
||||||
}
|
}
|
||||||
out[id] = normalized;
|
|
||||||
|
// Per-field shape validation on the Inspector-extension keys. The
|
||||||
|
// write path (validateSettings) is symmetric — same checks. Drop
|
||||||
|
// bad shapes individually so a single malformed key doesn't take
|
||||||
|
// out the rest of the entry; log so the user can fix the file.
|
||||||
|
if ("headers" in valObj && !isStringRecord(valObj.headers)) {
|
||||||
|
logWarn(
|
||||||
|
{ route: "/api/servers", id, droppedKey: "headers" },
|
||||||
|
"Dropping malformed `headers` field — expected `Record<string, string>`.",
|
||||||
|
);
|
||||||
|
delete valObj.headers;
|
||||||
|
}
|
||||||
|
if ("metadata" in valObj && !isKvArray(valObj.metadata)) {
|
||||||
|
logWarn(
|
||||||
|
{ route: "/api/servers", id, droppedKey: "metadata" },
|
||||||
|
"Dropping malformed `metadata` field — expected `Array<{ key: string, value: string }>`.",
|
||||||
|
);
|
||||||
|
delete valObj.metadata;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"connectionTimeout" in valObj &&
|
||||||
|
!isNonNegNumber(valObj.connectionTimeout)
|
||||||
|
) {
|
||||||
|
logWarn(
|
||||||
|
{ route: "/api/servers", id, droppedKey: "connectionTimeout" },
|
||||||
|
"Dropping malformed `connectionTimeout` field — expected non-negative number.",
|
||||||
|
);
|
||||||
|
delete valObj.connectionTimeout;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"requestTimeout" in valObj &&
|
||||||
|
!isNonNegNumber(valObj.requestTimeout)
|
||||||
|
) {
|
||||||
|
logWarn(
|
||||||
|
{ route: "/api/servers", id, droppedKey: "requestTimeout" },
|
||||||
|
"Dropping malformed `requestTimeout` field — expected non-negative number.",
|
||||||
|
);
|
||||||
|
delete valObj.requestTimeout;
|
||||||
|
}
|
||||||
|
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
||||||
|
logWarn(
|
||||||
|
{ route: "/api/servers", id, droppedKey: "oauth" },
|
||||||
|
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes? }`.",
|
||||||
|
);
|
||||||
|
delete valObj.oauth;
|
||||||
|
}
|
||||||
|
|
||||||
|
out[id] = normalizeServerType(
|
||||||
|
valObj as Record<string, unknown> & { type?: string },
|
||||||
|
) as StoredMCPServer;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a single on-disk entry from `{ config, settings }`, normalizing the
|
// Build a single on-disk entry from `{ config, settings }`, normalizing the
|
||||||
// type discriminator and attaching settings only when defined.
|
// type discriminator and splatting Inspector-extension fields onto the
|
||||||
|
// entry as direct keys (post-#1358 flat shape).
|
||||||
//
|
//
|
||||||
// `normalizeServerType` spreads unknown keys from the incoming config
|
// `normalizeServerType` spreads unknown keys from the incoming config
|
||||||
// through verbatim, so a caller that included `config.settings` on the
|
// through verbatim, so a caller that included `config.settings` (or any
|
||||||
// wire would smuggle a `settings` field straight onto the stored entry —
|
// of the now-flat Inspector keys) on the wire would smuggle those values
|
||||||
// bypassing `validateSettings`. Strip it here so `validateSettings`
|
// onto the stored entry — bypassing `validateSettings`. Strip them here
|
||||||
// remains the single write path for the settings node. Log a warning if
|
// so `validateSettings` remains the single write path for those fields.
|
||||||
// we observe this — it indicates a client bug (settings should travel
|
// Log a warning if we observe this — it indicates a client bug (settings
|
||||||
// through the body's top-level `settings` field, not nested in config).
|
// travel through the body's top-level `settings` field per the kept-
|
||||||
|
// envelope wire shape from #1358 decision 5, not nested in config).
|
||||||
//
|
//
|
||||||
// `id` is threaded through purely so the warning correlates to a
|
// `id` is threaded through purely so the warning correlates to a specific
|
||||||
// specific entry; the route ultimately reaches here via POST or PUT and
|
// entry; the route ultimately reaches here via POST or PUT and both know
|
||||||
// both know the target id at call time.
|
// the target id at call time.
|
||||||
|
//
|
||||||
|
// The set of guarded keys is the source-of-truth `INSPECTOR_FIELD_KEYS`
|
||||||
|
// plus the legacy `"settings"` wrapper key. Adding a new Inspector-
|
||||||
|
// extension field to `StoredMCPServer` propagates through the
|
||||||
|
// `satisfies` check in `serverList.ts` and updates this guard
|
||||||
|
// automatically; nothing to remember to update here.
|
||||||
|
const SMUGGLE_GUARDED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||||
|
...INSPECTOR_FIELD_KEYS,
|
||||||
|
"settings",
|
||||||
|
]);
|
||||||
const buildStoredEntry = (
|
const buildStoredEntry = (
|
||||||
id: string,
|
id: string,
|
||||||
config: unknown,
|
config: unknown,
|
||||||
|
|
@ -843,18 +972,26 @@ export function createRemoteApp(
|
||||||
config !== null && typeof config === "object"
|
config !== null && typeof config === "object"
|
||||||
? (config as Record<string, unknown>)
|
? (config as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
if ("settings" in configObj) {
|
const smuggled: string[] = [];
|
||||||
fileLogger?.warn(
|
const configOnly: Record<string, unknown> = {};
|
||||||
{ route: "/api/servers", id, smuggledKey: "settings" },
|
for (const [k, v] of Object.entries(configObj)) {
|
||||||
"Stripping `settings` key from request body's `config` — settings must travel through the top-level `settings` field, not nested inside `config`.",
|
if (SMUGGLE_GUARDED_KEYS.has(k)) {
|
||||||
|
smuggled.push(k);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
configOnly[k] = v;
|
||||||
|
}
|
||||||
|
if (smuggled.length > 0) {
|
||||||
|
logWarn(
|
||||||
|
{ route: "/api/servers", id, smuggledKeys: smuggled },
|
||||||
|
"Stripping Inspector-extension keys from request body's `config` — those must travel through the top-level `settings` field, not nested inside `config`.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { settings: _smuggled, ...configOnly } = configObj;
|
|
||||||
const normalized = normalizeServerType(
|
const normalized = normalizeServerType(
|
||||||
configOnly as Record<string, unknown> & { type?: string },
|
configOnly as Record<string, unknown> & { type?: string },
|
||||||
) as StoredMCPServer;
|
) as StoredMCPServer;
|
||||||
if (settings !== undefined) {
|
if (settings !== undefined) {
|
||||||
normalized.settings = settings;
|
Object.assign(normalized, inspectorSettingsToStoredFields(settings));
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
@ -1119,17 +1256,36 @@ export function createRemoteApp(
|
||||||
// malformed settings node on *other* servers in the file — a
|
// malformed settings node on *other* servers in the file — a
|
||||||
// deliberate side-effect of using `readMcpConfig` + full rewrite
|
// deliberate side-effect of using `readMcpConfig` + full rewrite
|
||||||
// here.
|
// here.
|
||||||
const existing = current.mcpServers[originalId]!;
|
const existing = current.mcpServers[originalId];
|
||||||
// Split the existing entry into its config (everything except
|
if (!existing) {
|
||||||
// settings) and its settings, then apply patch semantics from the
|
// The `in` check above guarantees this branch is unreachable;
|
||||||
// body to each.
|
// narrowing without the non-null assertion keeps TS happy and
|
||||||
const { settings: _existingSettings, ...existingConfig } = existing;
|
// makes the contract explicit for future refactors.
|
||||||
|
return c.json(
|
||||||
|
{ error: `Server '${originalId}' not found` },
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Split the existing entry into its SDK-only config (no Inspector-
|
||||||
|
// extension fields) and its lifted settings, then apply patch
|
||||||
|
// semantics from the body to each. The flat-on-disk Inspector
|
||||||
|
// fields are sliced off `existing` so the preserve-on-omit `config`
|
||||||
|
// path doesn't accidentally carry them through as raw disk keys —
|
||||||
|
// they need to flow through `buildStoredEntry` so empty `settings`
|
||||||
|
// intents can clear them.
|
||||||
|
//
|
||||||
|
// `stripInspectorFields` + `storedFieldsToInspectorSettings` both
|
||||||
|
// derive from the same `INSPECTOR_FIELD_KEYS` set, so adding a new
|
||||||
|
// Inspector-extension field to `StoredMCPServer` doesn't silently
|
||||||
|
// leak through this preserve path.
|
||||||
|
const existingConfig = stripInspectorFields(existing);
|
||||||
|
const existingSettings = storedFieldsToInspectorSettings(existing);
|
||||||
const nextConfig =
|
const nextConfig =
|
||||||
body.config !== undefined ? body.config : existingConfig;
|
body.config !== undefined ? body.config : existingConfig;
|
||||||
let nextSettings: InspectorServerSettings | undefined;
|
let nextSettings: InspectorServerSettings | undefined;
|
||||||
switch (settingsIntent.kind) {
|
switch (settingsIntent.kind) {
|
||||||
case "preserve":
|
case "preserve":
|
||||||
nextSettings = existing.settings;
|
nextSettings = existingSettings;
|
||||||
break;
|
break;
|
||||||
case "clear":
|
case "clear":
|
||||||
nextSettings = undefined;
|
nextSettings = undefined;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
InspectorServerSettings,
|
||||||
MCPConfig,
|
MCPConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ServerEntry,
|
ServerEntry,
|
||||||
|
|
@ -50,18 +51,186 @@ export function normalizeServerType(
|
||||||
return { ...config, type: normalizedType } as MCPServerConfig;
|
return { ...config, type: normalizedType } as MCPServerConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Inspector-extension fields that live as direct keys on a `StoredMCPServer`
|
||||||
|
* (post-#1358) — split out from `MCPServerConfig` so both directions of the
|
||||||
|
* converter can name them in one place. Equivalent to
|
||||||
|
* `Pick<StoredMCPServer, "headers" | "metadata" | ...>` without re-listing.
|
||||||
|
*/
|
||||||
|
type StoredInspectorFields = Pick<
|
||||||
|
StoredMCPServer,
|
||||||
|
"headers" | "metadata" | "connectionTimeout" | "requestTimeout" | "oauth"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
|
||||||
|
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
|
||||||
|
* and the rest of the in-memory layer consume. Returns `undefined` when none
|
||||||
|
* of the source fields are present so callers can skip attaching a settings
|
||||||
|
* node to entries that don't have one.
|
||||||
|
*
|
||||||
|
* `headers` becomes a pair-array preserving the object's key insertion order;
|
||||||
|
* `oauth.*` becomes the flat `oauthClientId` / `oauthClientSecret` /
|
||||||
|
* `oauthScopes` fields. Numeric timeouts default to 0 when absent — the form
|
||||||
|
* needs concrete values to render and 0 is the SDK's "no timeout" signal.
|
||||||
|
*/
|
||||||
|
export function storedFieldsToInspectorSettings(
|
||||||
|
stored: StoredInspectorFields,
|
||||||
|
): InspectorServerSettings | undefined {
|
||||||
|
const hasAny =
|
||||||
|
stored.headers !== undefined ||
|
||||||
|
stored.metadata !== undefined ||
|
||||||
|
stored.connectionTimeout !== undefined ||
|
||||||
|
stored.requestTimeout !== undefined ||
|
||||||
|
stored.oauth !== undefined;
|
||||||
|
if (!hasAny) return undefined;
|
||||||
|
|
||||||
|
const headersPairs: { key: string; value: string }[] = stored.headers
|
||||||
|
? Object.entries(stored.headers).map(([key, value]) => ({ key, value }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const settings: InspectorServerSettings = {
|
||||||
|
headers: headersPairs,
|
||||||
|
metadata: stored.metadata ?? [],
|
||||||
|
connectionTimeout: stored.connectionTimeout ?? 0,
|
||||||
|
requestTimeout: stored.requestTimeout ?? 0,
|
||||||
|
};
|
||||||
|
// Truthiness drops empty-string OAuth fields — mirrors the write-side
|
||||||
|
// coercion in `validateSettings` (server.ts) so a round-trip can't
|
||||||
|
// accidentally surface `oauthClientId: ""` to the form, where the
|
||||||
|
// OAuth manager would misread it as "configured."
|
||||||
|
if (stored.oauth?.clientId) settings.oauthClientId = stored.oauth.clientId;
|
||||||
|
if (stored.oauth?.clientSecret)
|
||||||
|
settings.oauthClientSecret = stored.oauth.clientSecret;
|
||||||
|
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splat the form-shape `InspectorServerSettings` back into the on-disk
|
||||||
|
* Inspector-extension fields (object-form `headers`, nested `oauth`, etc.).
|
||||||
|
* Empty-key rows are dropped — the form lets users leave new rows blank
|
||||||
|
* mid-edit and those shouldn't reach disk. Numeric timeouts at 0 are omitted
|
||||||
|
* so the file diff stays minimal for entries that never touched them.
|
||||||
|
*
|
||||||
|
* Returns the field deltas to merge onto a `StoredMCPServer`; callers can
|
||||||
|
* spread the result.
|
||||||
|
*/
|
||||||
|
export function inspectorSettingsToStoredFields(
|
||||||
|
settings: InspectorServerSettings,
|
||||||
|
): StoredInspectorFields {
|
||||||
|
const out: StoredInspectorFields = {};
|
||||||
|
|
||||||
|
const headersRecord: Record<string, string> = {};
|
||||||
|
for (const { key, value } of settings.headers) {
|
||||||
|
if (key.trim() === "") continue;
|
||||||
|
headersRecord[key] = value;
|
||||||
|
}
|
||||||
|
if (Object.keys(headersRecord).length > 0) {
|
||||||
|
out.headers = headersRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataFiltered = settings.metadata.filter(
|
||||||
|
(m) => m.key.trim() !== "",
|
||||||
|
);
|
||||||
|
if (metadataFiltered.length > 0) {
|
||||||
|
out.metadata = metadataFiltered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.connectionTimeout > 0) {
|
||||||
|
out.connectionTimeout = settings.connectionTimeout;
|
||||||
|
}
|
||||||
|
if (settings.requestTimeout > 0) {
|
||||||
|
out.requestTimeout = settings.requestTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthFields: {
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
scopes?: string;
|
||||||
|
} = {};
|
||||||
|
if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId;
|
||||||
|
if (settings.oauthClientSecret)
|
||||||
|
oauthFields.clientSecret = settings.oauthClientSecret;
|
||||||
|
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
|
||||||
|
if (Object.keys(oauthFields).length > 0) {
|
||||||
|
out.oauth = oauthFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source of truth for the set of Inspector-extension keys that live at the
|
||||||
|
* top level of a `StoredMCPServer`. Enumerated through a map keyed by
|
||||||
|
* `keyof StoredInspectorFields` with a `satisfies` constraint so any new
|
||||||
|
* field added to the type forces a compile error here — the disk → memory
|
||||||
|
* converter slice, the server-side smuggle guard, and the PUT preserve
|
||||||
|
* path all derive from this single source.
|
||||||
|
*
|
||||||
|
* Don't replace this with a hand-typed string array — `satisfies
|
||||||
|
* Record<keyof StoredInspectorFields, true>` is what gives us the
|
||||||
|
* exhaustive check. `as const` plus the `satisfies` clause yields a
|
||||||
|
* narrow tuple-of-literals type that downstream consumers can use as
|
||||||
|
* `(keyof StoredInspectorFields)[]`.
|
||||||
|
*/
|
||||||
|
const INSPECTOR_FIELD_KEY_MAP = {
|
||||||
|
headers: true,
|
||||||
|
metadata: true,
|
||||||
|
connectionTimeout: true,
|
||||||
|
requestTimeout: true,
|
||||||
|
oauth: true,
|
||||||
|
} as const satisfies Record<keyof StoredInspectorFields, true>;
|
||||||
|
|
||||||
|
export const INSPECTOR_FIELD_KEYS = new Set(
|
||||||
|
Object.keys(INSPECTOR_FIELD_KEY_MAP) as (keyof StoredInspectorFields)[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the Inspector-extension fields off a `StoredMCPServer` so the
|
||||||
|
* remainder is the pure SDK config shape the PUT route's preserve path
|
||||||
|
* needs. Source-of-truth driven via `INSPECTOR_FIELD_KEYS` so adding a
|
||||||
|
* new extension field doesn't silently leak through this slice — the
|
||||||
|
* `satisfies` constraint above forces the map update, which propagates
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
|
export function stripInspectorFields(
|
||||||
|
stored: StoredMCPServer,
|
||||||
|
): MCPServerConfig {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(
|
||||||
|
stored as unknown as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) continue;
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out as unknown as MCPServerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert the on-disk `MCPConfig` into the `ServerEntry[]` the Servers screen
|
* Convert the on-disk `MCPConfig` into the `ServerEntry[]` the Servers screen
|
||||||
* consumes. Map key becomes both `id` and `name`. Connection state initializes
|
* consumes. Map key becomes both `id` and `name`. Connection state initializes
|
||||||
* to `disconnected` — the React layer drives it from there. The optional
|
* to `disconnected` — the React layer drives it from there. Inspector-extension
|
||||||
* `settings` node is lifted from the stored entry into `ServerEntry.settings`
|
* fields (post-#1358 flat shape) are lifted into `ServerEntry.settings` so the
|
||||||
* so the rest of the app sees `config` as the pure SDK shape.
|
* rest of the app sees `config` as the pure SDK shape.
|
||||||
*/
|
*/
|
||||||
export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
|
export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
|
||||||
return Object.entries(config.mcpServers).map(([id, raw]) => {
|
return Object.entries(config.mcpServers).map(([id, raw]) => {
|
||||||
const { settings, ...rest } = raw as StoredMCPServer;
|
// Separate Inspector-extension fields from the SDK-only config so the
|
||||||
|
// transport never sees `entry.config.headers` (which would be ambiguous
|
||||||
|
// — pair-array in memory, object on disk). Headers live on the wire via
|
||||||
|
// `InspectorServerSettings` only.
|
||||||
|
const inspectorFields: StoredInspectorFields = {};
|
||||||
|
const sdkOnly: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(raw as unknown as Record<string, unknown>)) {
|
||||||
|
if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) {
|
||||||
|
(inspectorFields as Record<string, unknown>)[k] = v;
|
||||||
|
} else {
|
||||||
|
sdkOnly[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
const normalizedConfig = normalizeServerType(
|
const normalizedConfig = normalizeServerType(
|
||||||
rest as unknown as Record<string, unknown> & { type?: string },
|
sdkOnly as Record<string, unknown> & { type?: string },
|
||||||
);
|
);
|
||||||
const entry: ServerEntry = {
|
const entry: ServerEntry = {
|
||||||
id,
|
id,
|
||||||
|
|
@ -69,6 +238,7 @@ export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
|
||||||
config: normalizedConfig,
|
config: normalizedConfig,
|
||||||
connection: { status: "disconnected" },
|
connection: { status: "disconnected" },
|
||||||
};
|
};
|
||||||
|
const settings = storedFieldsToInspectorSettings(inspectorFields);
|
||||||
if (settings !== undefined) entry.settings = settings;
|
if (settings !== undefined) entry.settings = settings;
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
|
|
@ -76,15 +246,18 @@ export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert `ServerEntry[]` back into `MCPConfig` for serialization. Strips
|
* Convert `ServerEntry[]` back into `MCPConfig` for serialization. Strips
|
||||||
* runtime-only fields (connection, info, name); persists `config` and the
|
* runtime-only fields (connection, info, name); persists `config` plus the
|
||||||
* optional `settings` node so the file remains a clean canonical `mcp.json`
|
* Inspector-extension fields as direct keys on the entry (post-#1358 flat
|
||||||
* plus the Inspector-specific extension.
|
* shape) so the file matches the Claude Code / Cursor / Cline `.mcp.json`
|
||||||
|
* convention.
|
||||||
*/
|
*/
|
||||||
export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig {
|
export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig {
|
||||||
const mcpServers: Record<string, StoredMCPServer> = {};
|
const mcpServers: Record<string, StoredMCPServer> = {};
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const stored: StoredMCPServer = { ...entry.config } as StoredMCPServer;
|
const stored: StoredMCPServer = { ...entry.config } as StoredMCPServer;
|
||||||
if (entry.settings !== undefined) stored.settings = entry.settings;
|
if (entry.settings !== undefined) {
|
||||||
|
Object.assign(stored, inspectorSettingsToStoredFields(entry.settings));
|
||||||
|
}
|
||||||
mcpServers[entry.id] = stored;
|
mcpServers[entry.id] = stored;
|
||||||
}
|
}
|
||||||
return { mcpServers };
|
return { mcpServers };
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,55 @@ export type MCPServerConfig =
|
||||||
export type ServerType = "stdio" | "sse" | "streamable-http";
|
export type ServerType = "stdio" | "sse" | "streamable-http";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On-disk shape for a single `mcp.json` server entry. The base is the
|
* On-disk shape for a single `mcp.json` server entry (post-#1358). The base
|
||||||
* SDK-compatible `MCPServerConfig`; the optional `settings` node is an
|
* is the SDK-compatible `MCPServerConfig`; each Inspector-specific extension
|
||||||
* Inspector-specific extension that other MCP tools simply ignore.
|
* field lives directly alongside `type` / `url` / `command` rather than
|
||||||
|
* under a nested `settings` wrapper. This matches the shape Claude Code /
|
||||||
|
* Cursor / Cline write to their own `.mcp.json` files (`headers` as a
|
||||||
|
* `Record<string, string>`, `oauth` as a nested object), so a hand-edited
|
||||||
|
* file from any of those tools is readable on Inspector's first connect.
|
||||||
|
*
|
||||||
|
* The in-memory + wire shape is unchanged from #1352: `InspectorServerSettings`
|
||||||
|
* keeps its pair-array `headers` and flat `oauth*` fields because the form
|
||||||
|
* needs them in that shape to drive controlled-component editing. The
|
||||||
|
* conversion between disk-flat and memory-pair-array lives in
|
||||||
|
* `serverList.ts` (`mcpConfigToServerEntries` /
|
||||||
|
* `serverEntriesToMcpConfig`) and the `/api/servers` route's
|
||||||
|
* `buildStoredEntry`.
|
||||||
|
*
|
||||||
|
* Files written by the pre-#1358 build (one #1352 release of v2/main that
|
||||||
|
* never shipped a stable tag) had a nested `settings` block here; that
|
||||||
|
* shape is dropped on read with a warn and not re-emitted on next write.
|
||||||
*/
|
*/
|
||||||
export type StoredMCPServer = MCPServerConfig & {
|
export type StoredMCPServer = MCPServerConfig & {
|
||||||
settings?: InspectorServerSettings;
|
/**
|
||||||
|
* HTTP headers for SSE / streamable-http transports. Persisted as a flat
|
||||||
|
* `Record<string, string>` matching the Claude Code / Cursor / Cline
|
||||||
|
* `.mcp.json` convention. Lifted into `InspectorServerSettings.headers`
|
||||||
|
* (pair-array form) when read into memory.
|
||||||
|
*/
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Default `_meta` keys merged into every outgoing MCP request. Inspector-
|
||||||
|
* specific (no analog in the broader mcp.json ecosystem), so the pair-array
|
||||||
|
* shape is preserved on disk and in memory.
|
||||||
|
*/
|
||||||
|
metadata?: { key: string; value: string }[];
|
||||||
|
/** Inspector-specific connect-time timeout (ms). */
|
||||||
|
connectionTimeout?: number;
|
||||||
|
/** Inspector-specific request timeout (ms). */
|
||||||
|
requestTimeout?: number;
|
||||||
|
/**
|
||||||
|
* Pre-configured OAuth client credentials for HTTP transports. Nested to
|
||||||
|
* match Claude Code's `.mcp.json` shape; lifted into the flat `oauthClientId`
|
||||||
|
* / `oauthClientSecret` / `oauthScopes` fields on `InspectorServerSettings`
|
||||||
|
* when read into memory.
|
||||||
|
*/
|
||||||
|
oauth?: {
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
scopes?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MCPConfig {
|
export interface MCPConfig {
|
||||||
|
|
@ -100,9 +143,10 @@ export interface ServerEntry {
|
||||||
config: MCPServerConfig;
|
config: MCPServerConfig;
|
||||||
/**
|
/**
|
||||||
* Optional per-server runtime settings (headers, metadata, timeouts, OAuth
|
* Optional per-server runtime settings (headers, metadata, timeouts, OAuth
|
||||||
* credentials). Lives alongside `config` on disk under the `settings` key.
|
* credentials). On disk these live as direct keys on the entry (post-#1358);
|
||||||
* Edited via ServerSettingsForm; consumed by the transport / InspectorClient
|
* in memory they're grouped here in the pair-array / flat-OAuth shape the
|
||||||
* at connect time.
|
* form needs for controlled-component editing. Edited via ServerSettingsForm;
|
||||||
|
* consumed by the transport / InspectorClient at connect time.
|
||||||
*/
|
*/
|
||||||
settings?: InspectorServerSettings;
|
settings?: InspectorServerSettings;
|
||||||
info?: Implementation;
|
info?: Implementation;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a file-backed list at `~/.mcp-inspector/mcp.json`, read at startup, mutated via REST endpoints, surfaced through a `useServers` hook. The file also stores the per-server **settings** (custom headers, request metadata, timeouts, pre-configured OAuth credentials) edited by `ServerSettingsForm` — see [Per-server settings](#per-server-settings-1352) below for the on-disk shape, UI rationale, and write/read invariants.
|
Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a file-backed list at `~/.mcp-inspector/mcp.json`, read at startup, mutated via REST endpoints, surfaced through a `useServers` hook. The file also stores the per-server **settings** (custom headers, request metadata, timeouts, pre-configured OAuth credentials) edited by `ServerSettingsForm` — see [Per-server settings](#per-server-settings-1352) below for the on-disk shape, UI rationale, and write/read invariants. Post-#1358 those settings fields live as direct keys on the entry (matching the Claude Code / Cursor / Cline `.mcp.json` convention) rather than under a nested `settings` block.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
|
|
@ -48,17 +48,19 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil
|
||||||
"acme-api": {
|
"acme-api": {
|
||||||
"type": "streamable-http",
|
"type": "streamable-http",
|
||||||
"url": "https://api.acme.example/mcp",
|
"url": "https://api.acme.example/mcp",
|
||||||
// Optional Inspector-specific extension. Other MCP tools
|
// Inspector-extension fields (post-#1358) live as direct keys on the
|
||||||
// (Claude Desktop, Cursor, Cline) ignore unknown keys. See
|
// entry, matching the Claude Code / Cursor / Cline `.mcp.json`
|
||||||
// [Per-server settings](#per-server-settings-1352) for the
|
// convention. See [Per-server settings](#per-server-settings-1352)
|
||||||
// full contract.
|
// for the full contract; the in-memory + wire shape keeps pair-array
|
||||||
"settings": {
|
// headers and flat oauth* fields for the form's controlled-component
|
||||||
"headers": [{ "key": "X-Tenant", "value": "acme" }],
|
// editing.
|
||||||
"metadata": [{ "key": "trace", "value": "abc" }],
|
"headers": { "X-Tenant": "acme" },
|
||||||
"connectionTimeout": 30000,
|
"metadata": [{ "key": "trace", "value": "abc" }],
|
||||||
"requestTimeout": 60000,
|
"connectionTimeout": 30000,
|
||||||
"oauthClientId": "client-abc",
|
"requestTimeout": 60000,
|
||||||
"oauthScopes": "read:tools write:tools"
|
"oauth": {
|
||||||
|
"clientId": "client-abc",
|
||||||
|
"scopes": "read:tools write:tools"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +71,7 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil
|
||||||
- `type` omitted → normalized to `"stdio"`; `type: "http"` → `"streamable-http"` (`normalizeServerType` in `core/mcp/node/config.ts:81`).
|
- `type` omitted → normalized to `"stdio"`; `type: "http"` → `"streamable-http"` (`normalizeServerType` in `core/mcp/node/config.ts:81`).
|
||||||
- The map key is the **server `id`**. `ServerEntry.id` already documents itself this way (`core/mcp/types.ts:89`: "The MCPConfig.mcpServers map key").
|
- The map key is the **server `id`**. `ServerEntry.id` already documents itself this way (`core/mcp/types.ts:89`: "The MCPConfig.mcpServers map key").
|
||||||
- Display name: derived from the map key. The edit dialog treats id and display name as the same field; renaming = key-rotate + carry config across.
|
- Display name: derived from the map key. The edit dialog treats id and display name as the same field; renaming = key-rotate + carry config across.
|
||||||
- Each entry may optionally carry a `settings` node alongside the transport keys (`headers`, `metadata`, timeouts, OAuth credentials — see [Per-server settings](#per-server-settings-1352) below). It is an Inspector-specific extension; other MCP tools (Claude Desktop, Cursor, Cline) treat it as an unknown key and ignore it.
|
- Each entry may optionally carry Inspector-extension fields (`headers`, `metadata`, `connectionTimeout`, `requestTimeout`, `oauth`) as direct keys on the entry — post-#1358 these are no longer nested under a `settings` wrapper. The `headers` and `oauth` shapes match the Claude Code / Cursor / Cline `.mcp.json` convention, so a file written by any of those tools is loadable on first connect. `metadata` / `connectionTimeout` / `requestTimeout` are Inspector-only and other tools simply ignore them.
|
||||||
|
|
||||||
## First-run behavior
|
## First-run behavior
|
||||||
|
|
||||||
|
|
@ -202,11 +204,13 @@ Per `AGENTS.md`'s "test new or modified code" rule plus the UI-changes guidance:
|
||||||
- **Schema drift with Claude Desktop / Cursor.** They occasionally add fields (e.g. Claude Desktop's `disabled`). `loadMcpServersConfig` currently does `JSON.parse(...) as MCPConfig` — extra fields survive the round-trip as long as we don't filter them. The converters in `serverList.ts` should preserve unknown fields on `MCPServerConfig` rather than copying a fixed allow-list.
|
- **Schema drift with Claude Desktop / Cursor.** They occasionally add fields (e.g. Claude Desktop's `disabled`). `loadMcpServersConfig` currently does `JSON.parse(...) as MCPConfig` — extra fields survive the round-trip as long as we don't filter them. The converters in `serverList.ts` should preserve unknown fields on `MCPServerConfig` rather than copying a fixed allow-list.
|
||||||
- **Migration from `SEED_SERVERS`.** Existing dev users have no file. First boot writes one — they won't notice. No code path persists the in-memory `useState` list today, so nothing to migrate.
|
- **Migration from `SEED_SERVERS`.** Existing dev users have no file. First boot writes one — they won't notice. No code path persists the in-memory `useState` list today, so nothing to migrate.
|
||||||
|
|
||||||
## Per-server settings (#1352)
|
## Per-server settings (#1352, flattened in #1358)
|
||||||
|
|
||||||
Our new UI design separates the basic server configuration (transport, URL or command + args + env) from settings (custom headers, connect/request timeout, global request metadata, client id/secret) into two dialogs. The latter is stored in an optional `settings` node of the server config. The reason they're separated in the UI is that custom settings are less likely to be needed than basic config, so a simpler, friendlier form greets most users.
|
Our UI design separates the basic server configuration (transport, URL or command + args + env) from settings (custom headers, connect/request timeout, global request metadata, client id/secret) into two dialogs. The reason they're separated in the UI is that custom settings are less likely to be needed than basic config, so a simpler, friendlier form greets most users.
|
||||||
|
|
||||||
Each server entry may carry an optional `settings` node alongside the transport config:
|
#1352 originally persisted these settings under a nested `settings` block on each entry. #1358 flattens them onto the entry as direct keys, so the on-disk shape matches the `.mcp.json` convention Claude Code / Cursor / Cline use (`headers` as a `Record<string, string>`, `oauth` as a nested object). The in-memory + wire shape is unchanged: `InspectorServerSettings` keeps pair-array `headers` and flat `oauthClientId` / `oauthClientSecret` / `oauthScopes` because the form needs them in that shape for controlled-component editing.
|
||||||
|
|
||||||
|
Each server entry may carry these Inspector-extension fields at the top level:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
|
|
@ -214,39 +218,42 @@ Each server entry may carry an optional `settings` node alongside the transport
|
||||||
"my-server": {
|
"my-server": {
|
||||||
"type": "streamable-http",
|
"type": "streamable-http",
|
||||||
"url": "https://example.com/mcp",
|
"url": "https://example.com/mcp",
|
||||||
"settings": {
|
"headers": { "Authorization": "Bearer xxx" },
|
||||||
"headers": [{ "key": "Authorization", "value": "Bearer xxx" }],
|
"metadata": [{ "key": "tenant", "value": "acme" }],
|
||||||
"metadata": [{ "key": "tenant", "value": "acme" }],
|
"connectionTimeout": 30000,
|
||||||
"connectionTimeout": 30000,
|
"requestTimeout": 60000,
|
||||||
"requestTimeout": 60000,
|
"oauth": {
|
||||||
"oauthClientId": "...",
|
"clientId": "...",
|
||||||
"oauthClientSecret": "...",
|
"clientSecret": "...",
|
||||||
"oauthScopes": "read:tools write:tools"
|
"scopes": "read:tools write:tools"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Shape**: `InspectorServerSettings` in `core/mcp/types.ts`. The `settings` field on `StoredMCPServer` (also in `types.ts`) is the on-disk extension; other MCP tools (Claude Desktop, Cursor, Cline) ignore unknown fields.
|
- **Shape**:
|
||||||
|
- On disk (`StoredMCPServer` in `core/mcp/types.ts`): `headers` as `Record<string, string>`, `metadata` as a pair-array (Inspector-only — no compat target), numeric timeouts, `oauth` as a nested `{ clientId?, clientSecret?, scopes? }` object. Every field optional; absent fields are omitted on disk so the file diff stays minimal.
|
||||||
|
- In memory + on the wire (`InspectorServerSettings` in `core/mcp/types.ts`): `headers` as a pair-array `{ key, value }[]`, flat `oauthClientId` / `oauthClientSecret` / `oauthScopes` fields, required `metadata` (pair-array) + required numeric `connectionTimeout` / `requestTimeout` (the form needs concrete values to render; 0 is the SDK's "no timeout" signal).
|
||||||
|
- The bidirectional conversion lives in `core/mcp/serverList.ts` (`storedFieldsToInspectorSettings` / `inspectorSettingsToStoredFields`) and is invoked by `mcpConfigToServerEntries` / `serverEntriesToMcpConfig` and by the server route's `buildStoredEntry`.
|
||||||
- **Where it takes effect**:
|
- **Where it takes effect**:
|
||||||
- `settings.headers` → wire headers on every SSE / streamable-http request (`core/mcp/node/transport.ts`).
|
- `headers` → wire headers on every SSE / streamable-http request (`core/mcp/node/transport.ts` consumes the pair-array via `InspectorServerSettings.headers`).
|
||||||
- `settings.metadata` → default `_meta` payload merged into every outgoing MCP request (`core/mcp/inspectorClient.ts`'s `mergeMeta` helper). Per-call metadata wins on key collision.
|
- `metadata` → default `_meta` payload merged into every outgoing MCP request (`core/mcp/inspectorClient.ts`'s `mergeMeta` helper). Per-call metadata wins on key collision.
|
||||||
- `settings.requestTimeout` → `InspectorClientOptions.timeout`.
|
- `requestTimeout` → `InspectorClientOptions.timeout`.
|
||||||
- `settings.connectionTimeout` → `Promise.race` wrapper around `InspectorClient.connect()` in the web client.
|
- `connectionTimeout` → `Promise.race` wrapper around `InspectorClient.connect()` in the web client.
|
||||||
- `settings.oauthClientId` / `oauthClientSecret` / `oauthScopes` → pre-seeded OAuth client credentials via `InspectorClientOptions.oauth`.
|
- `oauth.clientId` / `oauth.clientSecret` / `oauth.scopes` → pre-seeded OAuth client credentials via `InspectorClientOptions.oauth` (the disk-side `oauth` object is lifted into the flat `oauthClientId` / etc. fields on `InspectorServerSettings` for the form).
|
||||||
- **First-connect contract**: settings apply on the *first* outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`.
|
- **First-connect contract**: settings apply on the *first* outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`.
|
||||||
- **Secret storage**: `oauthClientSecret` is persisted in `mcp.json` alongside stdio `env` values, both protected by the file's `0o600` permission. OS-keychain integration is tracked separately in #1356 — when that lands, the secret will be lifted out of the file and replaced with a keychain reference.
|
- **Secret storage**: `oauth.clientSecret` is persisted in `mcp.json` alongside stdio `env` values, both protected by the file's `0o600` permission. OS-keychain integration is tracked separately in #1356 — when that lands, the secret will be lifted out of the file and replaced with a keychain reference.
|
||||||
- **Removed**: `MCPServerConfig.headers` (previously on `SseServerConfig` / `StreamableHttpServerConfig`) has been deleted. The headers textarea in `ServerConfigModal` is gone; HTTP headers are entered only in `ServerSettingsForm`. v2 has not shipped a stable release with the old shape, so no on-read migration is included.
|
- **Hard-cutover legacy behavior (per #1358 decision 4)**: files written by the one pre-#1358 build of v2/main have a nested `settings` block. `normalizeMcpServers` drops the node on read and logs a one-line warn including the server id; the persisted headers / metadata / timeouts / OAuth credentials are intentionally lost on first read. Users re-enter them via the settings form (or hand-edit the file into the flat shape). v2 has not shipped a stable release with the nested shape, so the blast radius is the small set of v2/main dogfooders who edited per-server settings between #1353 merging and this change.
|
||||||
- **UI**: `ServerSettingsModal` is opened from the server card's settings affordance. Saving routes through `useServers.updateServerSettings(id, settings)` which issues a settings-only `PUT /api/servers/:id` with `{ id, settings }` — the route preserves the on-disk transport config inside its write lock. Conversely, `useServers.updateServer` (driven by the basic-config modal) issues a config-only PUT with `{ id, config }` and the route preserves the on-disk settings node. Edits in either modal cannot silently wipe the other half.
|
- **UI**: `ServerSettingsModal` is opened from the server card's settings affordance. Saving routes through `useServers.updateServerSettings(id, settings)` which issues a settings-only `PUT /api/servers/:id` with `{ id, settings }` — the route preserves the on-disk transport config inside its write lock. Conversely, `useServers.updateServer` (driven by the basic-config modal) issues a config-only PUT with `{ id, config }` and the route preserves the on-disk settings fields. Edits in either modal cannot silently wipe the other half.
|
||||||
- **Save cadence**: the form fires `onSettingsChange` on every keystroke. `App.tsx` debounces 300 ms and flushes on modal close so a burst of edits coalesces into a single PUT. If the close-flush PUT fails (network hiccup, server 500), a red `@mantine/notifications` toast surfaces the failure — the modal has already closed so a silent failure would leave the user thinking the last edits saved.
|
- **Save cadence**: the form fires `onSettingsChange` on every keystroke. `App.tsx` debounces 300 ms and flushes on modal close so a burst of edits coalesces into a single PUT. If the close-flush PUT fails (network hiccup, server 500), a red `@mantine/notifications` toast surfaces the failure — the modal has already closed so a silent failure would leave the user thinking the last edits saved.
|
||||||
- **`PUT /api/servers/:id` patch semantics**: both `config` and `settings` are independent patches.
|
- **`PUT /api/servers/:id` patch semantics (kept-envelope wire shape per #1358 decision 5)**: both `config` and `settings` are independent patches on the wire, even though the on-disk shape has no `settings` wrapper. The envelope-on-the-wire keeps the preserve/clear/apply semantics #1353 introduced — the backend splats validated `settings.*` into top-level disk keys when assembling the next on-disk shape.
|
||||||
- Field omitted → preserve the on-disk value.
|
- Field omitted → preserve the on-disk value.
|
||||||
- Explicit `null` on `settings` → clear the settings node. (`config` may not be `null`; a body that wants to update only settings should omit `config` entirely.)
|
- Explicit `null` on `settings` → clear all Inspector-extension fields on disk (`headers` / `metadata` / `connectionTimeout` / `requestTimeout` / `oauth`). (`config` may not be `null`; a body that wants to update only settings should omit `config` entirely.)
|
||||||
- Field present and well-formed → validate and apply.
|
- Field present and well-formed → validate and apply.
|
||||||
- A bare `PUT { id: "renamed" }` is a pure rename preserving both halves.
|
- A bare `PUT { id: "renamed" }` is a pure rename preserving both halves.
|
||||||
- **Write-path gates**: `validateSettings` rejects malformed shapes (non-object, wrong-typed `headers` / `metadata`, non-numeric timeouts) with `400` + descriptive message and picks-and-builds the validated value so unknown stowaway keys silently drop. `buildStoredEntry` strips any `settings` key nested inside the incoming `config` (and logs a `warn` with the server id) so `validateSettings` remains the only path settings reach disk on the write side.
|
- **Write-path gates**: `validateSettings` rejects malformed shapes (non-object, wrong-typed `headers` / `metadata`, non-numeric timeouts) with `400` + descriptive message and picks-and-builds the validated value so unknown stowaway keys silently drop. `buildStoredEntry` strips any of the Inspector-extension keys (`settings`, `headers`, `metadata`, `connectionTimeout`, `requestTimeout`, `oauth`) smuggled inside the incoming `config` and logs a `warn` with the server id, so the wire envelope's `settings` field remains the only path those values reach disk.
|
||||||
- **Read-path gates**: `normalizeMcpServers` re-runs `validateSettings` on the on-disk settings node when loading; a malformed hand-edited `settings` node is silently dropped (lenient-on-read pattern matching `normalizeServerType`'s unknown→stdio fallback). A subsequent preserve-on-PUT cannot propagate the broken node, and `mcp.json` files written by future versions with unknown stowaway keys won't poison the in-memory shape.
|
- **Read-path gates**: `normalizeMcpServers` passes the entry's flat Inspector-extension fields through verbatim — the form's `storedFieldsToInspectorSettings` does the lift into the in-memory pair-array / flat-OAuth shape. A legacy nested `settings` block triggers the hard-cutover drop described above.
|
||||||
|
|
||||||
## Out of scope (follow-ups)
|
## Out of scope (follow-ups)
|
||||||
|
|
||||||
|
|
@ -254,4 +261,4 @@ Each server entry may carry an optional `settings` node alongside the transport
|
||||||
- File watching for hot reload of external edits.
|
- File watching for hot reload of external edits.
|
||||||
- Per-server tags / folders / groups.
|
- Per-server tags / folders / groups.
|
||||||
- Export current list as JSON.
|
- Export current list as JSON.
|
||||||
- CLI/TUI: switch their default `--config` to `getDefaultMcpConfigPath()` when no `--config` flag is given. Touch when those clients are wired up to v2. While porting, re-add a `--header` flag that writes to `settings.headers` rather than to `MCPServerConfig`.
|
- CLI/TUI: switch their default `--config` to `getDefaultMcpConfigPath()` when no `--config` flag is given. Touch when those clients are wired up to v2. While porting, re-add a `--header` flag that writes to the entry's top-level `headers` field on disk (post-#1358 flat shape) rather than to `MCPServerConfig`.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue