mcp-server-diff 2.1.0 → 2.1.5
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/README.md +108 -1
- package/dist/cli/index.js +205 -9
- package/dist/index.js +32 -5
- package/package.json +6 -1
- package/.github/dependabot.yml +0 -21
- package/.github/workflows/ci.yml +0 -51
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/release.yml +0 -51
- package/.prettierignore +0 -3
- package/.prettierrc +0 -8
- package/CONTRIBUTING.md +0 -81
- package/action.yml +0 -250
- package/eslint.config.mjs +0 -47
- package/jest.config.mjs +0 -26
- package/src/__tests__/fixtures/http-server.ts +0 -103
- package/src/__tests__/fixtures/stdio-server.ts +0 -158
- package/src/__tests__/integration.test.ts +0 -306
- package/src/__tests__/runner.test.ts +0 -430
- package/src/cli.ts +0 -421
- package/src/diff.ts +0 -252
- package/src/git.ts +0 -262
- package/src/index.ts +0 -284
- package/src/logger.ts +0 -93
- package/src/probe.ts +0 -327
- package/src/reporter.ts +0 -214
- package/src/runner.ts +0 -902
- package/src/types.ts +0 -155
- package/tsconfig.json +0 -30
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
|
-
/**
|
|
3
|
-
* Minimal MCP Server for integration testing (stdio transport)
|
|
4
|
-
*
|
|
5
|
-
* This server exposes tools, prompts, and resources for testing the probe functionality.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
-
import {
|
|
11
|
-
CallToolRequestSchema,
|
|
12
|
-
GetPromptRequestSchema,
|
|
13
|
-
ListPromptsRequestSchema,
|
|
14
|
-
ListResourcesRequestSchema,
|
|
15
|
-
ListToolsRequestSchema,
|
|
16
|
-
ReadResourceRequestSchema,
|
|
17
|
-
} from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
-
|
|
19
|
-
const server = new Server(
|
|
20
|
-
{
|
|
21
|
-
name: "test-stdio-server",
|
|
22
|
-
version: "1.0.0",
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
capabilities: {
|
|
26
|
-
tools: {},
|
|
27
|
-
prompts: {},
|
|
28
|
-
resources: {},
|
|
29
|
-
},
|
|
30
|
-
}
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
// Define tools
|
|
34
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
35
|
-
return {
|
|
36
|
-
tools: [
|
|
37
|
-
{
|
|
38
|
-
name: "greet",
|
|
39
|
-
description: "Greets a person by name",
|
|
40
|
-
inputSchema: {
|
|
41
|
-
type: "object" as const,
|
|
42
|
-
properties: {
|
|
43
|
-
name: { type: "string", description: "Name to greet" },
|
|
44
|
-
},
|
|
45
|
-
required: ["name"],
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
name: "add",
|
|
50
|
-
description: "Adds two numbers",
|
|
51
|
-
inputSchema: {
|
|
52
|
-
type: "object" as const,
|
|
53
|
-
properties: {
|
|
54
|
-
a: { type: "number", description: "First number" },
|
|
55
|
-
b: { type: "number", description: "Second number" },
|
|
56
|
-
},
|
|
57
|
-
required: ["a", "b"],
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
};
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
65
|
-
const { name, arguments: args } = request.params;
|
|
66
|
-
|
|
67
|
-
if (name === "greet") {
|
|
68
|
-
return {
|
|
69
|
-
content: [{ type: "text", text: `Hello, ${(args as { name: string }).name}!` }],
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (name === "add") {
|
|
74
|
-
const { a, b } = args as { a: number; b: number };
|
|
75
|
-
// Return as embedded JSON to test normalization
|
|
76
|
-
return {
|
|
77
|
-
content: [
|
|
78
|
-
{
|
|
79
|
-
type: "text",
|
|
80
|
-
text: JSON.stringify({ result: a + b, operation: "add", inputs: { b, a } }),
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
throw new Error(`Unknown tool: ${name}`);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// Define prompts
|
|
90
|
-
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
91
|
-
return {
|
|
92
|
-
prompts: [
|
|
93
|
-
{
|
|
94
|
-
name: "code-review",
|
|
95
|
-
description: "Review code for issues",
|
|
96
|
-
arguments: [
|
|
97
|
-
{ name: "code", description: "The code to review", required: true },
|
|
98
|
-
{ name: "language", description: "Programming language", required: false },
|
|
99
|
-
],
|
|
100
|
-
},
|
|
101
|
-
],
|
|
102
|
-
};
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
106
|
-
if (request.params.name === "code-review") {
|
|
107
|
-
const args = request.params.arguments || {};
|
|
108
|
-
return {
|
|
109
|
-
messages: [
|
|
110
|
-
{
|
|
111
|
-
role: "user" as const,
|
|
112
|
-
content: {
|
|
113
|
-
type: "text" as const,
|
|
114
|
-
text: `Please review this ${args.language || "code"}:\n\n${args.code}`,
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
],
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// Define resources
|
|
124
|
-
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
125
|
-
return {
|
|
126
|
-
resources: [
|
|
127
|
-
{
|
|
128
|
-
uri: "test://readme",
|
|
129
|
-
name: "README",
|
|
130
|
-
description: "Project readme file",
|
|
131
|
-
mimeType: "text/plain",
|
|
132
|
-
},
|
|
133
|
-
],
|
|
134
|
-
};
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
138
|
-
if (request.params.uri === "test://readme") {
|
|
139
|
-
return {
|
|
140
|
-
contents: [
|
|
141
|
-
{
|
|
142
|
-
uri: "test://readme",
|
|
143
|
-
mimeType: "text/plain",
|
|
144
|
-
text: "# Test Server\n\nThis is a test MCP server.",
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
throw new Error(`Unknown resource: ${request.params.uri}`);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// Start the server
|
|
153
|
-
async function main() {
|
|
154
|
-
const transport = new StdioServerTransport();
|
|
155
|
-
await server.connect(transport);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
main().catch(console.error);
|
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for MCP Conformance Action
|
|
3
|
-
*
|
|
4
|
-
* These tests actually spin up MCP servers and probe them.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { probeServer, probeResultToFiles } from "../probe.js";
|
|
8
|
-
import { spawn, ChildProcess } from "child_process";
|
|
9
|
-
import * as path from "path";
|
|
10
|
-
import { fileURLToPath } from "url";
|
|
11
|
-
import { jest } from "@jest/globals";
|
|
12
|
-
|
|
13
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const FIXTURES_DIR = path.join(__dirname, "fixtures");
|
|
15
|
-
|
|
16
|
-
// Increase timeout for integration tests
|
|
17
|
-
jest.setTimeout(30000);
|
|
18
|
-
|
|
19
|
-
describe("Integration: stdio transport", () => {
|
|
20
|
-
it("probes a real stdio MCP server", async () => {
|
|
21
|
-
const result = await probeServer({
|
|
22
|
-
transport: "stdio",
|
|
23
|
-
command: "npx",
|
|
24
|
-
args: ["tsx", path.join(FIXTURES_DIR, "stdio-server.ts")],
|
|
25
|
-
workingDir: FIXTURES_DIR,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
// Should not have errors
|
|
29
|
-
expect(result.error).toBeUndefined();
|
|
30
|
-
|
|
31
|
-
// Should have initialize info
|
|
32
|
-
expect(result.initialize).not.toBeNull();
|
|
33
|
-
expect(result.initialize?.serverInfo?.name).toBe("test-stdio-server");
|
|
34
|
-
expect(result.initialize?.serverInfo?.version).toBe("1.0.0");
|
|
35
|
-
|
|
36
|
-
// Should have tools
|
|
37
|
-
expect(result.tools).not.toBeNull();
|
|
38
|
-
expect(result.tools?.tools).toHaveLength(2);
|
|
39
|
-
const toolNames = result.tools?.tools.map((t) => t.name).sort();
|
|
40
|
-
expect(toolNames).toEqual(["add", "greet"]);
|
|
41
|
-
|
|
42
|
-
// Should have prompts
|
|
43
|
-
expect(result.prompts).not.toBeNull();
|
|
44
|
-
expect(result.prompts?.prompts).toHaveLength(1);
|
|
45
|
-
expect(result.prompts?.prompts[0].name).toBe("code-review");
|
|
46
|
-
|
|
47
|
-
// Should have resources
|
|
48
|
-
expect(result.resources).not.toBeNull();
|
|
49
|
-
expect(result.resources?.resources).toHaveLength(1);
|
|
50
|
-
expect(result.resources?.resources[0].uri).toBe("test://readme");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("handles probe errors gracefully", async () => {
|
|
54
|
-
const result = await probeServer({
|
|
55
|
-
transport: "stdio",
|
|
56
|
-
command: "node",
|
|
57
|
-
args: ["-e", "console.log('not an mcp server'); process.exit(1)"],
|
|
58
|
-
workingDir: FIXTURES_DIR,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// Should have an error
|
|
62
|
-
expect(result.error).toBeDefined();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("returns error for non-existent command", async () => {
|
|
66
|
-
const result = await probeServer({
|
|
67
|
-
transport: "stdio",
|
|
68
|
-
command: "non-existent-command-12345",
|
|
69
|
-
args: [],
|
|
70
|
-
workingDir: FIXTURES_DIR,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
// Should have an error
|
|
74
|
-
expect(result.error).toBeDefined();
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe("Integration: streamable-http transport", () => {
|
|
79
|
-
let serverProcess: ChildProcess | null = null;
|
|
80
|
-
let serverUrl: string = "";
|
|
81
|
-
|
|
82
|
-
async function startHttpServer(): Promise<void> {
|
|
83
|
-
return new Promise((resolve, reject) => {
|
|
84
|
-
// Use port 0 to let OS assign free port
|
|
85
|
-
serverProcess = spawn("npx", ["tsx", path.join(FIXTURES_DIR, "http-server.ts"), "0"], {
|
|
86
|
-
cwd: FIXTURES_DIR,
|
|
87
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
88
|
-
detached: false, // Ensure child process is attached to parent
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
let resolved = false;
|
|
92
|
-
|
|
93
|
-
serverProcess.stdout?.on("data", (data) => {
|
|
94
|
-
const output = data.toString();
|
|
95
|
-
// Parse the actual port from server output
|
|
96
|
-
const portMatch = output.match(/listening on port (\d+)/);
|
|
97
|
-
if (portMatch && !resolved) {
|
|
98
|
-
resolved = true;
|
|
99
|
-
const port = portMatch[1];
|
|
100
|
-
serverUrl = `http://localhost:${port}/mcp`;
|
|
101
|
-
// Give it a moment to be fully ready
|
|
102
|
-
setTimeout(resolve, 500);
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
serverProcess.stderr?.on("data", (data) => {
|
|
107
|
-
// Log errors during startup to help debug
|
|
108
|
-
if (!resolved) {
|
|
109
|
-
const msg = data.toString();
|
|
110
|
-
if (msg.includes("EADDRINUSE")) {
|
|
111
|
-
reject(new Error("Port conflict - this should not happen with port 0"));
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
serverProcess.on("error", (err) => {
|
|
117
|
-
if (!resolved) {
|
|
118
|
-
resolved = true;
|
|
119
|
-
reject(err);
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
serverProcess.on("exit", (code) => {
|
|
124
|
-
if (!resolved && code !== 0) {
|
|
125
|
-
resolved = true;
|
|
126
|
-
reject(new Error(`Server exited with code ${code}`));
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// Timeout after 10s
|
|
131
|
-
setTimeout(() => {
|
|
132
|
-
if (!resolved) {
|
|
133
|
-
resolved = true;
|
|
134
|
-
reject(new Error("Server startup timeout"));
|
|
135
|
-
}
|
|
136
|
-
}, 10000);
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function stopHttpServer(): Promise<void> {
|
|
141
|
-
if (serverProcess) {
|
|
142
|
-
const proc = serverProcess;
|
|
143
|
-
serverProcess = null;
|
|
144
|
-
|
|
145
|
-
// Try graceful shutdown first
|
|
146
|
-
proc.kill("SIGTERM");
|
|
147
|
-
|
|
148
|
-
// Wait for exit with timeout, then force kill
|
|
149
|
-
await new Promise<void>((resolve) => {
|
|
150
|
-
const forceKillTimeout = setTimeout(() => {
|
|
151
|
-
proc.kill("SIGKILL");
|
|
152
|
-
}, 1000);
|
|
153
|
-
|
|
154
|
-
proc.on("exit", () => {
|
|
155
|
-
clearTimeout(forceKillTimeout);
|
|
156
|
-
resolve();
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// Final safety timeout
|
|
160
|
-
setTimeout(resolve, 2000);
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
beforeAll(async () => {
|
|
166
|
-
await startHttpServer();
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
afterAll(async () => {
|
|
170
|
-
await stopHttpServer();
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("probes a real HTTP MCP server", async () => {
|
|
174
|
-
const result = await probeServer({
|
|
175
|
-
transport: "streamable-http",
|
|
176
|
-
url: serverUrl,
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// Should not have errors
|
|
180
|
-
expect(result.error).toBeUndefined();
|
|
181
|
-
|
|
182
|
-
// Should have initialize info
|
|
183
|
-
expect(result.initialize).not.toBeNull();
|
|
184
|
-
expect(result.initialize?.serverInfo?.name).toBe("test-http-server");
|
|
185
|
-
expect(result.initialize?.serverInfo?.version).toBe("1.0.0");
|
|
186
|
-
|
|
187
|
-
// Should have tools
|
|
188
|
-
expect(result.tools).not.toBeNull();
|
|
189
|
-
expect(result.tools?.tools).toHaveLength(1);
|
|
190
|
-
expect(result.tools?.tools[0].name).toBe("echo");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("returns error for unavailable HTTP server", async () => {
|
|
194
|
-
const result = await probeServer({
|
|
195
|
-
transport: "streamable-http",
|
|
196
|
-
url: "http://localhost:59999/mcp", // Port that's not listening
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// Should have an error
|
|
200
|
-
expect(result.error).toBeDefined();
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe("Integration: normalization end-to-end", () => {
|
|
205
|
-
it("normalizes probe results consistently", async () => {
|
|
206
|
-
const result = await probeServer({
|
|
207
|
-
transport: "stdio",
|
|
208
|
-
command: "npx",
|
|
209
|
-
args: ["tsx", path.join(FIXTURES_DIR, "stdio-server.ts")],
|
|
210
|
-
workingDir: FIXTURES_DIR,
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
expect(result.error).toBeUndefined();
|
|
214
|
-
|
|
215
|
-
// Convert to files
|
|
216
|
-
const files = probeResultToFiles(result);
|
|
217
|
-
|
|
218
|
-
// Should have expected files
|
|
219
|
-
expect(files.has("initialize")).toBe(true);
|
|
220
|
-
expect(files.has("tools")).toBe(true);
|
|
221
|
-
expect(files.has("prompts")).toBe(true);
|
|
222
|
-
expect(files.has("resources")).toBe(true);
|
|
223
|
-
|
|
224
|
-
// Parse and verify tools are normalized (sorted)
|
|
225
|
-
const tools = JSON.parse(files.get("tools")!);
|
|
226
|
-
const toolNames = tools.tools.map((t: { name: string }) => t.name);
|
|
227
|
-
expect(toolNames).toEqual(["add", "greet"]); // Sorted alphabetically
|
|
228
|
-
|
|
229
|
-
// Verify the JSON is deterministic
|
|
230
|
-
const files2 = probeResultToFiles(result);
|
|
231
|
-
expect(files.get("tools")).toBe(files2.get("tools"));
|
|
232
|
-
expect(files.get("initialize")).toBe(files2.get("initialize"));
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("produces identical output for semantically identical servers", async () => {
|
|
236
|
-
// Probe the server twice
|
|
237
|
-
const result1 = await probeServer({
|
|
238
|
-
transport: "stdio",
|
|
239
|
-
command: "npx",
|
|
240
|
-
args: ["tsx", path.join(FIXTURES_DIR, "stdio-server.ts")],
|
|
241
|
-
workingDir: FIXTURES_DIR,
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
const result2 = await probeServer({
|
|
245
|
-
transport: "stdio",
|
|
246
|
-
command: "npx",
|
|
247
|
-
args: ["tsx", path.join(FIXTURES_DIR, "stdio-server.ts")],
|
|
248
|
-
workingDir: FIXTURES_DIR,
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
expect(result1.error).toBeUndefined();
|
|
252
|
-
expect(result2.error).toBeUndefined();
|
|
253
|
-
|
|
254
|
-
const files1 = probeResultToFiles(result1);
|
|
255
|
-
const files2 = probeResultToFiles(result2);
|
|
256
|
-
|
|
257
|
-
// All files should be identical
|
|
258
|
-
for (const [name, content] of files1) {
|
|
259
|
-
expect(files2.get(name)).toBe(content);
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
describe("Integration: custom messages", () => {
|
|
265
|
-
it("sends custom messages and captures responses", async () => {
|
|
266
|
-
// The stdio server supports tools/list which we can call as a custom message
|
|
267
|
-
const result = await probeServer({
|
|
268
|
-
transport: "stdio",
|
|
269
|
-
command: "npx",
|
|
270
|
-
args: ["tsx", path.join(FIXTURES_DIR, "stdio-server.ts")],
|
|
271
|
-
workingDir: FIXTURES_DIR,
|
|
272
|
-
customMessages: [
|
|
273
|
-
{
|
|
274
|
-
id: 1,
|
|
275
|
-
name: "list-tools-custom",
|
|
276
|
-
message: { method: "tools/list", params: {} },
|
|
277
|
-
},
|
|
278
|
-
],
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
expect(result.error).toBeUndefined();
|
|
282
|
-
|
|
283
|
-
// Should have custom response
|
|
284
|
-
expect(result.customResponses.has("list-tools-custom")).toBe(true);
|
|
285
|
-
const customResponse = result.customResponses.get("list-tools-custom") as { tools: unknown[] };
|
|
286
|
-
expect(customResponse.tools).toHaveLength(2);
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
describe("Integration: environment variables", () => {
|
|
291
|
-
it("passes environment variables to stdio server", async () => {
|
|
292
|
-
const result = await probeServer({
|
|
293
|
-
transport: "stdio",
|
|
294
|
-
command: "npx",
|
|
295
|
-
args: ["tsx", path.join(FIXTURES_DIR, "stdio-server.ts")],
|
|
296
|
-
workingDir: FIXTURES_DIR,
|
|
297
|
-
envVars: {
|
|
298
|
-
TEST_VAR: "test_value",
|
|
299
|
-
},
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
// Server should start successfully (it doesn't use the env var, but it should be passed)
|
|
303
|
-
expect(result.error).toBeUndefined();
|
|
304
|
-
expect(result.initialize?.serverInfo?.name).toBe("test-stdio-server");
|
|
305
|
-
});
|
|
306
|
-
});
|