A Streamable HTTP client that advertises capabilities.roots but never opens the standalone GET SSE stream pays a ~60s stall on its first tools/call. The call then succeeds normally — it is a delay, not a failure, which is why it is easy to misattribute to browser start-up or to the client.
Where: packages/playwright-core/src/tools/utils/mcp/server.ts, initializeServer.
Mechanism: server.listRoots() is awaited with no timeout option, so it falls back to the TypeScript SDK's DEFAULT_REQUEST_TIMEOUT_MSEC (60000). It is a server-to-client request with no relatedRequestId, so StreamableHTTPServerTransport.send() routes it to the standalone stream and returns early when none exists — the request is discarded silently and is never answered. The existing Promise.race([transportInitialized, 5000]) bounds waiting for the transport to become ready; it does not bound listRoots() itself. The .catch() then recovers with roots: [], which is why this presents as a stall followed by success rather than an error.
Measured, two clients against the same server and the same container, minutes apart:
| client |
standalone GET open? |
first tools/call (browser_navigate) |
| opens it |
yes |
708 ms |
| never opens it |
no |
60,683 ms |
The only variable is the standalone stream: both clients send byte-identical POST headers (accept: application/json, text/event-stream, mcp-protocol-version: 2025-06-18).
Browser start-up is excluded as a cause: the fast client ran against a cold browser and the slow one against a warm one. The stall also lands on tools/call, not on initialize — initialize returned in 239 ms in the same session.
Observed on @playwright/mcp 0.0.41 (as reported by serverInfo); the same code path is present in 0.0.79.
Related: PR microsoft/playwright-mcp#826 (fix: cursor does not respond to listRoots) addressed the same failure family for a client that does not answer roots/list. This is the transport-level case, where the request cannot be delivered at all.
Suggested fix: bound the call — server.listRoots(undefined, { timeout: 2000 }) — or skip it when no standalone stream is open.
Workaround for anyone hitting this: stop advertising capabilities.roots, or open the standalone GET stream and keep it open for the session.
A Streamable HTTP client that advertises
capabilities.rootsbut never opens the standaloneGETSSE stream pays a ~60s stall on its firsttools/call. The call then succeeds normally — it is a delay, not a failure, which is why it is easy to misattribute to browser start-up or to the client.Where:
packages/playwright-core/src/tools/utils/mcp/server.ts,initializeServer.Mechanism:
server.listRoots()is awaited with notimeoutoption, so it falls back to the TypeScript SDK'sDEFAULT_REQUEST_TIMEOUT_MSEC(60000). It is a server-to-client request with norelatedRequestId, soStreamableHTTPServerTransport.send()routes it to the standalone stream and returns early when none exists — the request is discarded silently and is never answered. The existingPromise.race([transportInitialized, 5000])bounds waiting for the transport to become ready; it does not boundlistRoots()itself. The.catch()then recovers withroots: [], which is why this presents as a stall followed by success rather than an error.Measured, two clients against the same server and the same container, minutes apart:
GETopen?tools/call(browser_navigate)The only variable is the standalone stream: both clients send byte-identical POST headers (
accept: application/json, text/event-stream,mcp-protocol-version: 2025-06-18).Browser start-up is excluded as a cause: the fast client ran against a cold browser and the slow one against a warm one. The stall also lands on
tools/call, not oninitialize—initializereturned in 239 ms in the same session.Observed on
@playwright/mcp0.0.41 (as reported byserverInfo); the same code path is present in 0.0.79.Related: PR microsoft/playwright-mcp#826 (
fix: cursor does not respond to listRoots) addressed the same failure family for a client that does not answerroots/list. This is the transport-level case, where the request cannot be delivered at all.Suggested fix: bound the call —
server.listRoots(undefined, { timeout: 2000 })— or skip it when no standalone stream is open.Workaround for anyone hitting this: stop advertising
capabilities.roots, or open the standaloneGETstream and keep it open for the session.