/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'node:fs'; import url from 'node:url'; import { ChildProcess, spawn } from 'node:child_process'; import path from 'node:path'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { test as baseTest, expect } from './fixtures.js'; import type { Config } from '../config.d.ts'; // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({ serverEndpoint: async ({ mcpHeadless }, use, testInfo) => { let cp: ChildProcess | undefined; const userDataDir = testInfo.outputPath('user-data-dir'); await use(async (options?: { args?: string[], noPort?: boolean }) => { if (cp) throw new Error('Process already running'); cp = spawn('node', [ path.join(path.dirname(__filename), '../cli.js'), ...(options?.noPort ? [] : ['--port=0']), '--user-data-dir=' + userDataDir, ...(mcpHeadless ? ['--headless'] : []), ...(options?.args || []), ], { stdio: 'pipe', env: { ...process.env, DEBUG: 'pw:mcp:test', DEBUG_COLORS: '0', DEBUG_HIDE_DATE: '1', }, }); let stderr = ''; const url = await new Promise(resolve => cp!.stderr?.on('data', data => { stderr += data.toString(); const match = stderr.match(/Listening on (http:\/\/.*)/); if (match) resolve(match[1]); })); return { url: new URL(url), stderr: () => stderr }; }); cp?.kill('SIGTERM'); }, }); test('sse transport', async ({ serverEndpoint }) => { const { url } = await serverEndpoint(); const transport = new SSEClientTransport(url); const client = new Client({ name: 'test', version: '1.0.0' }); await client.connect(transport); await client.ping(); }); test('sse transport (config)', async ({ serverEndpoint }) => { const config: Config = { server: { port: 0, } }; const configFile = test.info().outputPath('config.json'); await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2)); const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] }); const transport = new SSEClientTransport(url); const client = new Client({ name: 'test', version: '1.0.0' }); await client.connect(transport); await client.ping(); }); test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => { const { url, stderr } = await serverEndpoint({ args: ['--isolated'] }); const transport1 = new SSEClientTransport(url); const client1 = new Client({ name: 'test', version: '1.0.0' }); await client1.connect(transport1); await client1.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); await client1.close(); const transport2 = new SSEClientTransport(url); const client2 = new Client({ name: 'test', version: '1.0.0' }); await client2.connect(transport2); await client2.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); await client2.close(); await expect(async () => { const lines = stderr().split('\n'); expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); expect(lines.filter(line => line.match(/create context/)).length).toBe(2); expect(lines.filter(line => line.match(/close context/)).length).toBe(2); expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2); expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2); expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2); expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2); }).toPass(); }); test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => { const { url, stderr } = await serverEndpoint({ args: ['--isolated'] }); const transport1 = new SSEClientTransport(url); const client1 = new Client({ name: 'test', version: '1.0.0' }); await client1.connect(transport1); await client1.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); const transport2 = new SSEClientTransport(url); const client2 = new Client({ name: 'test', version: '1.0.0' }); await client2.connect(transport2); await client2.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); await client1.close(); const transport3 = new SSEClientTransport(url); const client3 = new Client({ name: 'test', version: '1.0.0' }); await client3.connect(transport3); await client3.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); await client2.close(); await client3.close(); await expect(async () => { const lines = stderr().split('\n'); expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3); expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3); expect(lines.filter(line => line.match(/create context/)).length).toBe(3); expect(lines.filter(line => line.match(/close context/)).length).toBe(3); expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3); expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3); expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1); expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1); }).toPass(); }); test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => { const { url, stderr } = await serverEndpoint(); const transport1 = new SSEClientTransport(url); const client1 = new Client({ name: 'test', version: '1.0.0' }); await client1.connect(transport1); await client1.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); await client1.close(); const transport2 = new SSEClientTransport(url); const client2 = new Client({ name: 'test', version: '1.0.0' }); await client2.connect(transport2); await client2.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); await client2.close(); await expect(async () => { const lines = stderr().split('\n'); expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); expect(lines.filter(line => line.match(/create context/)).length).toBe(2); expect(lines.filter(line => line.match(/close context/)).length).toBe(2); expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2); expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2); expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2); expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2); }).toPass(); }); test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => { const { url } = await serverEndpoint(); const transport1 = new SSEClientTransport(url); const client1 = new Client({ name: 'test', version: '1.0.0' }); await client1.connect(transport1); await client1.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); const transport2 = new SSEClientTransport(url); const client2 = new Client({ name: 'test', version: '1.0.0' }); await client2.connect(transport2); const response = await client2.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); expect(response.isError).toBe(true); expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser'); await client1.close(); await client2.close(); }); test('streamable http transport', async ({ serverEndpoint }) => { const { url } = await serverEndpoint(); const transport = new StreamableHTTPClientTransport(new URL('/mcp', url)); const client = new Client({ name: 'test', version: '1.0.0' }); await client.connect(transport); await client.ping(); expect(transport.sessionId, 'has session support').toBeDefined(); });