Skip to content

Commit 8137c41

Browse files
authored
test(nx-mcp): add e2e test for connecting from two mcp clients (#2882)
1 parent c4cd7ce commit 8137c41

File tree

3 files changed

+223
-0
lines changed

3 files changed

+223
-0
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {
2+
cleanupNxWorkspace,
3+
defaultVersion,
4+
e2eCwd,
5+
newWorkspace,
6+
simpleReactWorkspaceOptions,
7+
uniq,
8+
TestMCPClient,
9+
} from '@nx-console/shared-e2e-utils';
10+
import { spawn, ChildProcess } from 'node:child_process';
11+
import { rmSync } from 'node:fs';
12+
import { join } from 'node:path';
13+
import { workspaceRoot } from 'nx/src/devkit-exports';
14+
15+
describe('HTTP Multi-Client', () => {
16+
let serverProcess: ChildProcess;
17+
const serverPort = 9922;
18+
const workspaceName = uniq('nx-mcp-http-test');
19+
const testWorkspacePath = join(e2eCwd, workspaceName);
20+
const serverPath = join(workspaceRoot, 'dist', 'apps', 'nx-mcp', 'main.js');
21+
22+
// Helper to wait for HTTP server to be ready by polling
23+
async function waitForServerReady(
24+
port: number,
25+
timeoutMs = 60000,
26+
): Promise<void> {
27+
const startTime = Date.now();
28+
29+
while (Date.now() - startTime < timeoutMs) {
30+
try {
31+
// Try to connect to the server
32+
// this is not an official supported thing in the spec but it works for checkign aliveness
33+
const response = await fetch(`http://localhost:${port}/mcp`, {
34+
method: 'OPTIONS',
35+
});
36+
37+
// If we get any response (even an error), the server is up
38+
console.log(`Server is ready on port ${port}`);
39+
return;
40+
} catch (error) {
41+
// Server not ready yet, wait and retry
42+
await new Promise((resolve) => setTimeout(resolve, 500));
43+
}
44+
}
45+
46+
throw new Error(`Timeout waiting for server to be ready on port ${port}`);
47+
}
48+
49+
beforeAll(async () => {
50+
// Create workspace
51+
newWorkspace({
52+
name: workspaceName,
53+
options: simpleReactWorkspaceOptions,
54+
});
55+
56+
// Start HTTP MCP server without workspace path
57+
// The workspace will be determined per-session based on requests
58+
serverProcess = spawn(
59+
'node',
60+
[serverPath, '--transport=http', `--port=${serverPort}`],
61+
{
62+
stdio: 'pipe',
63+
env: {
64+
...process.env,
65+
NX_NO_CLOUD: 'true',
66+
MCP_AUTO_OPEN_ENABLED: 'false',
67+
},
68+
cwd: testWorkspacePath, // Set working directory to workspace
69+
},
70+
);
71+
72+
// Log server output for debugging
73+
serverProcess.stdout?.on('data', (data) => {
74+
if (process.env['NX_VERBOSE_LOGGING']) {
75+
console.log(`[MCP Server] ${data.toString()}`);
76+
}
77+
});
78+
79+
serverProcess.stderr?.on('data', (data) => {
80+
if (process.env['NX_VERBOSE_LOGGING']) {
81+
console.error(`[MCP Server Error] ${data.toString()}`);
82+
}
83+
});
84+
85+
// Wait for server to be ready by polling the endpoint
86+
await waitForServerReady(serverPort);
87+
88+
console.log(`MCP HTTP server confirmed ready on port ${serverPort}`);
89+
});
90+
91+
afterAll(async () => {
92+
// Kill server
93+
if (serverProcess) {
94+
serverProcess.kill('SIGTERM');
95+
// Wait a bit for graceful shutdown
96+
await new Promise((resolve) => setTimeout(resolve, 1000));
97+
if (!serverProcess.killed) {
98+
serverProcess.kill('SIGKILL');
99+
}
100+
}
101+
102+
// Clean up workspace
103+
await cleanupNxWorkspace(testWorkspacePath, defaultVersion);
104+
rmSync(testWorkspacePath, { recursive: true, force: true });
105+
});
106+
107+
it('should handle two simultaneous clients listing tools', async () => {
108+
const serverUrl = `http://localhost:${serverPort}/mcp`;
109+
110+
// Create two test clients
111+
const client1 = new TestMCPClient(serverUrl, 'test-client-1');
112+
const client2 = new TestMCPClient(serverUrl, 'test-client-2');
113+
114+
// Connect both clients in parallel
115+
await Promise.all([client1.connect(), client2.connect()]);
116+
117+
// Make two simultaneous requests
118+
const [tools1, tools2] = await Promise.all([
119+
client1.listTools(),
120+
client2.listTools(),
121+
]);
122+
123+
// Verify both clients got valid responses
124+
expect(tools1).toBeDefined();
125+
expect(tools2).toBeDefined();
126+
127+
const toolNames1 = tools1.map((tool: any) => tool.name);
128+
const toolNames2 = tools2.map((tool: any) => tool.name);
129+
130+
// Both should have the same set of tools
131+
const expectedTools = [
132+
'nx_docs',
133+
'nx_available_plugins',
134+
'nx_workspace',
135+
'nx_workspace_path',
136+
'nx_project_details',
137+
'nx_generators',
138+
'nx_generator_schema',
139+
];
140+
141+
expect(toolNames1).toEqual(expectedTools);
142+
expect(toolNames2).toEqual(expectedTools);
143+
144+
console.log('Both clients successfully connected and retrieved tools');
145+
146+
// Disconnect both clients
147+
await Promise.all([client1.disconnect(), client2.disconnect()]);
148+
});
149+
150+
it('should allow both clients to invoke tools and get results', async () => {
151+
const serverUrl = `http://localhost:${serverPort}/mcp`;
152+
153+
// Create two test clients
154+
const client1 = new TestMCPClient(serverUrl, 'test-client-3');
155+
const client2 = new TestMCPClient(serverUrl, 'test-client-4');
156+
157+
// Connect both clients in parallel
158+
await Promise.all([client1.connect(), client2.connect()]);
159+
160+
// Make two simultaneous tool calls
161+
const [result1, result2] = await Promise.all([
162+
client1.callTool('nx_workspace_path', {}),
163+
client2.callTool('nx_workspace_path', {}),
164+
]);
165+
166+
// Verify both clients got valid tool results
167+
expect(result1.content).toBeDefined();
168+
expect(result2.content).toBeDefined();
169+
170+
// The nx_workspace_path tool returns the workspace path as text
171+
expect(result1.content[0].type).toBe('text');
172+
expect(result1.content[0].text).toContain(testWorkspacePath);
173+
174+
expect(result2.content[0].type).toBe('text');
175+
expect(result2.content[0].text).toContain(testWorkspacePath);
176+
177+
console.log(
178+
'Both clients successfully invoked tools and received correct results',
179+
);
180+
181+
// Disconnect both clients
182+
await Promise.all([client1.disconnect(), client2.disconnect()]);
183+
});
184+
});

libs/shared/e2e-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './lib/utils';
2+
export * from './lib/mcp-test-client';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3+
4+
export class TestMCPClient {
5+
private client: Client;
6+
private transport: StreamableHTTPClientTransport | null = null;
7+
8+
constructor(
9+
private serverUrl: string,
10+
clientName: string,
11+
) {
12+
this.client = new Client({
13+
name: clientName,
14+
version: '1.0.0',
15+
});
16+
}
17+
18+
async connect(): Promise<void> {
19+
this.transport = new StreamableHTTPClientTransport(new URL(this.serverUrl));
20+
await this.client.connect(this.transport);
21+
}
22+
23+
async listTools(): Promise<any[]> {
24+
const result = await this.client.listTools();
25+
return result.tools;
26+
}
27+
28+
async callTool(name: string, args: Record<string, any> = {}): Promise<any> {
29+
return await this.client.callTool({
30+
name,
31+
arguments: args,
32+
});
33+
}
34+
35+
async disconnect(): Promise<void> {
36+
await this.client.close();
37+
}
38+
}

0 commit comments

Comments
 (0)