Skip to content

Commit 02801e1

Browse files
cliffhallclaude
andcommitted
fix(servers): pass-5 review — absorb late connect rejection, tighten validateSettings, log smuggle warning
Pass-5 review at #1353 (comment). Main finding: when the connect-time `Promise.race` lost to the timeout, the original `this.client.connect(this.transport)` promise was left pending. After `disconnect()` tore the transport down, real transports (SSE / streamable-http) would reject that promise later — with no handler attached, producing a Node `unhandledRejection` (and a vitest suite warning). Attach a no-op `.catch` right after creating the connect promise so Node sees the late rejection as handled. Nit (a): `validateSettings` was casting the raw object through to `InspectorServerSettings`, which let unknown keys ride along onto disk. Replaced the cast with an explicit pick-and-build over the known fields. Unknown stowaways now silently drop on the way through. New route test pins the behavior. Nit (b): `buildStoredEntry` now logs a `warn` via the route's pino logger when an incoming `config` carries a `settings` key. The strip is still defensive (it isn't a documented contract) but the warning lets a misconfigured client find out about the wasted field instead of debugging a silent drop. Nit (c) — the defensive `?? {}` fallback in `buildStoredEntry` — acknowledged in the review reply; left in place as belt-and-braces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a4469d6 commit 02801e1

3 files changed

Lines changed: 69 additions & 4 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,37 @@ describe("/api/servers routes", () => {
605605
expect(res.status).toBe(400);
606606
});
607607

608+
it("validateSettings drops unknown keys (explicit pick-and-build, not spread)", async () => {
609+
const res = await fetch(`${h.baseUrl}/api/servers`, {
610+
method: "POST",
611+
headers: { "Content-Type": "application/json" },
612+
body: JSON.stringify({
613+
id: "unknown-keys",
614+
config: { type: "stdio", command: "node" },
615+
settings: {
616+
headers: [{ key: "X-A", value: "1" }],
617+
metadata: [],
618+
connectionTimeout: 0,
619+
requestTimeout: 0,
620+
// Unknown stowaway — must not survive the validator.
621+
stowaway: { keep: "me" },
622+
},
623+
}),
624+
});
625+
expect(res.status).toBe(200);
626+
const stored = readConfig(h.configPath).mcpServers["unknown-keys"] as {
627+
settings?: Record<string, unknown>;
628+
};
629+
expect(stored.settings).toBeDefined();
630+
expect(stored.settings).not.toHaveProperty("stowaway");
631+
expect(stored.settings).toEqual({
632+
headers: [{ key: "X-A", value: "1" }],
633+
metadata: [],
634+
connectionTimeout: 0,
635+
requestTimeout: 0,
636+
});
637+
});
638+
608639
it("strips smuggled config.settings on POST (validateSettings is the only write path)", async () => {
609640
// `normalizeServerType` spreads unknown keys verbatim. Without the
610641
// strip in buildStoredEntry, a body that nests `settings` inside

core/mcp/inspectorClient.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,13 @@ export class InspectorClient extends InspectorClientEventTarget {
603603
// connect() starts clean and the upstream socket isn't left hanging.
604604
const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0;
605605
const connectPromise = this.client.connect(this.transport);
606+
// Absorb any late rejection from `connectPromise` — when the timeout
607+
// wins the race and `disconnect()` tears the transport down, real
608+
// transports (SSE / streamable-http) reject the in-flight handshake
609+
// *after* Promise.race has already settled. Without a handler here
610+
// Node emits an unhandledRejection warning (and in test runs vitest
611+
// can surface it as a suite failure).
612+
connectPromise.catch(() => {});
606613
if (connectTimeoutMs > 0) {
607614
let timer: ReturnType<typeof setTimeout> | undefined;
608615
const timeoutPromise = new Promise<never>((_, reject) => {

core/mcp/remote/node/server.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -667,13 +667,21 @@ export function createRemoteApp(
667667
// through verbatim, so a caller that included `config.settings` on the
668668
// wire would smuggle a `settings` field straight onto the stored entry —
669669
// bypassing `validateSettings`. Strip it here so `validateSettings`
670-
// remains the single write path for the settings node.
670+
// remains the single write path for the settings node. Log a warning if
671+
// we observe this — it indicates a client bug (settings should travel
672+
// through the body's top-level `settings` field, not nested in config).
671673
const buildStoredEntry = (
672674
config: unknown,
673675
settings: InspectorServerSettings | undefined,
674676
): StoredMCPServer => {
675-
const { settings: _smuggled, ...configOnly } =
676-
(config as Record<string, unknown>) ?? {};
677+
const configObj = (config as Record<string, unknown>) ?? {};
678+
if ("settings" in configObj) {
679+
fileLogger?.warn(
680+
{ route: "/api/servers", smuggledKey: "settings" },
681+
"Stripping `settings` key from request body's `config` — settings must travel through the top-level `settings` field, not nested inside `config`.",
682+
);
683+
}
684+
const { settings: _smuggled, ...configOnly } = configObj;
677685
const normalized = normalizeServerType(
678686
configOnly as Record<string, unknown> & { type?: string },
679687
) as StoredMCPServer;
@@ -743,7 +751,26 @@ export function createRemoteApp(
743751
return { ok: false, error: `settings.${optional} must be a string` };
744752
}
745753
}
746-
return { ok: true, value: obj as unknown as InspectorServerSettings };
754+
// Build the validated value from explicitly named fields rather than
755+
// casting the raw object through. Unknown keys silently drop so a
756+
// misconfigured client can't smuggle stowaways onto disk, and consumers
757+
// can rely on the validated shape being exactly InspectorServerSettings.
758+
const value: InspectorServerSettings = {
759+
headers: obj.headers as { key: string; value: string }[],
760+
metadata: obj.metadata as { key: string; value: string }[],
761+
connectionTimeout: obj.connectionTimeout as number,
762+
requestTimeout: obj.requestTimeout as number,
763+
};
764+
if (typeof obj.oauthClientId === "string") {
765+
value.oauthClientId = obj.oauthClientId;
766+
}
767+
if (typeof obj.oauthClientSecret === "string") {
768+
value.oauthClientSecret = obj.oauthClientSecret;
769+
}
770+
if (typeof obj.oauthScopes === "string") {
771+
value.oauthScopes = obj.oauthScopes;
772+
}
773+
return { ok: true, value };
747774
};
748775

749776
// In-process serialization for the read-modify-write flow on the

0 commit comments

Comments
 (0)