mcp-server-diff 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.github/dependabot.yml +21 -0
  2. package/.github/workflows/ci.yml +51 -0
  3. package/.github/workflows/publish.yml +36 -0
  4. package/.github/workflows/release.yml +51 -0
  5. package/.prettierignore +3 -0
  6. package/.prettierrc +8 -0
  7. package/CONTRIBUTING.md +81 -0
  8. package/LICENSE +21 -0
  9. package/README.md +526 -0
  10. package/action.yml +250 -0
  11. package/dist/__tests__/fixtures/http-server.d.ts +7 -0
  12. package/dist/__tests__/fixtures/stdio-server.d.ts +7 -0
  13. package/dist/cli/__tests__/fixtures/http-server.d.ts +7 -0
  14. package/dist/cli/__tests__/fixtures/stdio-server.d.ts +7 -0
  15. package/dist/cli/cli.d.ts +7 -0
  16. package/dist/cli/diff.d.ts +44 -0
  17. package/dist/cli/git.d.ts +37 -0
  18. package/dist/cli/index.d.ts +7 -0
  19. package/dist/cli/index.js +57182 -0
  20. package/dist/cli/licenses.txt +466 -0
  21. package/dist/cli/logger.d.ts +46 -0
  22. package/dist/cli/package.json +3 -0
  23. package/dist/cli/probe.d.ts +35 -0
  24. package/dist/cli/reporter.d.ts +20 -0
  25. package/dist/cli/runner.d.ts +30 -0
  26. package/dist/cli/types.d.ts +134 -0
  27. package/dist/cli.d.ts +7 -0
  28. package/dist/diff.d.ts +44 -0
  29. package/dist/git.d.ts +37 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/index.js +58032 -0
  32. package/dist/licenses.txt +466 -0
  33. package/dist/logger.d.ts +46 -0
  34. package/dist/package.json +3 -0
  35. package/dist/probe.d.ts +35 -0
  36. package/dist/reporter.d.ts +20 -0
  37. package/dist/runner.d.ts +30 -0
  38. package/dist/types.d.ts +134 -0
  39. package/eslint.config.mjs +47 -0
  40. package/jest.config.mjs +26 -0
  41. package/package.json +64 -0
  42. package/src/__tests__/fixtures/http-server.ts +103 -0
  43. package/src/__tests__/fixtures/stdio-server.ts +158 -0
  44. package/src/__tests__/integration.test.ts +306 -0
  45. package/src/__tests__/runner.test.ts +430 -0
  46. package/src/cli.ts +421 -0
  47. package/src/diff.ts +252 -0
  48. package/src/git.ts +262 -0
  49. package/src/index.ts +284 -0
  50. package/src/logger.ts +93 -0
  51. package/src/probe.ts +327 -0
  52. package/src/reporter.ts +214 -0
  53. package/src/runner.ts +902 -0
  54. package/src/types.ts +155 -0
  55. package/tsconfig.json +30 -0
