Skip to content

Commit 698944e

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 698944e

1 file changed

Lines changed: 81 additions & 67 deletions

File tree

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

Lines changed: 81 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,77 @@ 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

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

2587
describe('Cloudflare Workers compatibility (no nodejs_compat)', () => {
26-
let env: TestEnv | null = null;
88+
let cleanup: (() => Promise<void>) | null = null;
2789

2890
beforeAll(async () => {
2991
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-worker-test-'));
@@ -42,8 +104,7 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => {
42104
private: true,
43105
type: 'module',
44106
dependencies: {
45-
'@modelcontextprotocol/server': `file:./${tarballName}`,
46-
'@cfworker/json-schema': '^4.1.1'
107+
'@modelcontextprotocol/server': `file:./${tarballName}`
47108
},
48109
devDependencies: {
49110
wrangler: '^4.14.4'
@@ -84,50 +145,22 @@ export default {
84145
// Install dependencies
85146
execSync('npm install', { cwd: tempDir, stdio: 'pipe', timeout: 60_000 });
86147

87-
// Start wrangler dev server
148+
// Start wrangler dev server. Readiness is determined by probing the MCP endpoint, not by
149+
// parsing wrangler's stdout — see waitForMcpReady for the reasoning.
88150
const proc = spawn('npx', ['wrangler', 'dev', '--local', '--port', String(PORT)], {
89151
cwd: tempDir,
90152
shell: true,
91153
stdio: 'pipe'
92154
});
93155

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-
});
156+
try {
157+
await waitForMcpReady(proc);
158+
} catch (error) {
159+
proc.kill('SIGTERM');
160+
throw error;
161+
}
129162

130-
const cleanup = async () => {
163+
cleanup = async () => {
131164
proc.kill('SIGTERM');
132165
await new Promise<void>(resolve => {
133166
proc.on('close', () => resolve());
@@ -139,35 +172,16 @@ export default {
139172
// Ignore cleanup errors
140173
}
141174
};
142-
143-
env = { tempDir, process: proc, cleanup };
144175
}, 120_000);
145176

146177
afterAll(async () => {
147-
await env?.cleanup();
178+
await cleanup?.();
148179
});
149180

150181
it('should handle MCP requests', async () => {
151-
expect(env).not.toBeNull();
152-
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-
}
182+
const client = new Client({ name: 'test-client', version: '1.0.0' });
183+
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/`));
184+
await client.connect(transport);
171185

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

0 commit comments

Comments
 (0)