@@ -15,15 +15,77 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli
1515import { afterAll , beforeAll , describe , expect , it } from 'vitest' ;
1616
1717const 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
2587describe ( '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 ( / R e a d y o n | L i s t e n i n g o n / . 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 ( / N o s u c h m o d u l e " n o d e : / . 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