diff --git a/packages/playwright-core/src/tools/mcp/index.ts b/packages/playwright-core/src/tools/mcp/index.ts index 51619e006708a..a1a1c8c4b5a0c 100644 --- a/packages/playwright-core/src/tools/mcp/index.ts +++ b/packages/playwright-core/src/tools/mcp/index.ts @@ -43,7 +43,7 @@ export async function createConnection(userConfig: Config = {}, contextGetter?: }, disposed: async () => { } }; - return createServer('api', packageJSON.version, backendFactory, false); + return createServer('api', packageJSON.version, backendFactory, Promise.resolve(), false); } class SimpleBrowser { diff --git a/packages/playwright-core/src/tools/utils/mcp/DEPS.list b/packages/playwright-core/src/tools/utils/mcp/DEPS.list index f5833b09a35c3..b050876e51b50 100644 --- a/packages/playwright-core/src/tools/utils/mcp/DEPS.list +++ b/packages/playwright-core/src/tools/utils/mcp/DEPS.list @@ -1,5 +1,6 @@ [*] @utils/** +@isomorphic/** node_modules/@modelcontextprotocol/sdk/server/index.js node_modules/@modelcontextprotocol/sdk/server/sse.js node_modules/@modelcontextprotocol/sdk/server/stdio.js diff --git a/packages/playwright-core/src/tools/utils/mcp/http.ts b/packages/playwright-core/src/tools/utils/mcp/http.ts index 8d6a2fb38dd5f..4f81786960b3f 100644 --- a/packages/playwright-core/src/tools/utils/mcp/http.ts +++ b/packages/playwright-core/src/tools/utils/mcp/http.ts @@ -24,6 +24,7 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { urlHostFromAddress } from '@utils/httpServer'; import { createHttpServer, startHttpServer } from '@utils/network'; +import { ManualPromise } from '@isomorphic/manualPromise'; import * as mcpServer from './server'; @@ -123,7 +124,7 @@ async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.I const transport = new SSEServerTransport('/sse', res); sessions.set(transport.sessionId, transport); testDebug(`create SSE session`); - await mcpServer.connect(serverBackendFactory, transport, false); + await mcpServer.connect(serverBackendFactory, transport, Promise.resolve(), false); res.on('close', () => { testDebug(`delete SSE session`); sessions.delete(transport.sessionId); @@ -135,16 +136,22 @@ async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.I res.end('Method not allowed'); } -async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map) { +async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map }>) { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (sessionId) { - const transport = sessions.get(sessionId); - if (!transport) { + const sessionInfo = sessions.get(sessionId); + if (!sessionInfo) { res.statusCode = 404; res.end('Session not found'); return; } - return await transport.handleRequest(req, res); + if (req.method === 'GET') { + // As per spec, GET is for the event stream only, when we see it consider transport bidirectionally ready. + const streamResponse = sessionInfo.transport.handleRequest(req, res); + sessionInfo.transportInitialized.resolve(); + return streamResponse; + } + return sessionInfo.transport.handleRequest(req, res); } if (req.method === 'POST') { @@ -152,8 +159,11 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: async sessionId => { testDebug(`create http session`); - await mcpServer.connect(serverBackendFactory, transport, true); - sessions.set(sessionId, transport); + const sessionInfo = { transport, transportInitialized: new ManualPromise() }; + // Only give the client 5 seconds to reach for the event stream. + setTimeout(() => sessionInfo.transportInitialized.resolve(), 5000); + sessions.set(sessionId, sessionInfo); + await mcpServer.connect(serverBackendFactory, sessionInfo.transport, sessionInfo.transportInitialized, true); } }); diff --git a/packages/playwright-core/src/tools/utils/mcp/server.ts b/packages/playwright-core/src/tools/utils/mcp/server.ts index 4bd2ea7eef76f..15cf6ebe1b5ed 100644 --- a/packages/playwright-core/src/tools/utils/mcp/server.ts +++ b/packages/playwright-core/src/tools/utils/mcp/server.ts @@ -75,12 +75,12 @@ export type ServerBackendFactory = { disposed: (backend: ServerBackend) => Promise; }; -export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) { - const server = createServer(factory.name, factory.version, factory, runHeartbeat); +export async function connect(factory: ServerBackendFactory, transport: Transport, transportInitialized: Promise, runHeartbeat: boolean) { + const server = createServer(factory.name, factory.version, factory, transportInitialized, runHeartbeat); await server.connect(transport); } -export function createServer(name: string, version: string, factory: ServerBackendFactory, runHeartbeat: boolean): ServerType { +export function createServer(name: string, version: string, factory: ServerBackendFactory, transportInitialized: Promise, runHeartbeat: boolean): ServerType { const server = new Server({ name, version }, { capabilities: { tools: {}, @@ -102,7 +102,7 @@ export function createServer(name: string, version: string, factory: ServerBacke try { if (!backendPromise) { - backendPromise = initializeServer(server, factory, runHeartbeat).catch(e => { + backendPromise = initializeServer(server, factory, transportInitialized, runHeartbeat).catch(e => { backendPromise = undefined; throw e; }); @@ -129,10 +129,11 @@ export function createServer(name: string, version: string, factory: ServerBacke return server; } -const initializeServer = async (server: ServerType, factory: ServerBackendFactory, runHeartbeat: boolean): Promise => { +const initializeServer = async (server: ServerType, factory: ServerBackendFactory, transportInitialized: Promise, runHeartbeat: boolean): Promise => { const capabilities = server.getClientCapabilities(); let clientRoots: Root[] = []; if (capabilities?.roots) { + await transportInitialized; const { roots } = await server.listRoots().catch(e => { serverDebug(e); return { roots: [] }; @@ -196,7 +197,7 @@ export async function start(serverBackendFactory: ServerBackendFactory, options: // The SDK's StdioServerTransport doesn't detect peer disconnect — it never listens for stdin // end-of-stream. Wire it up so callTool requests can be cancelled when the client goes away. process.stdin.on('end', () => void transport.close()); - await connect(serverBackendFactory, transport, false); + await connect(serverBackendFactory, transport, Promise.resolve(), false); return; }