豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 35a44fe

Browse files
fix: improve deterministic JSON normalization for stable diffs
- Sort tools/prompts by 'name' field - Sort resources by 'uri' field - Sort resource templates by 'uriTemplate' field - Sort content items by 'type' field - Sort by 'method' field for request objects - Fallback to normalized JSON string comparison for unknown objects - Add comprehensive tests for deterministic ordering - Ensures identical servers produce identical JSON regardless of initial order
1 parent f9819a5 commit 35a44fe

File tree

5 files changed

+304
-18
lines changed

5 files changed

+304
-18
lines changed

dist/index.js

Lines changed: 46 additions & 5 deletions
Large diffs are not rendered by default.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/probe.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export declare function probeServer(options: ProbeOptions): Promise<ProbeResult>
2121
/**
2222
* Normalize a probe result for comparison by sorting keys and arrays recursively.
2323
* Also handles embedded JSON strings in "text" fields (from tool call responses).
24+
*
25+
* Sorting strategy:
26+
* - Object keys: sorted alphabetically
27+
* - Arrays of objects: sorted by primary key (name, uri, type) for deterministic output
28+
* - Primitive arrays: sorted by string representation
29+
* - Embedded JSON in "text" fields: parsed, normalized, and re-serialized
2430
*/
2531
export declare function normalizeProbeResult(result: unknown): unknown;
2632
/**

src/__tests__/runner.test.ts

Lines changed: 200 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,107 @@ describe("normalizeProbeResult", () => {
181181
expect(nestedKeys).toEqual(["apple", "zebra"]);
182182
});
183183

184-
it("sorts arrays by JSON string representation", () => {
185-
const input = [
186-
{ name: "zebra", value: 1 },
187-
{ name: "apple", value: 2 },
188-
];
189-
const result = normalizeProbeResult(input) as Array<{ name: string }>;
190-
expect(result[0].name).toBe("apple");
191-
expect(result[1].name).toBe("zebra");
184+
it("sorts arrays of objects by 'name' field (tools)", () => {
185+
const input = {
186+
tools: [
187+
{ name: "zebra_tool", description: "Z tool" },
188+
{ name: "apple_tool", description: "A tool" },
189+
{ name: "mango_tool", description: "M tool" },
190+
],
191+
};
192+
const result = normalizeProbeResult(input) as { tools: Array<{ name: string }> };
193+
expect(result.tools[0].name).toBe("apple_tool");
194+
expect(result.tools[1].name).toBe("mango_tool");
195+
expect(result.tools[2].name).toBe("zebra_tool");
196+
});
197+
198+
it("sorts arrays of objects by 'uri' field (resources)", () => {
199+
const input = {
200+
resources: [
201+
{ uri: "file:///z.txt", name: "Z" },
202+
{ uri: "file:///a.txt", name: "A" },
203+
{ uri: "file:///m.txt", name: "M" },
204+
],
205+
};
206+
const result = normalizeProbeResult(input) as { resources: Array<{ uri: string }> };
207+
expect(result.resources[0].uri).toBe("file:///a.txt");
208+
expect(result.resources[1].uri).toBe("file:///m.txt");
209+
expect(result.resources[2].uri).toBe("file:///z.txt");
210+
});
211+
212+
it("sorts arrays of objects by 'uriTemplate' field (resource templates)", () => {
213+
const input = {
214+
resourceTemplates: [
215+
{ uriTemplate: "file:///{z}", name: "Z Template" },
216+
{ uriTemplate: "file:///{a}", name: "A Template" },
217+
],
218+
};
219+
const result = normalizeProbeResult(input) as {
220+
resourceTemplates: Array<{ uriTemplate: string }>;
221+
};
222+
expect(result.resourceTemplates[0].uriTemplate).toBe("file:///{a}");
223+
expect(result.resourceTemplates[1].uriTemplate).toBe("file:///{z}");
224+
});
225+
226+
it("sorts arrays of objects by 'type' field (content items)", () => {
227+
const input = {
228+
content: [
229+
{ type: "text", text: "Hello" },
230+
{ type: "image", data: "base64..." },
231+
{ type: "audio", data: "base64..." },
232+
],
233+
};
234+
const result = normalizeProbeResult(input) as { content: Array<{ type: string }> };
235+
expect(result.content[0].type).toBe("audio");
236+
expect(result.content[1].type).toBe("image");
237+
expect(result.content[2].type).toBe("text");
238+
});
239+
240+
it("sorts prompt arguments by name", () => {
241+
const input = {
242+
prompts: [
243+
{
244+
name: "test-prompt",
245+
arguments: [
246+
{ name: "zebra_arg", required: true },
247+
{ name: "apple_arg", required: false },
248+
{ name: "mango_arg", required: true },
249+
],
250+
},
251+
],
252+
};
253+
const result = normalizeProbeResult(input) as {
254+
prompts: Array<{ arguments: Array<{ name: string }> }>;
255+
};
256+
const args = result.prompts[0].arguments;
257+
expect(args[0].name).toBe("apple_arg");
258+
expect(args[1].name).toBe("mango_arg");
259+
expect(args[2].name).toBe("zebra_arg");
260+
});
261+
262+
it("sorts tool inputSchema properties deterministically", () => {
263+
const input = {
264+
tools: [
265+
{
266+
name: "my_tool",
267+
inputSchema: {
268+
type: "object",
269+
properties: {
270+
zebra: { type: "string" },
271+
apple: { type: "number" },
272+
},
273+
required: ["zebra", "apple"],
274+
},
275+
},
276+
],
277+
};
278+
const result = normalizeProbeResult(input) as {
279+
tools: Array<{ inputSchema: { properties: Record<string, unknown>; required: string[] } }>;
280+
};
281+
const propKeys = Object.keys(result.tools[0].inputSchema.properties);
282+
expect(propKeys).toEqual(["apple", "zebra"]);
283+
// Required array should also be sorted
284+
expect(result.tools[0].inputSchema.required).toEqual(["apple", "zebra"]);
192285
});
193286

194287
it("handles embedded JSON in text fields", () => {
@@ -238,4 +331,103 @@ describe("normalizeProbeResult", () => {
238331

239332
expect(result1).toBe(result2);
240333
});
334+
335+
it("produces consistent JSON for complete MCP responses regardless of ordering", () => {
336+
// Simulate two identical tool responses with different initial ordering
337+
const response1 = {
338+
tools: [
339+
{
340+
name: "get_user",
341+
description: "Gets user info",
342+
inputSchema: {
343+
type: "object",
344+
required: ["id", "name"],
345+
properties: { name: { type: "string" }, id: { type: "number" } },
346+
},
347+
},
348+
{
349+
name: "add_numbers",
350+
description: "Adds two numbers",
351+
inputSchema: {
352+
type: "object",
353+
properties: { a: { type: "number" }, b: { type: "number" } },
354+
required: ["a", "b"],
355+
},
356+
},
357+
],
358+
};
359+
360+
const response2 = {
361+
tools: [
362+
{
363+
description: "Adds two numbers",
364+
name: "add_numbers",
365+
inputSchema: {
366+
required: ["a", "b"],
367+
type: "object",
368+
properties: { b: { type: "number" }, a: { type: "number" } },
369+
},
370+
},
371+
{
372+
inputSchema: {
373+
properties: { id: { type: "number" }, name: { type: "string" } },
374+
required: ["name", "id"],
375+
type: "object",
376+
},
377+
description: "Gets user info",
378+
name: "get_user",
379+
},
380+
],
381+
};
382+
383+
const normalized1 = JSON.stringify(normalizeProbeResult(response1), null, 2);
384+
const normalized2 = JSON.stringify(normalizeProbeResult(response2), null, 2);
385+
386+
expect(normalized1).toBe(normalized2);
387+
388+
// Verify the order is deterministic (add_numbers before get_user)
389+
const parsed = JSON.parse(normalized1) as { tools: Array<{ name: string }> };
390+
expect(parsed.tools[0].name).toBe("add_numbers");
391+
expect(parsed.tools[1].name).toBe("get_user");
392+
});
393+
394+
it("is idempotent - normalizing twice produces same result", () => {
395+
const input = {
396+
tools: [
397+
{ name: "z_tool", description: "Last" },
398+
{ name: "a_tool", description: "First" },
399+
],
400+
resources: [
401+
{ uri: "file:///z.txt" },
402+
{ uri: "file:///a.txt" },
403+
],
404+
};
405+
406+
const once = normalizeProbeResult(input);
407+
const twice = normalizeProbeResult(once);
408+
409+
expect(JSON.stringify(once)).toBe(JSON.stringify(twice));
410+
});
411+
412+
it("handles arrays without identifiable sort keys", () => {
413+
const input = {
414+
data: [3, 1, 4, 1, 5, 9, 2, 6],
415+
};
416+
const result = normalizeProbeResult(input) as { data: number[] };
417+
// Numbers sorted as strings
418+
expect(result.data).toEqual([1, 1, 2, 3, 4, 5, 6, 9]);
419+
});
420+
421+
it("handles mixed arrays with objects lacking standard keys", () => {
422+
const input = {
423+
items: [
424+
{ value: 3, label: "three" },
425+
{ value: 1, label: "one" },
426+
],
427+
};
428+
const result = normalizeProbeResult(input) as { items: Array<{ value: number }> };
429+
// Falls back to JSON string comparison
430+
expect(result.items[0].value).toBe(1);
431+
expect(result.items[1].value).toBe(3);
432+
});
241433
});

src/probe.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,22 +196,69 @@ export async function probeServer(options: ProbeOptions): Promise<ProbeResult> {
196196
return result;
197197
}
198198

199+
/**
200+
* Get a sort key for an array element based on common MCP entity patterns.
201+
* This ensures deterministic sorting for tools, prompts, resources, etc.
202+
*/
203+
function getSortKey(item: unknown): string {
204+
if (item === null || item === undefined) {
205+
return "";
206+
}
207+
208+
if (typeof item !== "object") {
209+
return String(item);
210+
}
211+
212+
const obj = item as Record<string, unknown>;
213+
214+
// Primary sort keys for MCP entities (in priority order)
215+
// Tools, prompts, arguments: use "name"
216+
// Resources, resource templates: use "uri" or "uriTemplate"
217+
// Content items: use "uri" or "type"
218+
if (typeof obj.name === "string") {
219+
return obj.name;
220+
}
221+
if (typeof obj.uri === "string") {
222+
return obj.uri;
223+
}
224+
if (typeof obj.uriTemplate === "string") {
225+
return obj.uriTemplate;
226+
}
227+
if (typeof obj.type === "string") {
228+
return obj.type;
229+
}
230+
if (typeof obj.method === "string") {
231+
return obj.method;
232+
}
233+
234+
// Fallback to JSON string - but normalize first to ensure deterministic output
235+
return JSON.stringify(normalizeProbeResult(item));
236+
}
237+
199238
/**
200239
* Normalize a probe result for comparison by sorting keys and arrays recursively.
201240
* Also handles embedded JSON strings in "text" fields (from tool call responses).
241+
*
242+
* Sorting strategy:
243+
* - Object keys: sorted alphabetically
244+
* - Arrays of objects: sorted by primary key (name, uri, type) for deterministic output
245+
* - Primitive arrays: sorted by string representation
246+
* - Embedded JSON in "text" fields: parsed, normalized, and re-serialized
202247
*/
203248
export function normalizeProbeResult(result: unknown): unknown {
204249
if (result === null || result === undefined) {
205250
return result;
206251
}
207252

208253
if (Array.isArray(result)) {
209-
// First normalize all elements, then sort by JSON string representation
254+
// First normalize all elements
210255
const normalized = result.map(normalizeProbeResult);
256+
257+
// Then sort by sort key for deterministic output
211258
return normalized.sort((a, b) => {
212-
const aStr = JSON.stringify(a);
213-
const bStr = JSON.stringify(b);
214-
return aStr.localeCompare(bStr);
259+
const aKey = getSortKey(a);
260+
const bKey = getSortKey(b);
261+
return aKey.localeCompare(bKey);
215262
});
216263
}
217264

0 commit comments

Comments
 (0)