Skip to content

Commit cecc9a8

Browse files
committed
test(integration): drop pre-installed @cfworker/json-schema from CF Workers test
PR #2088 adds @cfworker/json-schema to noExternal so the validator is bundled into the published @modelcontextprotocol/server tarball. The Cloudflare Workers integration test was still installing the package as a direct dep in the generated consumer package.json, which masked any future re-externalization regression — wrangler would resolve the bare import from the test's own install instead of failing. Removing the dep turns the test into a true regression guard that the bundle is genuinely self-contained, matching the migration docs. Closes the last open review thread on #2088.
1 parent 44e7b26 commit cecc9a8

1 file changed

Lines changed: 78 additions & 56 deletions

File tree

test/integration/test/server/cloudflareWorkers.test.ts

Lines changed: 78 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,79 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli
1515
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
1616

1717
const PORT = 8787;
18+
const READINESS_TIMEOUT_MS = 60_000;
19+
const READINESS_POLL_INTERVAL_MS = 100;
1820

1921
interface TestEnv {
2022
tempDir: string;
2123
process: ChildProcess;
2224
cleanup: () => Promise<void>;
2325
}
2426

27+
/**
28+
* Wait until the worker can serve a real MCP `initialize` request.
29+
*
30+
* Wrangler's "Ready on …" stdout line is unreliable: miniflare can print it before the user
31+
* worker is actually wired, and subsequent POSTs come back as `500 Network connection lost` or
32+
* `ECONNREFUSED`. The only signal we can trust is "the server returned an MCP-shaped response
33+
* to a protocol request".
34+
*
35+
* Polls the configured port with an MCP `initialize` POST every {@link READINESS_POLL_INTERVAL_MS}ms
36+
* until either a JSON-RPC result body comes back, the wrangler process exits, or
37+
* {@link READINESS_TIMEOUT_MS} elapses.
38+
*/
39+
async function waitForMcpReady(proc: ChildProcess): Promise<void> {
40+
let stderrTail = '';
41+
proc.stderr?.on('data', d => {
42+
stderrTail = (stderrTail + d.toString()).slice(-2048);
43+
});
44+
45+
let processExitedWithCode: number | null = null;
46+
proc.on('exit', code => {
47+
processExitedWithCode = code ?? -1;
48+
});
49+
50+
const deadline = Date.now() + READINESS_TIMEOUT_MS;
51+
let lastFailure = 'no attempts made';
52+
53+
while (Date.now() < deadline) {
54+
if (processExitedWithCode !== null) {
55+
throw new Error(`wrangler dev exited with code ${processExitedWithCode} before becoming ready.\nstderr tail:\n${stderrTail}`);
56+
}
57+
58+
try {
59+
const response = await fetch(`http://127.0.0.1:${PORT}/`, {
60+
method: 'POST',
61+
headers: {
62+
'Content-Type': 'application/json',
63+
Accept: 'application/json, text/event-stream'
64+
},
65+
body: JSON.stringify({
66+
jsonrpc: '2.0',
67+
id: 'readiness-probe',
68+
method: 'initialize',
69+
params: {
70+
protocolVersion: '2025-06-18',
71+
capabilities: {},
72+
clientInfo: { name: 'readiness-probe', version: '0' }
73+
}
74+
})
75+
});
76+
const body = await response.text();
77+
if (response.ok && body.includes('"jsonrpc"') && body.includes('"result"')) {
78+
return;
79+
}
80+
lastFailure = `status=${response.status} body=${body.slice(0, 200)}`;
81+
} catch (error) {
82+
lastFailure = (error as { cause?: { code?: string }; message: string }).cause?.code ?? (error as Error).message;
83+
}
84+
85+
await new Promise(resolve => setTimeout(resolve, READINESS_POLL_INTERVAL_MS));
86+
}
87+
88+
throw new Error(`Worker did not become ready within ${READINESS_TIMEOUT_MS}ms.\nLast probe: ${lastFailure}\nstderr tail:\n${stderrTail}`);
89+
}
90+
2591
describe('Cloudflare Workers compatibility (no nodejs_compat)', () => {
2692
let env: TestEnv | null = null;
2793

@@ -42,8 +108,7 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => {
42108
private: true,
43109
type: 'module',
44110
dependencies: {
45-
'@modelcontextprotocol/server': `file:./${tarballName}`,
46-
'@cfworker/json-schema': '^4.1.1'
111+
'@modelcontextprotocol/server': `file:./${tarballName}`
47112
},
48113
devDependencies: {
49114
wrangler: '^4.14.4'
@@ -84,48 +149,20 @@ export default {
84149
// Install dependencies
85150
execSync('npm install', { cwd: tempDir, stdio: 'pipe', timeout: 60_000 });
86151

87-
// Start wrangler dev server
152+
// Start wrangler dev server. Readiness is determined by probing the MCP endpoint, not by
153+
// parsing wrangler's stdout — see waitForMcpReady for the reasoning.
88154
const proc = spawn('npx', ['wrangler', 'dev', '--local', '--port', String(PORT)], {
89155
cwd: tempDir,
90156
shell: true,
91157
stdio: 'pipe'
92158
});
93159

94-
// Wait for server to be ready
95-
await new Promise<void>((resolve, reject) => {
96-
const timeout = setTimeout(() => reject(new Error('Wrangler startup timeout')), 60_000);
97-
let stderrData = '';
98-
99-
proc.stdout?.on('data', data => {
100-
const output = data.toString();
101-
if (/Ready on|Listening on/.test(output)) {
102-
clearTimeout(timeout);
103-
// Extra delay for wrangler to fully initialize
104-
setTimeout(resolve, 1000);
105-
}
106-
});
107-
108-
proc.stderr?.on('data', data => {
109-
stderrData += data.toString();
110-
// Check for fatal errors like missing node: modules
111-
if (/No such module "node:/.test(stderrData)) {
112-
clearTimeout(timeout);
113-
reject(new Error(`Wrangler fatal error: ${stderrData}`));
114-
}
115-
});
116-
117-
proc.on('error', err => {
118-
clearTimeout(timeout);
119-
reject(err);
120-
});
121-
122-
proc.on('close', code => {
123-
if (code !== 0 && code !== null) {
124-
clearTimeout(timeout);
125-
reject(new Error(`Wrangler exited with code ${code}. stderr: ${stderrData}`));
126-
}
127-
});
128-
});
160+
try {
161+
await waitForMcpReady(proc);
162+
} catch (error) {
163+
proc.kill('SIGTERM');
164+
throw error;
165+
}
129166

130167
const cleanup = async () => {
131168
proc.kill('SIGTERM');
@@ -150,24 +187,9 @@ export default {
150187
it('should handle MCP requests', async () => {
151188
expect(env).not.toBeNull();
152189

153-
// Retry connection — wrangler may report "Ready" before it can handle requests
154-
let client!: Client;
155-
let lastError: unknown;
156-
for (let attempt = 0; attempt < 5; attempt++) {
157-
try {
158-
client = new Client({ name: 'test-client', version: '1.0.0' });
159-
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/`));
160-
await client.connect(transport);
161-
lastError = undefined;
162-
break;
163-
} catch (error) {
164-
lastError = error;
165-
await new Promise(resolve => setTimeout(resolve, 1000));
166-
}
167-
}
168-
if (lastError) {
169-
throw lastError;
170-
}
190+
const client = new Client({ name: 'test-client', version: '1.0.0' });
191+
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/`));
192+
await client.connect(transport);
171193

172194
const result = await client.callTool({ name: 'greet', arguments: { name: 'World' } });
173195
expect(result.content).toEqual([{ type: 'text', text: 'Hello, World!' }]);

0 commit comments

Comments
 (0)