Files
playwriter/playwriter/src/mcp.ts
Tommy D. Rossi 0f9591722d Add Playwright CDPSession compatibility and export classes
- Add ICDPSession interface for Playwright CDPSession compatibility
- Add method aliases: addListener, removeListener, once, detach
- Make on/off return 'this' for chaining
- Export CDPSession, Editor, Debugger classes from index
- Classes accept ICDPSession but use typed CDPSession internally
2025-12-31 11:51:03 +01:00

890 lines
27 KiB
TypeScript

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import vm from 'node:vm'
import dedent from 'string-dedent'
import { createPatch } from 'diff'
import { getCdpUrl, LOG_FILE_PATH, VERSION, sleep } from './utils.js'
import { killPortProcess } from 'kill-port-process'
import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
import { getCDPSessionForPage, CDPSession, ICDPSession } from './cdp-session.js'
import { Debugger } from './debugger.js'
import { Editor } from './editor.js'
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
import { getReactSource, type ReactSourceLocation } from './react-source.js'
class CodeExecutionTimeoutError extends Error {
constructor(timeout: number) {
super(`Code execution timed out after ${timeout}ms`)
this.name = 'CodeExecutionTimeoutError'
}
}
const require = createRequire(import.meta.url)
const usefulGlobals = {
setTimeout,
setInterval,
clearTimeout,
clearInterval,
URL,
URLSearchParams,
fetch,
Buffer,
TextEncoder,
TextDecoder,
crypto,
AbortController,
AbortSignal,
structuredClone,
} as const
interface State {
isConnected: boolean
page: Page | null
browser: Browser | null
context: BrowserContext | null
}
interface VMContext {
page: Page
context: BrowserContext
state: Record<string, any>
console: {
log: (...args: any[]) => void
info: (...args: any[]) => void
warn: (...args: any[]) => void
error: (...args: any[]) => void
debug: (...args: any[]) => void
}
accessibilitySnapshot: (options: {
page: Page
search?: string | RegExp
contextLines?: number
showDiffSinceLastCall?: boolean
}) => Promise<string>
getLocatorStringForElement: (element: any) => Promise<string>
resetPlaywright: () => Promise<{ page: Page; context: BrowserContext }>
getLatestLogs: (options?: { page?: Page; count?: number; search?: string | RegExp }) => Promise<string[]>
clearAllLogs: () => void
waitForPageLoad: (options: WaitForPageLoadOptions) => Promise<WaitForPageLoadResult>
getCDPSession: (options: { page: Page }) => Promise<CDPSession>
createDebugger: (options: { cdp: ICDPSession }) => Debugger
createEditor: (options: { cdp: ICDPSession }) => Editor
getStylesForLocator: (options: { locator: any }) => Promise<StylesResult>
formatStylesAsText: (styles: StylesResult) => string
getReactSource: (options: { locator: any }) => Promise<ReactSourceLocation | null>
require: NodeRequire
import: (specifier: string) => Promise<any>
}
type VMContextWithGlobals = VMContext & typeof usefulGlobals
type SelectorGenerator = typeof import('@mizchi/selector-generator')
const state: State = {
isConnected: false,
page: null,
browser: null,
context: null,
}
const userState: Record<string, any> = {}
// Store logs per page targetId
const browserLogs: Map<string, string[]> = new Map()
const MAX_LOGS_PER_PAGE = 5000
// Store last accessibility snapshot per page for diff feature
const lastSnapshots: WeakMap<Page, string> = new WeakMap()
// Cache CDP sessions per page
const cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988
const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe`
interface RemoteConfig {
host: string
port: number
token?: string
}
function getRemoteConfig(): RemoteConfig | null {
const host = process.env.PLAYWRITER_HOST
if (!host) {
return null
}
return {
host,
port: RELAY_PORT,
token: process.env.PLAYWRITER_TOKEN,
}
}
async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> {
if (os.platform() !== 'darwin') {
return
}
const options = (context as any)._options
if (!options || options.deviceScaleFactor === 2) {
return
}
options.deviceScaleFactor = 2
}
async function preserveSystemColorScheme(context: BrowserContext): Promise<void> {
const options = (context as any)._options
if (!options) {
return
}
options.colorScheme = 'no-override'
options.reducedMotion = 'no-override'
options.forcedColors = 'no-override'
}
function isRegExp(value: any): value is RegExp {
return (
typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'
)
}
function clearUserState() {
Object.keys(userState).forEach((key) => delete userState[key])
}
function clearConnectionState() {
state.isConnected = false
state.browser = null
state.page = null
state.context = null
}
function getLogServerUrl(): string {
const remote = getRemoteConfig()
if (remote) {
return `http://${remote.host}:${remote.port}/mcp-log`
}
return `http://127.0.0.1:${RELAY_PORT}/mcp-log`
}
async function sendLogToRelayServer(level: string, ...args: any[]) {
try {
await fetch(getLogServerUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level, args }),
signal: AbortSignal.timeout(1000),
})
} catch {
// Silently fail if relay server is not available
}
}
async function getServerVersion(port: number): Promise<string | null> {
try {
const response = await fetch(`http://127.0.0.1:${port}/version`, {
signal: AbortSignal.timeout(500),
})
if (!response.ok) {
return null
}
const data = (await response.json()) as { version: string }
return data.version
} catch (error) {
return null
}
}
async function killRelayServer(port: number): Promise<void> {
try {
await killPortProcess(port)
await sleep(500)
} catch {}
}
async function ensureRelayServer(): Promise<void> {
const serverVersion = await getServerVersion(RELAY_PORT)
if (serverVersion === VERSION) {
return
}
if (serverVersion !== null) {
console.error(`CDP relay server version mismatch (server: ${serverVersion}, mcp: ${VERSION}), restarting...`)
await killRelayServer(RELAY_PORT)
} else {
console.error('CDP relay server not running, starting it...')
}
const scriptPath = require.resolve('../dist/start-relay-server.js')
const serverProcess = spawn(process.execPath, [scriptPath], {
detached: true,
stdio: 'ignore',
})
serverProcess.unref()
for (let i = 0; i < 10; i++) {
await sleep(500)
const newVersion = await getServerVersion(RELAY_PORT)
if (newVersion === VERSION) {
console.error('CDP relay server started successfully, waiting for extension to connect...')
await sleep(1000)
return
}
}
throw new Error(`Failed to start CDP relay server after 5 seconds. Check logs at: ${LOG_FILE_PATH}`)
}
async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
if (state.isConnected && state.browser && state.page) {
return { browser: state.browser, page: state.page }
}
const remote = getRemoteConfig()
if (!remote) {
await ensureRelayServer()
}
const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT })
const browser = await chromium.connectOverCDP(cdpEndpoint)
const contexts = browser.contexts()
const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
// Set up console listener for all future pages
context.on('page', (page) => {
setupPageConsoleListener(page)
})
const pages = context.pages()
if (pages.length === 0) {
throw new Error(NO_TABS_ERROR)
}
const page = pages[0]
// Set up console listener for all existing pages
context.pages().forEach((p) => setupPageConsoleListener(p))
// These functions only set context-level options, they do NOT send CDP commands to pages.
// Sending CDP commands (like Emulation.setEmulatedMedia or setDeviceMetricsOverride) to pages
// immediately after connectOverCDP causes pages to render white/blank with about:blank URLs,
// because pages may not be fully initialized yet. Playwright applies these settings lazily.
await preserveSystemColorScheme(context)
await setDeviceScaleFactorForMacOS(context)
state.browser = browser
state.page = page
state.context = context
state.isConnected = true
return { browser, page }
}
async function getPageTargetId(page: Page): Promise<string> {
if (!page) {
throw new Error('Page is null or undefined')
}
const guid = (page as any)._guid
if (guid) {
return guid
}
throw new Error('Could not get page identifier: _guid not available')
}
function setupPageConsoleListener(page: Page) {
// Get targetId synchronously using _guid
const targetId = (page as any)._guid as string | undefined
if (!targetId) {
// If no _guid, silently fail - this shouldn't happen in normal operation
return
}
// Initialize logs array for this page
if (!browserLogs.has(targetId)) {
browserLogs.set(targetId, [])
}
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) {
browserLogs.set(targetId, [])
}
})
page.on('close', () => {
browserLogs.delete(targetId)
})
page.on('console', (msg) => {
try {
let logEntry = `[${msg.type()}] ${msg.text()}`
if (!browserLogs.has(targetId)) {
browserLogs.set(targetId, [])
}
const pageLogs = browserLogs.get(targetId)!
pageLogs.push(logEntry)
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
pageLogs.shift()
}
} catch (e) {
console.error('[MCP] Failed to get console message text:', e)
return
}
})
}
async function getCurrentPage(timeout = 5000) {
if (state.page) {
return state.page
}
if (state.browser) {
const contexts = state.browser.contexts()
if (contexts.length > 0) {
const pages = contexts[0].pages()
if (pages.length > 0) {
const page = pages[0]
await page.waitForLoadState('load', { timeout }).catch(() => {})
return page
}
}
}
throw new Error(NO_TABS_ERROR)
}
async function resetConnection(): Promise<{ browser: Browser; page: Page; context: BrowserContext }> {
if (state.browser) {
try {
await state.browser.close()
} catch (e) {
console.error('Error closing browser:', e)
}
}
clearConnectionState()
clearUserState()
// DO NOT clear browser logs on reset - logs should persist across reconnections
// browserLogs.clear()
const remote = getRemoteConfig()
if (!remote) {
await ensureRelayServer()
}
const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT })
const browser = await chromium.connectOverCDP(cdpEndpoint)
const contexts = browser.contexts()
const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
// Set up console listener for all future pages
context.on('page', (page) => {
setupPageConsoleListener(page)
})
const pages = context.pages()
if (pages.length === 0) {
throw new Error(NO_TABS_ERROR)
}
const page = pages[0]
// Set up console listener for all existing pages
context.pages().forEach((p) => setupPageConsoleListener(p))
await preserveSystemColorScheme(context)
await setDeviceScaleFactorForMacOS(context)
state.browser = browser
state.page = page
state.context = context
state.isConnected = true
return { browser, page, context }
}
const server = new McpServer({
name: 'playwriter',
title: 'The better playwright MCP: works as a browser extension. No context bloat. More capable.',
version: '1.0.0',
})
const promptContent =
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'prompt.md'), 'utf-8') +
`\n\nfor debugging internal playwriter errors, check playwriter relay server logs at: ${LOG_FILE_PATH}`
server.resource('debugger-api', 'https://playwriter.dev/resources/debugger-api.md', { mimeType: 'text/plain' }, async () => {
const packageJsonPath = require.resolve('playwriter/package.json')
const packageDir = path.dirname(packageJsonPath)
const content = fs.readFileSync(path.join(packageDir, 'dist', 'debugger-api.md'), 'utf-8')
return {
contents: [
{
uri: 'https://playwriter.dev/resources/debugger-api.md',
text: content,
mimeType: 'text/plain',
},
],
}
})
server.resource('editor-api', 'https://playwriter.dev/resources/editor-api.md', { mimeType: 'text/plain' }, async () => {
const packageJsonPath = require.resolve('playwriter/package.json')
const packageDir = path.dirname(packageJsonPath)
const content = fs.readFileSync(path.join(packageDir, 'dist', 'editor-api.md'), 'utf-8')
return {
contents: [
{
uri: 'https://playwriter.dev/resources/editor-api.md',
text: content,
mimeType: 'text/plain',
},
],
}
})
server.resource('styles-api', 'https://playwriter.dev/resources/styles-api.md', { mimeType: 'text/plain' }, async () => {
const packageJsonPath = require.resolve('playwriter/package.json')
const packageDir = path.dirname(packageJsonPath)
const content = fs.readFileSync(path.join(packageDir, 'dist', 'styles-api.md'), 'utf-8')
return {
contents: [
{
uri: 'https://playwriter.dev/resources/styles-api.md',
text: content,
mimeType: 'text/plain',
},
],
}
})
server.tool(
'execute',
promptContent,
{
code: z
.string()
.describe(
'js playwright code, has {page, state, context} in scope. Should be one line, using ; to execute multiple statements. you MUST call execute multiple times instead of writing complex scripts in a single tool call.',
),
timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'),
},
async ({ code, timeout }) => {
const consoleLogs: Array<{ method: string; args: any[] }> = []
const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => {
if (logs.length === 0) {
return ''
}
let text = `${prefix}:\n`
logs.forEach(({ method, args }) => {
const formattedArgs = args
.map((arg) => {
if (typeof arg === 'object') {
return JSON.stringify(arg, null, 2)
}
return String(arg)
})
.join(' ')
text += `[${method}] ${formattedArgs}\n`
})
return text + '\n'
}
try {
await ensureRelayServer()
await ensureConnection()
const page = await getCurrentPage(timeout)
const context = state.context || page.context()
console.error('Executing code:', code)
const customConsole = {
log: (...args: any[]) => {
consoleLogs.push({ method: 'log', args })
},
info: (...args: any[]) => {
consoleLogs.push({ method: 'info', args })
},
warn: (...args: any[]) => {
consoleLogs.push({ method: 'warn', args })
},
error: (...args: any[]) => {
consoleLogs.push({ method: 'error', args })
},
debug: (...args: any[]) => {
consoleLogs.push({ method: 'debug', args })
},
}
const accessibilitySnapshot = async (options: {
page: Page
search?: string | RegExp
contextLines?: number
showDiffSinceLastCall?: boolean
}) => {
const { page: targetPage, search, contextLines = 10, showDiffSinceLastCall = false } = options
if ((targetPage as any)._snapshotForAI) {
const snapshot = await (targetPage as any)._snapshotForAI()
const snapshotStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)
if (showDiffSinceLastCall) {
const previousSnapshot = lastSnapshots.get(targetPage)
if (!previousSnapshot) {
lastSnapshots.set(targetPage, snapshotStr)
return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.'
}
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', {
context: contextLines,
})
if (patch.split('\n').length <= 4) {
return 'No changes detected since last snapshot'
}
return patch
}
lastSnapshots.set(targetPage, snapshotStr)
if (!search) {
return snapshotStr
}
const lines = snapshotStr.split('\n')
const matches: { line: string; index: number }[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
let isMatch = false
if (isRegExp(search)) {
isMatch = search.test(line)
} else {
isMatch = line.includes(search)
}
if (isMatch) {
matches.push({ line, index: i })
if (matches.length >= 10) break
}
}
if (matches.length === 0) {
return 'No matches found'
}
return matches
.map((m) => {
const start = Math.max(0, m.index - contextLines)
const end = Math.min(lines.length, m.index + contextLines + 1)
return lines.slice(start, end).join('\n')
})
.join('\n\n---\n\n')
}
throw new Error('accessibilitySnapshot is not available on this page')
}
const getLocatorStringForElement = async (element: any) => {
if (!element || typeof element.evaluate !== 'function') {
throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle')
}
const elementPage = element.page ? element.page() : page
const hasGenerator = await elementPage.evaluate(() => !!(globalThis as any).__selectorGenerator)
if (!hasGenerator) {
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const scriptPath = path.join(currentDir, '..', 'dist', 'selector-generator.js')
const scriptContent = fs.readFileSync(scriptPath, 'utf-8')
const cdp = await getCDPSession({ page: elementPage })
await cdp.send('Runtime.evaluate', { expression: scriptContent })
}
return await element.evaluate((el: any) => {
const { createSelectorGenerator, toLocator } = (globalThis as any).__selectorGenerator
const generator = createSelectorGenerator(globalThis)
const result = generator(el)
return toLocator(result.selector, 'javascript')
})
}
const getLatestLogs = async (options?: { page?: Page; count?: number; search?: string | RegExp }) => {
const { page: filterPage, count, search } = options || {}
let allLogs: string[] = []
if (filterPage) {
const targetId = await getPageTargetId(filterPage)
const pageLogs = browserLogs.get(targetId) || []
allLogs = [...pageLogs]
} else {
for (const pageLogs of browserLogs.values()) {
allLogs.push(...pageLogs)
}
}
if (search) {
allLogs = allLogs.filter((log) => {
if (typeof search === 'string') {
return log.includes(search)
} else if (isRegExp(search)) {
return search.test(log)
}
return false
})
}
return count !== undefined ? allLogs.slice(-count) : allLogs
}
const clearAllLogs = () => {
browserLogs.clear()
}
const getCDPSession = async (options: { page: Page }) => {
const cached = cdpSessionCache.get(options.page)
if (cached) {
return cached
}
const wsUrl = getCdpUrl(getRemoteConfig() || { port: RELAY_PORT })
const session = await getCDPSessionForPage({ page: options.page, wsUrl })
cdpSessionCache.set(options.page, session)
return session
}
const createDebugger = (options: { cdp: ICDPSession }) => {
return new Debugger(options)
}
const createEditor = (options: { cdp: ICDPSession }) => {
return new Editor(options)
}
const getStylesForLocatorFn = async (options: { locator: any }) => {
const cdp = await getCDPSession({ page: options.locator.page() })
return getStylesForLocator({ locator: options.locator, cdp })
}
const getReactSourceFn = async (options: { locator: any }) => {
const cdp = await getCDPSession({ page: options.locator.page() })
return getReactSource({ locator: options.locator, cdp })
}
let vmContextObj: VMContextWithGlobals = {
page,
context,
state: userState,
console: customConsole,
accessibilitySnapshot,
getLocatorStringForElement,
getLatestLogs,
clearAllLogs,
waitForPageLoad,
getCDPSession,
createDebugger,
createEditor,
getStylesForLocator: getStylesForLocatorFn,
formatStylesAsText,
getReactSource: getReactSourceFn,
resetPlaywright: async () => {
const { page: newPage, context: newContext } = await resetConnection()
const resetObj: VMContextWithGlobals = {
page: newPage,
context: newContext,
state: userState,
console: customConsole,
accessibilitySnapshot,
getLocatorStringForElement,
getLatestLogs,
clearAllLogs,
waitForPageLoad,
getCDPSession,
createDebugger,
createEditor,
getStylesForLocator: getStylesForLocatorFn,
formatStylesAsText,
getReactSource: getReactSourceFn,
resetPlaywright: vmContextObj.resetPlaywright,
require,
// TODO --experimental-vm-modules is needed to make import work in vm
import: vmContextObj.import,
...usefulGlobals,
}
Object.keys(vmContextObj).forEach((key) => delete (vmContextObj as any)[key])
Object.assign(vmContextObj, resetObj)
return { page: newPage, context: newContext }
},
require,
import: (specifier: string) => import(specifier),
...usefulGlobals,
}
const vmContext = vm.createContext(vmContextObj)
const wrappedCode = `(async () => { ${code} })()`
const result = await Promise.race([
vm.runInContext(wrappedCode, vmContext, {
timeout,
displayErrors: true,
}),
new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
])
let responseText = formatConsoleLogs(consoleLogs)
if (result !== undefined) {
responseText += 'Return value:\n'
if (typeof result === 'string') {
responseText += result
} else {
responseText += JSON.stringify(result, null, 2)
}
} else if (consoleLogs.length === 0) {
responseText += 'Code executed successfully (no output)'
}
const MAX_LENGTH = 6000
let finalText = responseText.trim()
if (finalText.length > MAX_LENGTH) {
finalText =
finalText.slice(0, MAX_LENGTH) +
`\n\n[Truncated to ${MAX_LENGTH} characters. Better manage your logs or paginate them to read the full logs]`
}
return {
content: [
{
type: 'text',
text: finalText,
},
],
}
} catch (error: any) {
const errorStack = error.stack || error.message
console.error('Error in execute tool:', errorStack)
const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
if (!isTimeoutError) {
sendLogToRelayServer('error', '[MCP] Error:', errorStack)
}
const resetHint = isTimeoutError
? ''
: '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]'
return {
content: [
{
type: 'text',
text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
},
],
isError: true,
}
}
},
)
server.tool(
'reset',
dedent`
Recreates the CDP connection and resets the browser/page/context. Use this when the MCP stops responding, you get connection errors, if there are no pages in context, assertion failures, page closed, or other issues.
After calling this tool, the page and context variables are automatically updated in the execution environment.
This tools also removes any custom properties you may have added to the global scope AND clearing all keys from the \`state\` object. Only \`page\`, \`context\`, \`state\` (empty), \`console\`, and utility functions will remain.
if playwright always returns all pages as about:blank urls and evaluate does not work you should ask the user to restart Chrome. This is a known Chrome bug.
`,
{},
async () => {
try {
const { page, context } = await resetConnection()
const pagesCount = context.pages().length
return {
content: [
{
type: 'text',
text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}`,
},
],
}
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Failed to reset connection: ${error.message}`,
},
],
isError: true,
}
}
},
)
async function checkRemoteServer({ host, port }: { host: string; port: number }): Promise<void> {
const versionUrl = `http://${host}:${port}/version`
try {
const response = await fetch(versionUrl, { signal: AbortSignal.timeout(3000) })
if (!response.ok) {
throw new Error(`Server responded with status ${response.status}`)
}
} catch (error: any) {
const isConnectionError = error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError'
if (isConnectionError) {
throw new Error(
`Cannot connect to remote relay server at ${host}:${port}. ` +
`Make sure 'npx playwriter serve' is running on the host machine.`,
)
}
throw new Error(`Failed to connect to remote relay server: ${error.message}`)
}
}
export async function startMcp(options: { host?: string; token?: string } = {}) {
if (options.host) {
process.env.PLAYWRITER_HOST = options.host
}
if (options.token) {
process.env.PLAYWRITER_TOKEN = options.token
}
const remote = getRemoteConfig()
if (!remote) {
await ensureRelayServer()
} else {
console.error(`Using remote CDP relay server: ${remote.host}:${remote.port}`)
await checkRemoteServer(remote)
}
const transport = new StdioServerTransport()
await server.connect(transport)
}