Skip to content

Commit bd4e506

Browse files
committed
fix(hostConfig): deep-clone nested JSON records in DTO conversion
clientCapabilities and hostContext can be nested (e.g. the SDK's default capabilities include `extensions.mimeTypes` arrays). The shallow spread in hostConfigDtoToInput and emptyHostConfigInputV2 left those inner trees aliased to the source DTO, so any nested edit through the returned mutable input would silently mutate the baseline used for resets and dirty comparisons. Replace shallow spreads with a recursive deepCloneJsonValue helper that walks objects and arrays. Add a regression test that mutates nested mimeTypes / hostContext.nested.deep.value through the input and asserts the source DTO is unchanged. https://claude.ai/code/session_01FjxdHVhJba5KpLjXiiuuCu
1 parent b6a0812 commit bd4e506

2 files changed

Lines changed: 89 additions & 5 deletions

File tree

mcpjam-inspector/client/src/lib/__tests__/host-config-v2.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,53 @@ describe("hostConfigDtoToInput", () => {
149149
expect(dto.clientCapabilities).toEqual({ c: 1 });
150150
expect(dto.hostContext).toEqual({ h: 2 });
151151
});
152+
153+
it("deep-clones nested clientCapabilities and hostContext", () => {
154+
const dto: HostConfigDtoV2 = {
155+
id: "host-2",
156+
schemaVersion: 2,
157+
hostStyle: "claude",
158+
modelId: "x",
159+
systemPrompt: "",
160+
temperature: 0.7,
161+
requireToolApproval: false,
162+
serverIds: [],
163+
optionalServerIds: [],
164+
connectionDefaults: { headers: {}, requestTimeout: 10000 },
165+
clientCapabilities: {
166+
extensions: { mimeTypes: ["a", "b"] },
167+
} as Record<string, unknown>,
168+
hostContext: {
169+
nested: { deep: { value: 1 } },
170+
} as Record<string, unknown>,
171+
};
172+
const input = hostConfigDtoToInput(dto);
173+
174+
// Mutate inside the nested trees and confirm the source DTO is
175+
// unaffected — proves the clone descends into nested structures.
176+
(
177+
(input.clientCapabilities.extensions as Record<string, unknown>)
178+
.mimeTypes as string[]
179+
).push("c");
180+
(
181+
(
182+
(input.hostContext.nested as Record<string, unknown>).deep as Record<
183+
string,
184+
unknown
185+
>
186+
) as { value: number }
187+
).value = 999;
188+
189+
expect(
190+
(dto.clientCapabilities.extensions as Record<string, unknown>).mimeTypes,
191+
).toEqual(["a", "b"]);
192+
expect(
193+
(
194+
(dto.hostContext.nested as Record<string, unknown>).deep as Record<
195+
string,
196+
unknown
197+
>
198+
),
199+
).toEqual({ value: 1 });
200+
});
152201
});

mcpjam-inspector/client/src/lib/host-config-v2.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,31 @@ export function emptyHostConfigInputV2(
123123
// ProjectClientConfig path also seeds from getDefaultClientCapabilities;
124124
// an empty {} here would silently drop MCP Apps support until the
125125
// user manually edited the capability JSON.
126+
//
127+
// Deep-clone — clientCapabilities and hostContext can be nested
128+
// (e.g. extensions.mimeTypes arrays). A shallow spread would alias
129+
// the inner trees with the partial/source, allowing later mutations
130+
// to leak through.
126131
clientCapabilities: partial.clientCapabilities
127-
? { ...partial.clientCapabilities }
128-
: (getDefaultClientCapabilities() as Record<string, unknown>),
129-
hostContext: partial.hostContext ? { ...partial.hostContext } : {},
132+
? deepCloneJsonRecord(partial.clientCapabilities)
133+
: deepCloneJsonRecord(
134+
getDefaultClientCapabilities() as Record<string, unknown>,
135+
),
136+
hostContext: partial.hostContext
137+
? deepCloneJsonRecord(partial.hostContext)
138+
: {},
130139
};
131140
}
132141

133142
export function hostConfigDtoToInput(
134143
dto: HostConfigDtoV2,
135144
): HostConfigInputV2 {
145+
// Deep-clone the JSON record fields. clientCapabilities and
146+
// hostContext can be nested (e.g. the SDK's default capabilities
147+
// include an `extensions` object with arrays). A shallow spread
148+
// would leave the inner trees aliased to the source DTO; any nested
149+
// edit through the returned input would silently mutate the
150+
// baseline used for resets and dirty comparisons.
136151
return {
137152
hostStyle: dto.hostStyle,
138153
modelId: dto.modelId,
@@ -145,11 +160,31 @@ export function hostConfigDtoToInput(
145160
headers: { ...dto.connectionDefaults.headers },
146161
requestTimeout: dto.connectionDefaults.requestTimeout,
147162
},
148-
clientCapabilities: { ...dto.clientCapabilities },
149-
hostContext: { ...dto.hostContext },
163+
clientCapabilities: deepCloneJsonRecord(dto.clientCapabilities),
164+
hostContext: deepCloneJsonRecord(dto.hostContext),
150165
};
151166
}
152167

168+
function deepCloneJsonRecord(
169+
value: Record<string, unknown>,
170+
): Record<string, unknown> {
171+
return deepCloneJsonValue(value) as Record<string, unknown>;
172+
}
173+
174+
function deepCloneJsonValue(value: unknown): unknown {
175+
if (Array.isArray(value)) {
176+
return value.map(deepCloneJsonValue);
177+
}
178+
if (value && typeof value === "object") {
179+
const out: Record<string, unknown> = {};
180+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
181+
out[k] = deepCloneJsonValue(v);
182+
}
183+
return out;
184+
}
185+
return value;
186+
}
187+
153188
/**
154189
* Equality on the canonical fields (ignoring `id` and any extra
155190
* metadata). Used by editors to detect "no changes" before submitting.

0 commit comments

Comments
 (0)