package/src/cli.ts ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * MCP Server Diff - CLI Entry Point
3
+ *
4
+ * Standalone CLI for diffing MCP server public interfaces.
5
+ * Can compare any two servers or multiple servers against a base.
6
+ */
7
+
8
+ import { parseArgs } from "node:util";
9
+ import * as fs from "fs";
10
+ import { probeServer } from "./probe.js";
11
+ import { compareProbeResults, extractCounts, type DiffResult } from "./diff.js";
12
+ import { ConsoleLogger, QuietLogger, setLogger, log } from "./logger.js";
13
+ import type { ProbeResult, PrimitiveCounts } from "./types.js";
14
+
15
+ interface ServerConfig {
16
+ name: string;
17
+ transport: "stdio" | "streamable-http";
18
+ start_command?: string;
19
+ server_url?: string;
20
+ headers?: Record<string, string>;
21
+ env_vars?: Record<string, string>;
22
+ }
23
+
24
+ interface DiffConfig {
25
+ base: ServerConfig;
26
+ targets: ServerConfig[];
27
+ }
28
+
29
+ interface ComparisonResult {
30
+ base: string;
31
+ target: string;
32
+ hasDifferences: boolean;
33
+ diffs: DiffResult[];
34
+ baseCounts: PrimitiveCounts;
35
+ targetCounts: PrimitiveCounts;
36
+ error?: string;
37
+ }
38
+
39
+ /**
40
+ * Parse command line arguments
41
+ */
42
+ function parseCliArgs() {
43
+ const { values, positionals } = parseArgs({
44
+ options: {
45
+ base: { type: "string", short: "b" },
46
+ target: { type: "string", short: "t" },
47
+ config: { type: "string", short: "c" },
48
+ output: { type: "string", short: "o", default: "summary" },
49
+ verbose: { type: "boolean", short: "v", default: false },
50
+ quiet: { type: "boolean", short: "q", default: false },
51
+ help: { type: "boolean", short: "h", default: false },
52
+ version: { type: "boolean", default: false },
53
+ },
54
+ allowPositionals: true,
55
+ strict: true,
56
+ });
57
+
58
+ return { values, positionals };
59
+ }
60
+
61
+ /**
62
+ * Print help message
63
+ */
64
+ function printHelp(): void {
65
+ console.log(`
66
+ mcp-server-diff - Diff MCP server public interfaces
67
+
68
+ USAGE:
69
+ mcp-server-diff [OPTIONS]
70
+ mcp-server-diff --base "python -m server" --target "node dist/stdio.js"
71
+ mcp-server-diff --config servers.json
72
+
73
+ OPTIONS:
74
+ -b, --base <command> Base server command (stdio) or URL (http)
75
+ -t, --target <command> Target server command (stdio) or URL (http)
76
+ -c, --config <file> Config file with base and targets
77
+ -o, --output <format> Output format: json, markdown, summary (default: summary)
78
+ -v, --verbose Verbose output
79
+ -q, --quiet Quiet mode (only output diffs)
80
+ -h, --help Show this help
81
+ --version Show version
82
+
83
+ CONFIG FILE FORMAT:
84
+ {
85
+ "base": {
86
+ "name": "python-server",
87
+ "transport": "stdio",
88
+ "start_command": "python -m mcp_server"
89
+ },
90
+ "targets": [
91
+ {
92
+ "name": "typescript-server",
93
+ "transport": "stdio",
94
+ "start_command": "node dist/stdio.js"
95
+ }
96
+ ]
97
+ }
98
+
99
+ OUTPUT FORMATS:
100
+ summary - One line per comparison (default)
101
+ json - Raw JSON with full diff details
102
+ markdown - Formatted markdown report
103
+
104
+ EXAMPLES:
105
+ # Compare two stdio servers
106
+ mcp-server-diff -b "python -m server" -t "node dist/index.js"
107
+
108
+ # Compare against HTTP server
109
+ mcp-server-diff -b "python -m server" -t "http://localhost:3000/mcp"
110
+
111
+ # Use config file for multiple comparisons
112
+ mcp-server-diff -c servers.json -o markdown
113
+
114
+ # Output raw JSON for CI
115
+ mcp-server-diff -c servers.json -o json -q
116
+ `);
117
+ }
118
+
119
+ /**
120
+ * Load and parse config file
121
+ */
122
+ function loadConfig(configPath: string): DiffConfig {
123
+ const content = fs.readFileSync(configPath, "utf-8");
124
+ const config = JSON.parse(content) as DiffConfig;
125
+
126
+ if (!config.base) {
127
+ throw new Error("Config must have a 'base' server");
128
+ }
129
+ if (!config.targets || config.targets.length === 0) {
130
+ throw new Error("Config must have at least one 'target' server");
131
+ }
132
+
133
+ return config;
134
+ }
135
+
136
+ /**
137
+ * Create a server config from a command string
138
+ */
139
+ function commandToConfig(command: string, name: string): ServerConfig {
140
+ if (command.startsWith("http://") || command.startsWith("https://")) {
141
+ return {
142
+ name,
143
+ transport: "streamable-http",
144
+ server_url: command,
145
+ };
146
+ }
147
+
148
+ return {
149
+ name,
150
+ transport: "stdio",
151
+ start_command: command,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Probe a server and return results
157
+ */
158
+ async function probeServerConfig(config: ServerConfig): Promise<ProbeResult> {
159
+ if (config.transport === "stdio") {
160
+ if (!config.start_command) {
161
+ throw new Error(`No start_command for stdio server: ${config.name}`);
162
+ }
163
+
164
+ const parts = config.start_command.split(/\s+/);
165
+ const command = parts[0];
166
+ const args = parts.slice(1);
167
+
168
+ return await probeServer({
169
+ transport: "stdio",
170
+ command,
171
+ args,
172
+ envVars: config.env_vars,
173
+ });
174
+ } else {
175
+ if (!config.server_url) {
176
+ throw new Error(`No server_url for HTTP server: ${config.name}`);
177
+ }
178
+
179
+ return await probeServer({
180
+ transport: "streamable-http",
181
+ url: config.server_url,
182
+ headers: config.headers,
183
+ envVars: config.env_vars,
184
+ });
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Compare base against all targets
190
+ */
191
+ async function runComparisons(config: DiffConfig): Promise<ComparisonResult[]> {
192
+ const results: ComparisonResult[] = [];
193
+
194
+ log.info(`\n📍 Probing base: ${config.base.name}`);
195
+ let baseResult: ProbeResult;
196
+ try {
197
+ baseResult = await probeServerConfig(config.base);
198
+ if (baseResult.error) {
199
+ throw new Error(baseResult.error);
200
+ }
201
+ } catch (error) {
202
+ log.error(`Failed to probe base server: ${error}`);
203
+ return config.targets.map((target) => ({
204
+ base: config.base.name,
205
+ target: target.name,
206
+ hasDifferences: true,
207
+ diffs: [{ endpoint: "error", diff: `Base server probe failed: ${error}` }],
208
+ baseCounts: { tools: 0, prompts: 0, resources: 0, resourceTemplates: 0 },
209
+ targetCounts: { tools: 0, prompts: 0, resources: 0, resourceTemplates: 0 },
210
+ error: String(error),
211
+ }));
212
+ }
213
+
214
+ const baseCounts = extractCounts(baseResult);
215
+
216
+ for (const target of config.targets) {
217
+ log.info(`\n🎯 Probing target: ${target.name}`);
218
+
219
+ const result: ComparisonResult = {
220
+ base: config.base.name,
221
+ target: target.name,
222
+ hasDifferences: false,
223
+ diffs: [],
224
+ baseCounts,
225
+ targetCounts: { tools: 0, prompts: 0, resources: 0, resourceTemplates: 0 },
226
+ };
227
+
228
+ try {
229
+ const targetResult = await probeServerConfig(target);
230
+
231
+ if (targetResult.error) {
232
+ result.hasDifferences = true;
233
+ result.diffs = [{ endpoint: "error", diff: `Target probe failed: ${targetResult.error}` }];
234
+ result.error = targetResult.error;
235
+ } else {
236
+ result.targetCounts = extractCounts(targetResult);
237
+ result.diffs = compareProbeResults(baseResult, targetResult);
238
+ result.hasDifferences = result.diffs.length > 0;
239
+ }
240
+ } catch (error) {
241
+ result.hasDifferences = true;
242
+ result.diffs = [{ endpoint: "error", diff: `Target probe failed: ${error}` }];
243
+ result.error = String(error);
244
+ }
245
+
246
+ results.push(result);
247
+ }
248
+
249
+ return results;
250
+ }
251
+
252
+ /**
253
+ * Output results in summary format
254
+ */
255
+ function outputSummary(results: ComparisonResult[]): void {
256
+ console.log("\n📊 Comparison Results:\n");
257
+
258
+ let hasAnyDiff = false;
259
+ for (const result of results) {
260
+ const status = result.hasDifferences ? "❌" : "✅";
261
+ const diffCount = result.diffs.length;
262
+ const counts = `(${result.targetCounts.tools}T/${result.targetCounts.prompts}P/${result.targetCounts.resources}R)`;
263
+
264
+ if (result.error) {
265
+ console.log(`${status} ${result.target} ${counts} - ERROR: ${result.error}`);
266
+ } else if (result.hasDifferences) {
267
+ console.log(`${status} ${result.target} ${counts} - ${diffCount} difference(s)`);
268
+ hasAnyDiff = true;
269
+ } else {
270
+ console.log(`${status} ${result.target} ${counts} - matches base`);
271
+ }
272
+ }
273
+
274
+ console.log("");
275
+ if (hasAnyDiff) {
276
+ console.log("Run with -o markdown or -o json for detailed diffs.");
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Output results in JSON format
282
+ */
283
+ function outputJson(results: ComparisonResult[]): void {
284
+ const output = {
285
+ timestamp: new Date().toISOString(),
286
+ results: results.map((r) => ({
287
+ base: r.base,
288
+ target: r.target,
289
+ hasDifferences: r.hasDifferences,
290
+ baseCounts: r.baseCounts,
291
+ targetCounts: r.targetCounts,
292
+ diffs: r.diffs,
293
+ error: r.error,
294
+ })),
295
+ summary: {
296
+ total: results.length,
297
+ matching: results.filter((r) => !r.hasDifferences).length,
298
+ different: results.filter((r) => r.hasDifferences).length,
299
+ },
300
+ };
301
+
302
+ console.log(JSON.stringify(output, null, 2));
303
+ }
304
+
305
+ /**
306
+ * Output results in markdown format
307
+ */
308
+ function outputMarkdown(results: ComparisonResult[]): void {
309
+ const lines: string[] = [];
310
+
311
+ lines.push("# MCP Server Diff Report");
312
+ lines.push("");
313
+ lines.push(`**Generated:** ${new Date().toISOString()}`);
314
+ lines.push("");
315
+
316
+ lines.push("## Summary");
317
+ lines.push("");
318
+ lines.push("| Server | Tools | Prompts | Resources | Status |");
319
+ lines.push("|--------|-------|---------|-----------|--------|");
320
+
321
+ for (const result of results) {
322
+ const status = result.hasDifferences
323
+ ? result.error
324
+ ? "❌ Error"
325
+ : `⚠️ ${result.diffs.length} diff(s)`
326
+ : "✅ Match";
327
+ const c = result.targetCounts;
328
+ lines.push(`| ${result.target} | ${c.tools} | ${c.prompts} | ${c.resources} | ${status} |`);
329
+ }
330
+
331
+ lines.push("");
332
+
333
+ const diffsPresent = results.filter((r) => r.hasDifferences && r.diffs.length > 0);
334
+ if (diffsPresent.length > 0) {
335
+ lines.push("## Differences");
336
+ lines.push("");
337
+
338
+ for (const result of diffsPresent) {
339
+ lines.push(`### ${result.target}`);
340
+ lines.push("");
341
+
342
+ for (const { endpoint, diff } of result.diffs) {
343
+ lines.push(`**${endpoint}**`);
344
+ lines.push("");
345
+ lines.push("```diff");
346
+ lines.push(diff);
347
+ lines.push("```");
348
+ lines.push("");
349
+ }
350
+ }
351
+ } else {
352
+ lines.push("## ✅ All Servers Match");
353
+ lines.push("");
354
+ lines.push("No differences detected between base and target servers.");
355
+ }
356
+
357
+ console.log(lines.join("\n"));
358
+ }
359
+
360
+ /**
361
+ * Main CLI entry point
362
+ */
363
+ async function main(): Promise<void> {
364
+ const { values } = parseCliArgs();
365
+
366
+ if (values.help) {
367
+ printHelp();
368
+ process.exit(0);
369
+ }
370
+
371
+ if (values.version) {
372
+ console.log("mcp-server-diff v2.1.0");
373
+ process.exit(0);
374
+ }
375
+
376
+ // Set up logger - CLI uses console logger by default
377
+ if (values.quiet) {
378
+ setLogger(new QuietLogger());
379
+ } else {
380
+ setLogger(new ConsoleLogger(values.verbose || false));
381
+ }
382
+
383
+ let config: DiffConfig;
384
+
385
+ if (values.config) {
386
+ config = loadConfig(values.config);
387
+ } else if (values.base && values.target) {
388
+ config = {
389
+ base: commandToConfig(values.base, "base"),
390
+ targets: [commandToConfig(values.target, "target")],
391
+ };
392
+ } else {
393
+ console.error("Error: Must provide --config or both --base and --target");
394
+ console.error("Run with --help for usage.");
395
+ process.exit(1);
396
+ }
397
+
398
+ const results = await runComparisons(config);
399
+
400
+ const outputFormat = values.output || "summary";
401
+ switch (outputFormat) {
402
+ case "json":
403
+ outputJson(results);
404
+ break;
405
+ case "markdown":
406
+ outputMarkdown(results);
407
+ break;
408
+ case "summary":
409
+ default:
410
+ outputSummary(results);
411
+ break;
412
+ }
413
+
414
+ const hasDiffs = results.some((r) => r.hasDifferences);
415
+ process.exit(hasDiffs ? 1 : 0);
416
+ }
417
+
418
+ main().catch((error) => {
419
+ console.error("Fatal error:", error);
420
+ process.exit(1);
421
+ });
package/src/diff.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Core diffing logic for MCP servers
3
+ *
4
+ * Pure functions for comparing probe results - no I/O side effects.
5
+ */
6
+
7
+ import type { ProbeResult, PrimitiveCounts } from "./types.js";
8
+ import { probeResultToFiles } from "./probe.js";
9
+
10
+ export interface DiffResult {
11
+ endpoint: string;
12
+ diff: string;
13
+ }
14
+
15
+ export interface ComparisonResult {
16
+ baseName: string;
17
+ targetName: string;
18
+ hasDifferences: boolean;
19
+ diffs: DiffResult[];
20
+ baseCounts: PrimitiveCounts;
21
+ targetCounts: PrimitiveCounts;
22
+ baseError?: string;
23
+ targetError?: string;
24
+ }
25
+
26
+ /**
27
+ * Extract primitive counts from a probe result
28
+ */
29
+ export function extractCounts(result: ProbeResult): PrimitiveCounts {
30
+ return {
31
+ tools: result.tools?.tools?.length || 0,
32
+ prompts: result.prompts?.prompts?.length || 0,
33
+ resources: result.resources?.resources?.length || 0,
34
+ resourceTemplates: result.resourceTemplates?.resourceTemplates?.length || 0,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Compare two probe results and return structured diff results
40
+ */
41
+ export function compareProbeResults(
42
+ baseResult: ProbeResult,
43
+ targetResult: ProbeResult
44
+ ): DiffResult[] {
45
+ const baseFiles = probeResultToFiles(baseResult);
46
+ const targetFiles = probeResultToFiles(targetResult);
47
+ const diffs: DiffResult[] = [];
48
+
49
+ const allEndpoints = new Set([...baseFiles.keys(), ...targetFiles.keys()]);
50
+
51
+ for (const endpoint of allEndpoints) {
52
+ const baseContent = baseFiles.get(endpoint);
53
+ const targetContent = targetFiles.get(endpoint);
54
+
55
+ if (!targetContent && baseContent) {
56
+ diffs.push({
57
+ endpoint,
58
+ diff: `Endpoint removed in target (was present in base)`,
59
+ });
60
+ } else if (targetContent && !baseContent) {
61
+ diffs.push({
62
+ endpoint,
63
+ diff: `Endpoint added in target (not present in base)`,
64
+ });
65
+ } else if (baseContent !== targetContent) {
66
+ const diff = generateJsonDiff(endpoint, baseContent || "", targetContent || "");
67
+ if (diff) {
68
+ diffs.push({ endpoint, diff });
69
+ }
70
+ }
71
+ }
72
+
73
+ return diffs;
74
+ }
75
+
76
+ /**
77
+ * Generate semantic JSON diff
78
+ */
79
+ export function generateJsonDiff(name: string, base: string, target: string): string | null {
80
+ try {
81
+ const baseObj = JSON.parse(base);
82
+ const targetObj = JSON.parse(target);
83
+
84
+ const differences = findJsonDifferences(baseObj, targetObj, "");
85
+
86
+ if (differences.length === 0) {
87
+ return null;
88
+ }
89
+
90
+ const diffLines = [`--- base/${name}.json`, `+++ target/${name}.json`, ""];
91
+ diffLines.push(...differences);
92
+
93
+ return diffLines.join("\n");
94
+ } catch {
95
+ // Fallback for non-JSON
96
+ if (base === target) return null;
97
+ return `--- base/${name}\n+++ target/${name}\n- ${base}\n+ ${target}`;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Recursively find differences between two JSON objects
103
+ */
104
+ export function findJsonDifferences(base: unknown, target: unknown, path: string): string[] {
105
+ const diffs: string[] = [];
106
+
107
+ if (base === null || base === undefined) {
108
+ if (target !== null && target !== undefined) {
109
+ diffs.push(`+ ${path || "root"}: ${formatValue(target)}`);
110
+ }
111
+ return diffs;
112
+ }
113
+
114
+ if (target === null || target === undefined) {
115
+ diffs.push(`- ${path || "root"}: ${formatValue(base)}`);
116
+ return diffs;
117
+ }
118
+
119
+ if (typeof base !== typeof target) {
120
+ diffs.push(`- ${path || "root"}: ${formatValue(base)}`);
121
+ diffs.push(`+ ${path || "root"}: ${formatValue(target)}`);
122
+ return diffs;
123
+ }
124
+
125
+ if (Array.isArray(base) && Array.isArray(target)) {
126
+ return compareArrays(base, target, path);
127
+ }
128
+
129
+ if (typeof base === "object" && typeof target === "object") {
130
+ const baseObj = base as Record<string, unknown>;
131
+ const targetObj = target as Record<string, unknown>;
132
+ const allKeys = new Set([...Object.keys(baseObj), ...Object.keys(targetObj)]);
133
+
134
+ for (const key of allKeys) {
135
+ const newPath = path ? `${path}.${key}` : key;
136
+
137
+ if (!(key in baseObj)) {
138
+ diffs.push(`+ ${newPath}: ${formatValue(targetObj[key])}`);
139
+ } else if (!(key in targetObj)) {
140
+ diffs.push(`- ${newPath}: ${formatValue(baseObj[key])}`);
141
+ } else {
142
+ diffs.push(...findJsonDifferences(baseObj[key], targetObj[key], newPath));
143
+ }
144
+ }
145
+ return diffs;
146
+ }
147
+
148
+ if (base !== target) {
149
+ diffs.push(`- ${path}: ${formatValue(base)}`);
150
+ diffs.push(`+ ${path}: ${formatValue(target)}`);
151
+ }
152
+
153
+ return diffs;
154
+ }
155
+
156
+ /**
157
+ * Compare arrays by finding items by their identity
158
+ */
159
+ function compareArrays(base: unknown[], target: unknown[], path: string): string[] {
160
+ const diffs: string[] = [];
161
+
162
+ const baseItems = new Map<string, { item: unknown; index: number }>();
163
+ const targetItems = new Map<string, { item: unknown; index: number }>();
164
+
165
+ base.forEach((item, index) => {
166
+ const key = getItemKey(item, index);
167
+ baseItems.set(key, { item, index });
168
+ });
169
+
170
+ target.forEach((item, index) => {
171
+ const key = getItemKey(item, index);
172
+ targetItems.set(key, { item, index });
173
+ });
174
+
175
+ // Find removed items
176
+ for (const [key, { item }] of baseItems) {
177
+ if (!targetItems.has(key)) {
178
+ diffs.push(`- ${path}[${key}]: ${formatValue(item)}`);
179
+ }
180
+ }
181
+
182
+ // Find added items
183
+ for (const [key, { item }] of targetItems) {
184
+ if (!baseItems.has(key)) {
185
+ diffs.push(`+ ${path}[${key}]: ${formatValue(item)}`);
186
+ }
187
+ }
188
+
189
+ // Find modified items
190
+ for (const [key, { item: baseItem }] of baseItems) {
191
+ const targetEntry = targetItems.get(key);
192
+ if (targetEntry) {
193
+ diffs.push(...findJsonDifferences(baseItem, targetEntry.item, `${path}[${key}]`));
194
+ }
195
+ }
196
+
197
+ return diffs;
198
+ }
199
+
200
+ /**
201
+ * Get a unique key for an array item
202
+ */
203
+ function getItemKey(item: unknown, index: number): string {
204
+ if (item === null || item === undefined || typeof item !== "object") {
205
+ return `#${index}`;
206
+ }
207
+
208
+ const obj = item as Record<string, unknown>;
209
+
210
+ if (typeof obj.name === "string") return obj.name;
211
+ if (typeof obj.uri === "string") return obj.uri;
212
+ if (typeof obj.uriTemplate === "string") return obj.uriTemplate;
213
+ if (typeof obj.method === "string") return obj.method;
214
+
215
+ return `#${index}`;
216
+ }
217
+
218
+ /**
219
+ * Format a value for display in diff output
220
+ */
221
+ export function formatValue(value: unknown): string {
222
+ if (value === null) return "null";
223
+ if (value === undefined) return "undefined";
224
+
225
+ if (typeof value === "string") {
226
+ if (value.length > 100) {
227
+ return JSON.stringify(value.slice(0, 100) + "...");
228
+ }
229
+ return JSON.stringify(value);
230
+ }
231
+
232
+ if (typeof value === "object") {
233
+ const json = JSON.stringify(value);
234
+ if (json.length > 200) {
235
+ return json.slice(0, 200) + "...";
236
+ }
237
+ return json;
238
+ }
239
+
240
+ return String(value);
241
+ }
242
+
243
+ /**
244
+ * Convert DiffResult array to Map for backward compatibility
245
+ */
246
+ export function diffsToMap(diffs: DiffResult[]): Map<string, string> {
247
+ const map = new Map<string, string>();
248
+ for (const { endpoint, diff } of diffs) {
249
+ map.set(endpoint, diff);
250
+ }
251
+ return map;
252
+ }