mcp-server-diff 2.1.0 → 2.1.6

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.
package/src/probe.ts DELETED
@@ -1,327 +0,0 @@
1
- /**
2
- * MCP Server Probe
3
- *
4
- * Probes an MCP server and collects capability snapshots.
5
- */
6
-
7
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
10
- import { z } from "zod";
11
- import type {
12
- ProbeResult,
13
- InitializeInfo,
14
- ToolsResult,
15
- PromptsResult,
16
- ResourcesResult,
17
- ResourceTemplatesResult,
18
- CustomMessage,
19
- } from "./types.js";
20
- import { log } from "./logger.js";
21
-
22
- export interface ProbeOptions {
23
- transport: "stdio" | "streamable-http";
24
- command?: string;
25
- args?: string[];
26
- url?: string;
27
- headers?: Record<string, string>;
28
- workingDir?: string;
29
- envVars?: Record<string, string>;
30
- customMessages?: CustomMessage[];
31
- }
32
-
33
- /**
34
- * Probes an MCP server and returns capability snapshots
35
- */
36
- export async function probeServer(options: ProbeOptions): Promise<ProbeResult> {
37
- const result: ProbeResult = {
38
- initialize: null,
39
- tools: null,
40
- prompts: null,
41
- resources: null,
42
- resourceTemplates: null,
43
- customResponses: new Map(),
44
- };
45
-
46
- const client = new Client(
47
- {
48
- name: "mcp-server-diff-probe",
49
- version: "2.0.0",
50
- },
51
- {
52
- capabilities: {},
53
- }
54
- );
55
-
56
- let transport: StdioClientTransport | StreamableHTTPClientTransport;
57
-
58
- try {
59
- if (options.transport === "stdio") {
60
- if (!options.command) {
61
- throw new Error("Command is required for stdio transport");
62
- }
63
-
64
- log.info(` Connecting via stdio: ${options.command} ${(options.args || []).join(" ")}`);
65
-
66
- // Merge environment variables
67
- const env: Record<string, string> = {};
68
- for (const [key, value] of Object.entries(process.env)) {
69
- if (value !== undefined) {
70
- env[key] = value;
71
- }
72
- }
73
- for (const [key, value] of Object.entries(options.envVars || {})) {
74
- env[key] = value;
75
- }
76
-
77
- transport = new StdioClientTransport({
78
- command: options.command,
79
- args: options.args || [],
80
- env,
81
- cwd: options.workingDir,
82
- });
83
- } else {
84
- if (!options.url) {
85
- throw new Error("URL is required for streamable-http transport");
86
- }
87
-
88
- log.info(` Connecting via streamable-http: ${options.url}`);
89
- const transportOptions: { requestInit?: RequestInit } = {};
90
- if (options.headers && Object.keys(options.headers).length > 0) {
91
- transportOptions.requestInit = { headers: options.headers };
92
- log.info(` With headers: ${Object.keys(options.headers).join(", ")}`);
93
- }
94
- transport = new StreamableHTTPClientTransport(new URL(options.url), transportOptions);
95
- }
96
-
97
- // Connect to the server
98
- await client.connect(transport);
99
- log.info(" Connected successfully");
100
-
101
- // Get server info and capabilities
102
- const serverCapabilities = client.getServerCapabilities();
103
- const serverInfo = client.getServerVersion();
104
-
105
- result.initialize = {
106
- serverInfo,
107
- capabilities: serverCapabilities,
108
- } as InitializeInfo;
109
-
110
- // Probe tools if supported
111
- if (serverCapabilities?.tools) {
112
- try {
113
- const toolsResult = await client.listTools();
114
- result.tools = toolsResult as ToolsResult;
115
- log.info(` Listed ${result.tools.tools.length} tools`);
116
- } catch (error) {
117
- log.warning(` Failed to list tools: ${error}`);
118
- }
119
- } else {
120
- log.info(" Server does not support tools");
121
- }
122
-
123
- // Probe prompts if supported
124
- if (serverCapabilities?.prompts) {
125
- try {
126
- const promptsResult = await client.listPrompts();
127
- result.prompts = promptsResult as PromptsResult;
128
- log.info(` Listed ${result.prompts.prompts.length} prompts`);
129
- } catch (error) {
130
- log.warning(` Failed to list prompts: ${error}`);
131
- }
132
- } else {
133
- log.info(" Server does not support prompts");
134
- }
135
-
136
- // Probe resources if supported
137
- if (serverCapabilities?.resources) {
138
- try {
139
- const resourcesResult = await client.listResources();
140
- result.resources = resourcesResult as ResourcesResult;
141
- log.info(` Listed ${result.resources.resources.length} resources`);
142
- } catch (error) {
143
- log.warning(` Failed to list resources: ${error}`);
144
- }
145
-
146
- // Also get resource templates
147
- try {
148
- const templatesResult = await client.listResourceTemplates();
149
- result.resourceTemplates = templatesResult as ResourceTemplatesResult;
150
- log.info(
151
- ` Listed ${result.resourceTemplates.resourceTemplates.length} resource templates`
152
- );
153
- } catch (error) {
154
- log.warning(` Failed to list resource templates: ${error}`);
155
- }
156
- } else {
157
- log.info(" Server does not support resources");
158
- }
159
-
160
- // Send custom messages if provided
161
- if (options.customMessages && options.customMessages.length > 0) {
162
- // Schema that accepts any response for custom messages
163
- const anyResponseSchema = z.record(z.unknown());
164
-
165
- for (const customMsg of options.customMessages) {
166
- try {
167
- // Cast message to the expected request type - custom messages should have a method field
168
- const response = await client.request(
169
- customMsg.message as { method: string; params?: Record<string, unknown> },
170
- anyResponseSchema
171
- );
172
- result.customResponses.set(customMsg.name, response);
173
- log.info(` Custom message '${customMsg.name}' successful`);
174
- } catch (error) {
175
- log.warning(` Custom message '${customMsg.name}' failed: ${error}`);
176
- }
177
- }
178
- }
179
-
180
- log.info(" Probe complete");
181
-
182
- // Close the connection
183
- await client.close();
184
- } catch (error) {
185
- result.error = String(error);
186
- log.error(` Error probing server: ${error}`);
187
-
188
- // Try to close client on error
189
- try {
190
- await client.close();
191
- } catch {
192
- // Ignore close errors
193
- }
194
- }
195
-
196
- return result;
197
- }
198
-
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
-
238
- /**
239
- * Normalize a probe result for comparison by sorting keys and arrays recursively.
240
- * 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
247
- */
248
- export function normalizeProbeResult(result: unknown): unknown {
249
- if (result === null || result === undefined) {
250
- return result;
251
- }
252
-
253
- if (Array.isArray(result)) {
254
- // First normalize all elements
255
- const normalized = result.map(normalizeProbeResult);
256
-
257
- // Then sort by sort key for deterministic output
258
- return normalized.sort((a, b) => {
259
- const aKey = getSortKey(a);
260
- const bKey = getSortKey(b);
261
- return aKey.localeCompare(bKey);
262
- });
263
- }
264
-
265
- if (typeof result === "object") {
266
- const obj = result as Record<string, unknown>;
267
- const normalized: Record<string, unknown> = {};
268
-
269
- // Sort keys alphabetically
270
- const keys = Object.keys(obj).sort();
271
-
272
- for (const key of keys) {
273
- let value = obj[key];
274
-
275
- // Handle embedded JSON in "text" fields (tool call responses)
276
- if (key === "text" && typeof value === "string") {
277
- const trimmed = value.trim();
278
- if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
279
- try {
280
- const parsed = JSON.parse(value);
281
- // Re-serialize the normalized JSON to keep it as a string
282
- value = JSON.stringify(normalizeProbeResult(parsed));
283
- } catch {
284
- // Not valid JSON, keep as-is
285
- }
286
- }
287
- }
288
-
289
- normalized[key] = normalizeProbeResult(value);
290
- }
291
- return normalized;
292
- }
293
-
294
- return result;
295
- }
296
-
297
- /**
298
- * Convert probe result to a map of endpoint -> JSON string
299
- */
300
- export function probeResultToFiles(result: ProbeResult): Map<string, string> {
301
- const files = new Map<string, string>();
302
-
303
- if (result.initialize) {
304
- files.set("initialize", JSON.stringify(normalizeProbeResult(result.initialize), null, 2));
305
- }
306
- if (result.tools) {
307
- files.set("tools", JSON.stringify(normalizeProbeResult(result.tools), null, 2));
308
- }
309
- if (result.prompts) {
310
- files.set("prompts", JSON.stringify(normalizeProbeResult(result.prompts), null, 2));
311
- }
312
- if (result.resources) {
313
- files.set("resources", JSON.stringify(normalizeProbeResult(result.resources), null, 2));
314
- }
315
- if (result.resourceTemplates) {
316
- files.set(
317
- "resource_templates",
318
- JSON.stringify(normalizeProbeResult(result.resourceTemplates), null, 2)
319
- );
320
- }
321
-
322
- for (const [name, response] of result.customResponses.entries()) {
323
- files.set(`custom_${name}`, JSON.stringify(normalizeProbeResult(response), null, 2));
324
- }
325
-
326
- return files;
327
- }
package/src/reporter.ts DELETED
@@ -1,214 +0,0 @@
1
- /**
2
- * Report generator for MCP server diff
3
- */
4
-
5
- import * as core from "@actions/core";
6
- import * as fs from "fs";
7
- import * as path from "path";
8
- import type { TestResult, ConformanceReport } from "./types.js";
9
-
10
- /**
11
- * Generate a diff report from test results
12
- */
13
- export function generateReport(
14
- results: TestResult[],
15
- currentBranch: string,
16
- compareRef: string
17
- ): ConformanceReport {
18
- const totalBranchTime = results.reduce((sum, r) => sum + r.branchTime, 0);
19
- const totalBaseTime = results.reduce((sum, r) => sum + r.baseTime, 0);
20
- const passedCount = results.filter((r) => !r.hasDifferences).length;
21
- const diffCount = results.filter((r) => r.hasDifferences).length;
22
-
23
- return {
24
- generatedAt: new Date().toISOString(),
25
- currentBranch,
26
- compareRef,
27
- results,
28
- totalBranchTime,
29
- totalBaseTime,
30
- passedCount,
31
- diffCount,
32
- };
33
- }
34
-
35
- /**
36
- * Generate markdown report
37
- */
38
- export function generateMarkdownReport(report: ConformanceReport): string {
39
- const lines: string[] = [];
40
-
41
- lines.push("# MCP Conformance Test Report");
42
- lines.push("");
43
- lines.push(`**Generated:** ${report.generatedAt}`);
44
- lines.push(`**Current Branch:** ${report.currentBranch}`);
45
- lines.push(`**Compared Against:** ${report.compareRef}`);
46
- lines.push("");
47
-
48
- // Summary
49
- lines.push("## Summary");
50
- lines.push("");
51
- lines.push(`| Metric | Value |`);
52
- lines.push(`|--------|-------|`);
53
- lines.push(`| Total Configurations | ${report.results.length} |`);
54
- lines.push(`| Passed | ${report.passedCount} |`);
55
- lines.push(`| With Differences | ${report.diffCount} |`);
56
- lines.push(`| Branch Total Time | ${formatTime(report.totalBranchTime)} |`);
57
- lines.push(`| Base Total Time | ${formatTime(report.totalBaseTime)} |`);
58
- lines.push("");
59
-
60
- // Overall status
61
- if (report.diffCount === 0) {
62
- lines.push("## ✅ No API Changes");
63
- lines.push("");
64
- lines.push("No differences detected between the current branch and the comparison ref.");
65
- } else {
66
- lines.push("## 📋 API Changes Detected");
67
- lines.push("");
68
- lines.push(
69
- `${report.diffCount} configuration(s) have changes. Review below to ensure they are intentional.`
70
- );
71
- }
72
- lines.push("");
73
-
74
- // Per-configuration results
75
- lines.push("## Configuration Results");
76
- lines.push("");
77
-
78
- for (const result of report.results) {
79
- const statusIcon = result.error ? "❌" : result.hasDifferences ? "⚠️" : "✅";
80
- lines.push(`### ${statusIcon} ${result.configName}`);
81
- lines.push("");
82
- lines.push(`- **Transport:** ${result.transport}`);
83
-
84
- // Show primitive counts if available
85
- if (result.branchCounts) {
86
- const counts = result.branchCounts;
87
- const countParts: string[] = [];
88
- if (counts.tools > 0) countParts.push(`${counts.tools} tools`);
89
- if (counts.prompts > 0) countParts.push(`${counts.prompts} prompts`);
90
- if (counts.resources > 0) countParts.push(`${counts.resources} resources`);
91
- if (counts.resourceTemplates > 0)
92
- countParts.push(`${counts.resourceTemplates} resource templates`);
93
- if (countParts.length > 0) {
94
- lines.push(`- **Primitives:** ${countParts.join(", ")}`);
95
- }
96
- }
97
-
98
- lines.push(`- **Branch Time:** ${formatTime(result.branchTime)}`);
99
- lines.push(`- **Base Time:** ${formatTime(result.baseTime)}`);
100
- lines.push("");
101
-
102
- if (result.hasDifferences) {
103
- lines.push("#### Changes");
104
- lines.push("");
105
-
106
- for (const [endpoint, diff] of result.diffs) {
107
- lines.push(`**${endpoint}**`);
108
- lines.push("");
109
- lines.push("```diff");
110
- lines.push(diff);
111
- lines.push("```");
112
- lines.push("");
113
- }
114
- } else {
115
- lines.push("No differences detected.");
116
- lines.push("");
117
- }
118
- }
119
-
120
- return lines.join("\n");
121
- }
122
-
123
- /**
124
- * Format milliseconds to human readable time
125
- */
126
- function formatTime(ms: number): string {
127
- if (ms < 1000) {
128
- return `${ms}ms`;
129
- }
130
- const seconds = (ms / 1000).toFixed(2);
131
- return `${seconds}s`;
132
- }
133
-
134
- /**
135
- * Save report to file and set outputs
136
- */
137
- export function saveReport(report: ConformanceReport, markdown: string, outputDir: string): void {
138
- // Ensure output directory exists
139
- const reportDir = path.join(outputDir, "mcp-diff-report");
140
- fs.mkdirSync(reportDir, { recursive: true });
141
-
142
- // Save JSON report
143
- const jsonPath = path.join(reportDir, "mcp-diff-report.json");
144
- fs.writeFileSync(
145
- jsonPath,
146
- JSON.stringify(
147
- {
148
- ...report,
149
- results: report.results.map((r) => ({
150
- ...r,
151
- diffs: Object.fromEntries(r.diffs),
152
- })),
153
- },
154
- null,
155
- 2
156
- )
157
- );
158
- core.info(`📄 JSON report saved to: ${jsonPath}`);
159
-
160
- // Save markdown report
161
- const mdPath = path.join(reportDir, "MCP_DIFF_REPORT.md");
162
- fs.writeFileSync(mdPath, markdown);
163
- core.info(`📄 Markdown report saved to: ${mdPath}`);
164
-
165
- // Set outputs using GITHUB_OUTPUT file (for composite actions)
166
- const githubOutput = process.env.GITHUB_OUTPUT;
167
- if (githubOutput) {
168
- const status = report.diffCount > 0 ? "differences" : "passed";
169
- fs.appendFileSync(githubOutput, `status=${status}\n`);
170
- fs.appendFileSync(githubOutput, `report_path=${mdPath}\n`);
171
- fs.appendFileSync(githubOutput, `json_report_path=${jsonPath}\n`);
172
- fs.appendFileSync(githubOutput, `has_differences=${report.diffCount > 0}\n`);
173
- fs.appendFileSync(githubOutput, `passed_count=${report.passedCount}\n`);
174
- fs.appendFileSync(githubOutput, `diff_count=${report.diffCount}\n`);
175
- }
176
-
177
- // Also set via core for compatibility
178
- core.setOutput("report_path", mdPath);
179
- core.setOutput("json_report_path", jsonPath);
180
- core.setOutput("has_differences", report.diffCount > 0);
181
- core.setOutput("passed_count", report.passedCount);
182
- core.setOutput("diff_count", report.diffCount);
183
- }
184
-
185
- /**
186
- * Write a simple summary for PR comments
187
- */
188
- export function generatePRSummary(report: ConformanceReport): string {
189
- const lines: string[] = [];
190
-
191
- if (report.diffCount === 0) {
192
- lines.push("## ✅ MCP Conformance: No Changes");
193
- lines.push("");
194
- lines.push(`Tested ${report.results.length} configuration(s) - no API changes detected.`);
195
- } else {
196
- lines.push("## 📋 MCP Conformance: API Changes Detected");
197
- lines.push("");
198
- lines.push(
199
- `**${report.diffCount}** of ${report.results.length} configuration(s) have changes.`
200
- );
201
- lines.push("");
202
- lines.push("### Changed Endpoints");
203
- lines.push("");
204
-
205
- for (const result of report.results.filter((r) => r.hasDifferences)) {
206
- lines.push(`- **${result.configName}:** ${Array.from(result.diffs.keys()).join(", ")}`);
207
- }
208
-
209
- lines.push("");
210
- lines.push("See the full report in the job summary for details.");
211
- }
212
-
213
- return lines.join("\n");
214
- }