Skip to main content
Glama
index.js35.3 kB
#!/usr/bin/env node // src/index.ts import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Command, InvalidArgumentError } from "commander"; import { randomUUID as randomUUID2 } from "crypto"; import { once } from "events"; import { createServer } from "http"; import { createRequire } from "module"; // src/bridge.ts import { randomUUID } from "crypto"; import { WebSocketServer, WebSocket } from "ws"; var DEFAULT_TIMEOUT_MS = 3e4; var ExtensionBridge = class { options; wss; socket; pending = /* @__PURE__ */ new Map(); hello; constructor(options) { this.options = { requestTimeoutMs: DEFAULT_TIMEOUT_MS, ...options }; } async start() { if (this.wss) { return; } const wss = new WebSocketServer({ port: this.options.port }); this.wss = wss; wss.on("connection", (socket, request) => this.handleConnection(socket, request)); const listenPromise = new Promise((resolve, reject) => { const handleError = (error) => { const err = error; if (err?.code === "EADDRINUSE") { reject( new Error( `WebSocket port ${this.options.port} is already in use. Another YetiBrowser MCP instance might be running. Use --ws-port to pick a different port.` ) ); } else { reject(err instanceof Error ? err : new Error(String(err))); } }; wss.once("error", handleError); wss.once("listening", () => { wss.off("error", handleError); resolve(); }); }); try { await listenPromise; } catch (error) { this.wss = void 0; wss.removeAllListeners(); try { wss.close(); } catch (closeError) { console.error("Failed to close WebSocket server after startup error", closeError); } throw error; } wss.on("error", (error) => { console.error("WebSocket server error", error); }); console.error(`[yetibrowser] Waiting for extension on ws://localhost:${this.options.port}`); } isConnected() { return !!this.socket && this.socket.readyState === WebSocket.OPEN; } getHelloInfo() { return this.hello; } getPort() { return this.options.port; } async close() { this.rejectAllPending(new Error("Extension bridge shutting down")); if (this.socket) { try { this.socket.terminate(); } catch (error) { console.error("Failed to terminate WebSocket", error); } this.socket.removeAllListeners(); this.socket = void 0; } if (this.wss) { await new Promise((resolve) => this.wss?.close(() => resolve())); this.wss.removeAllListeners(); this.wss = void 0; } } async send(command, payload) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { throw new Error( "YetiBrowser extension not connected. Click the extension icon and connect a tab before using this tool." ); } const id = randomUUID(); const message = { type: "call", id, command, payload }; const raw = JSON.stringify(message); return await new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pending.delete(id); reject(new Error(`Extension response timed out for command "${command}"`)); }, this.options.requestTimeoutMs); this.pending.set(id, { resolve: (value) => resolve(value), reject, timeout, command }); try { this.socket.send(raw); } catch (error) { clearTimeout(timeout); this.pending.delete(id); reject(error instanceof Error ? error : new Error(String(error))); } }); } handleConnection(socket, request) { if (this.socket) { console.warn("Existing extension connection detected. Closing previous socket."); this.socket.terminate(); this.rejectAllPending(new Error("Previous connection was replaced by a new socket")); } this.socket = socket; console.error(`[yetibrowser] Extension connected from ${request.socket.remoteAddress ?? "unknown"}`); socket.on("message", (data) => this.handleMessage(data)); socket.on("error", (error) => { console.error("Extension socket error", error); this.rejectAllPending(new Error("Extension socket error")); }); socket.on("close", () => { console.error("[yetibrowser] Extension disconnected"); this.socket = void 0; this.rejectAllPending(new Error("Extension disconnected")); }); } handleMessage(data) { let message; try { message = JSON.parse(data.toString()); } catch (error) { console.error("Failed to parse message from extension", error); return; } if (message.type === "hello") { this.hello = { client: message.client, version: message.version }; console.error( `[yetibrowser] Extension hello from ${message.client}${message.version ? ` v${message.version}` : ""}` ); return; } if (message.type === "event") { console.error("[yetibrowser] extension event", message.event, message.payload); return; } if (message.type === "result") { const pending = this.pending.get(message.id); if (!pending) { console.warn(`Received result for unknown id ${message.id}`); return; } const { resolve, reject, timeout, command } = pending; clearTimeout(timeout); this.pending.delete(message.id); if (!message.ok) { reject(new Error(message.error ?? `Command "${command}" failed`)); return; } resolve(message.result); return; } console.warn("Received unsupported message from extension", message); } rejectAllPending(error) { for (const [id, pending] of this.pending.entries()) { clearTimeout(pending.timeout); pending.reject(error); this.pending.delete(id); } } }; // src/server.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js"; // src/context.ts var ExtensionContext = class { constructor(bridge) { this.bridge = bridge; } snapshotHistory = []; async call(command, payload = void 0) { const finalPayload = payload ?? {}; return await this.bridge.send(command, finalPayload); } async captureSnapshot(statusMessage = "") { const [{ url }, { title }, snapshotResult] = await Promise.all([ this.call("getUrl"), this.call("getTitle"), this.call("snapshot") ]); const record = { capturedAt: snapshotResult.raw.capturedAt, message: statusMessage, snapshot: snapshotResult.raw, formatted: snapshotResult.formatted, url, title }; this.snapshotHistory.push(record); if (this.snapshotHistory.length > 20) { this.snapshotHistory.shift(); } const index = this.snapshotHistory.length; const statusLines = [statusMessage, `Snapshot #${index} captured at ${record.capturedAt}`].filter(Boolean).join("\n"); const prefix = statusLines ? `${statusLines} ` : ""; const text = `${prefix}- Page URL: ${url} - Page Title: ${title} - Page Snapshot \`\`\`yaml ${snapshotResult.formatted} \`\`\` `; return { content: [ { type: "text", text } ] }; } async diffLatestSnapshots() { if (this.snapshotHistory.length < 2) { return { content: [ { type: "text", text: "At least two snapshots are required to compute a diff. Capture another snapshot first." } ], isError: true }; } const current = this.snapshotHistory.at(-1); const previous = this.snapshotHistory.at(-2); const diff = diffSnapshots(previous.snapshot, current.snapshot); const summaryLines = []; summaryLines.push( `Diffing snapshot captured ${current.capturedAt} (Snapshot #${this.snapshotHistory.length}) against ${previous.capturedAt}` ); summaryLines.push(`Current URL: ${current.url}`); if (current.url !== previous.url) { summaryLines.push(`Previous URL: ${previous.url}`); } summaryLines.push("Summary:"); summaryLines.push(`- Added elements: ${diff.added.length}`); summaryLines.push(`- Removed elements: ${diff.removed.length}`); summaryLines.push(`- Changed elements: ${diff.changed.length}`); const formatEntry = (entry) => `selector: ${entry.selector} role: ${entry.role} name: ${entry.name}`; if (diff.added.length) { summaryLines.push("Added:"); for (const entry of diff.added.slice(0, 5)) { summaryLines.push(` - ${entry.selector} (${entry.role}) \u2192 "${entry.name}"`); } if (diff.added.length > 5) { summaryLines.push(` - \u2026 ${diff.added.length - 5} more`); } } if (diff.removed.length) { summaryLines.push("Removed:"); for (const entry of diff.removed.slice(0, 5)) { summaryLines.push(` - ${entry.selector} (${entry.role}) \u2192 "${entry.name}"`); } if (diff.removed.length > 5) { summaryLines.push(` - \u2026 ${diff.removed.length - 5} more`); } } if (diff.changed.length) { summaryLines.push("Changed:"); for (const change of diff.changed.slice(0, 5)) { summaryLines.push( ` - ${change.selector} before: role=${change.before.role}, name="${change.before.name}" after: role=${change.after.role}, name="${change.after.name}"` ); } if (diff.changed.length > 5) { summaryLines.push(` - \u2026 ${diff.changed.length - 5} more`); } } if (!diff.added.length && !diff.removed.length && !diff.changed.length) { summaryLines.push("No element-level differences detected."); } return { content: [ { type: "text", text: summaryLines.join("\n") } ] }; } getConnectionInfo() { return { wsPort: this.bridge.getPort(), connected: this.bridge.isConnected(), extension: this.bridge.getHelloInfo() }; } }; function diffSnapshots(previous, current) { const prevMap = /* @__PURE__ */ new Map(); const currentMap = /* @__PURE__ */ new Map(); for (const entry of previous.entries) { if (!prevMap.has(entry.selector)) { prevMap.set(entry.selector, entry); } } for (const entry of current.entries) { if (!currentMap.has(entry.selector)) { currentMap.set(entry.selector, entry); } } const added = []; const removed = []; const changed = []; for (const [selector, entry] of currentMap.entries()) { const previousEntry = prevMap.get(selector); if (!previousEntry) { added.push(entry); continue; } if (previousEntry.role !== entry.role || previousEntry.name !== entry.name) { changed.push({ selector, before: previousEntry, after: entry }); } } for (const [selector, entry] of prevMap.entries()) { if (!currentMap.has(selector)) { removed.push(entry); } } return { added, removed, changed }; } // src/tools.ts import zodToJsonSchema from "zod-to-json-schema"; import { z } from "zod"; var ElementTargetSchema = z.object({ selector: z.string().min(1).describe("CSS selector that uniquely identifies the target element"), description: z.string().optional().describe("Optional human-readable description of the element") }); var NavigateSchema = z.object({ url: z.string().describe("The URL to navigate to") }); var WaitSchema = z.object({ seconds: z.number().min(0).describe("The time to wait in seconds") }); var PressKeySchema = z.object({ key: z.string().min(1).describe("Name of the key to press or a character to generate, such as `ArrowLeft` or `a`") }); var TypeSchema = ElementTargetSchema.extend({ text: z.string().describe("Text to type into the element"), submit: z.boolean().default(false).describe("Whether to submit entered text (press Enter after)") }); var SelectOptionSchema = ElementTargetSchema.extend({ values: z.array(z.string()).nonempty().describe( "Array of values to select in the dropdown. This can be a single value or multiple values." ) }); var ScreenshotSchema = z.object({ fullPage: z.boolean().optional().describe("Whether to capture the full page instead of the visible viewport") }); var WaitForSelectorSchema = z.object({ selector: z.string().min(1).describe("CSS selector to wait for"), timeoutMs: z.number().int().min(0).max(12e4).optional().describe("Optional timeout in milliseconds. Defaults to 5000ms."), visible: z.boolean().optional().describe("When true, wait until the element is visible (non-zero size and not hidden)") }); var FormFieldValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); var FormFieldSchema = z.object({ selector: z.string().min(1).describe("CSS selector for the form control to update"), value: FormFieldValueSchema.optional().describe("Value to apply to the field (string/number/boolean)"), values: z.array(z.string()).nonempty().optional().describe("List of values to select for multi-select inputs"), submit: z.boolean().optional().describe("Submit the containing form after setting this field"), description: z.string().optional().describe("Optional description to help debugging errors"), type: z.enum(["auto", "text", "textarea", "select", "checkbox", "radio", "contentEditable"]).optional().describe("Override automatic element detection") }).refine((data) => typeof data.value !== "undefined" || data.values, { message: "Provide either a value or values for each form field", path: ["value"] }); var FillFormSchema = z.object({ fields: z.array(FormFieldSchema).min(1) }); var EvaluateSchema = z.object({ script: z.string().min(1).describe("JavaScript function expression to execute in the page context, e.g. `(el) => el.textContent`"), args: z.array(z.any()).optional().describe("Optional array of arguments passed to the function"), timeoutMs: z.number().int().min(0).max(12e4).optional().describe("Optional timeout in milliseconds. Defaults to no timeout.") }); var HandleDialogSchema = z.object({ action: z.enum(["accept", "dismiss"]).describe("Whether to accept or dismiss the active JavaScript dialog"), promptText: z.string().optional().describe("Optional text to enter into prompt dialogs before accepting") }); var DragSchema = z.object({ fromSelector: z.string().min(1).describe("CSS selector for the element to start the drag from"), toSelector: z.string().min(1).describe("CSS selector for the element to drop onto"), steps: z.number().int().min(1).max(200).optional().describe("Optional number of intermediate drag steps. Defaults to 12."), description: z.string().optional().describe("Optional human-readable description of the drag target") }); function noInputSchema() { return zodToJsonSchema(z.object({})); } function buildSnapshotTool(name, description, command) { const isNavigate = command === "navigate"; const inputSchema = isNavigate ? zodToJsonSchema(NavigateSchema) : noInputSchema(); return { schema: { name, description, inputSchema }, handle: async (context, params) => { if (isNavigate) { const { url } = NavigateSchema.parse(params); await context.call("navigate", { url }); return context.captureSnapshot(`Navigated to ${url}`); } if (command === "goBack") { await context.call("goBack", {}); return context.captureSnapshot("Navigated back"); } await context.call("goForward", {}); return context.captureSnapshot("Navigated forward"); } }; } function createTools() { const snapshotTool = { schema: { name: "browser_snapshot", description: "Capture accessibility snapshot of the current page. Use this for getting references to elements to interact with.", inputSchema: noInputSchema() }, handle: async (context) => { return context.captureSnapshot(); } }; const snapshotDiffTool = { schema: { name: "browser_snapshot_diff", description: "Compare the most recent snapshot with the previous one to highlight DOM changes", inputSchema: noInputSchema() }, handle: async (context) => { return context.diffLatestSnapshots(); } }; const waitTool = { schema: { name: "browser_wait", description: "Wait for a specified time in seconds", inputSchema: zodToJsonSchema(WaitSchema) }, handle: async (context, params) => { const { seconds } = WaitSchema.parse(params); await context.call("wait", { seconds }); return { content: [ { type: "text", text: `Waited for ${seconds} seconds` } ] }; } }; const waitForTool = { schema: { name: "browser_wait_for", description: "Wait until a selector appears (optionally visible) before continuing. Returns a fresh snapshot after the element is detected.", inputSchema: zodToJsonSchema(WaitForSelectorSchema) }, handle: async (context, params) => { const { selector, timeoutMs, visible } = WaitForSelectorSchema.parse(params); await context.call("waitFor", { selector, timeoutMs, visible }); const parts = [ `Waited for selector "${selector}".`, visible ? "Required element to be visible." : void 0, typeof timeoutMs === "number" ? `Timeout: ${timeoutMs}ms.` : void 0 ].filter(Boolean); parts.push("Call `browser_snapshot` if you need a refreshed DOM listing."); return { content: [ { type: "text", text: parts.join(" ") } ] }; } }; const pressKeyTool = { schema: { name: "browser_press_key", description: "Press a key on the keyboard", inputSchema: zodToJsonSchema(PressKeySchema) }, handle: async (context, params) => { const { key } = PressKeySchema.parse(params); await context.call("pressKey", { key }); return { content: [ { type: "text", text: `Pressed key ${key}` } ] }; } }; const clickTool = { schema: { name: "browser_click", description: "Perform click on a web page", inputSchema: zodToJsonSchema(ElementTargetSchema) }, handle: async (context, params) => { const parsed = ElementTargetSchema.parse(params); await context.call("click", { selector: parsed.selector, description: parsed.description }); return context.captureSnapshot( `Clicked "${parsed.description ?? parsed.selector}"` ); } }; const hoverTool = { schema: { name: "browser_hover", description: "Hover over element on page", inputSchema: zodToJsonSchema(ElementTargetSchema) }, handle: async (context, params) => { const parsed = ElementTargetSchema.parse(params); await context.call("hover", { selector: parsed.selector, description: parsed.description }); return context.captureSnapshot( `Hovered over "${parsed.description ?? parsed.selector}"` ); } }; const dragTool = { schema: { name: "browser_drag", description: "Drag an element (like cards in a kanban board) onto a target element. Useful for sortable UIs.", inputSchema: zodToJsonSchema(DragSchema) }, handle: async (context, params) => { const { fromSelector, toSelector, steps, description } = DragSchema.parse(params); await context.call("drag", { fromSelector, toSelector, steps, description }); const summary = [ `Dragged "${fromSelector}" onto "${toSelector}".`, typeof steps === "number" ? `Steps: ${steps}.` : void 0, description ? `Context: ${description}.` : void 0, "Run `browser_snapshot` if you need a DOM snapshot after the drag." ].filter(Boolean).join(" "); return { content: [ { type: "text", text: summary } ] }; } }; const typeTool = { schema: { name: "browser_type", description: "Type text into editable element", inputSchema: zodToJsonSchema(TypeSchema) }, handle: async (context, params) => { const { selector, text, submit, description } = TypeSchema.parse(params); await context.call("type", { selector, text, submit, description }); return context.captureSnapshot( `Typed "${text}" into "${description ?? selector}"` ); } }; const fillFormTool = { schema: { name: "browser_fill_form", description: "Fill multiple form fields in a single call. Supports inputs, textareas, selects, checkboxes, and radios.", inputSchema: zodToJsonSchema(FillFormSchema) }, handle: async (context, params) => { const { fields } = FillFormSchema.parse(params); const result = await context.call("fillForm", { fields }); const summaryLines = [ `Filled ${result.filled}/${result.attempted} fields.`, result.errors.length ? `Issues: ${result.errors.map((err) => `- ${err}`).join("\n")}` : "No validation errors reported.", "Call `browser_snapshot` if you need to inspect the page state after filling." ]; return { content: [ { type: "text", text: summaryLines.join("\n") } ] }; } }; const selectOptionTool = { schema: { name: "browser_select_option", description: "Select an option in a dropdown", inputSchema: zodToJsonSchema(SelectOptionSchema) }, handle: async (context, params) => { const { selector, values, description } = SelectOptionSchema.parse(params); await context.call("selectOption", { selector, values, description }); return context.captureSnapshot( `Selected option in "${description ?? selector}"` ); } }; const screenshotTool = { schema: { name: "browser_screenshot", description: "Take a screenshot of the current page", inputSchema: zodToJsonSchema(ScreenshotSchema) }, handle: async (context, params) => { const { fullPage } = ScreenshotSchema.parse(params); const result = await context.call("screenshot", { fullPage }); return { content: [ { type: "image", data: result.data, mimeType: result.mimeType } ] }; } }; const consoleLogsTool = { schema: { name: "browser_get_console_logs", description: "Get the console logs from the browser", inputSchema: noInputSchema() }, handle: async (context) => { const logs = await context.call("getConsoleLogs", {}); const text = logs.map((log) => { const time = new Date(log.timestamp).toISOString(); const lines = [`[${time}] [${log.level}] ${log.message}`]; if (log.stack) { lines.push(log.stack); } return lines.join("\n"); }).join("\n\n"); return { content: [ { type: "text", text: text || "No console output captured" } ] }; } }; const pageStateTool = { schema: { name: "browser_page_state", description: "Extract form data, storage values, and cookies from the active page", inputSchema: noInputSchema() }, handle: async (context) => { const state = await context.call("pageState", {}); const summary = [ `Page state captured ${state.capturedAt}`, `- Forms inspected: ${state.forms.length}`, `- localStorage keys: ${state.localStorage.length}`, `- sessionStorage keys: ${state.sessionStorage.length}`, `- Cookies: ${state.cookies.length}`, "", "```json", JSON.stringify(state, null, 2), "```" ].join("\n"); return { content: [ { type: "text", text: summary } ] }; } }; const evaluateTool = { schema: { name: "browser_evaluate", description: "Run custom JavaScript inside the page context and return the JSON-serializable result.", inputSchema: zodToJsonSchema(EvaluateSchema) }, handle: async (context, params) => { const { script, args, timeoutMs } = EvaluateSchema.parse(params); const { value } = await context.call("evaluate", { script, args, timeoutMs }); let formatted; try { formatted = JSON.stringify(value, null, 2); } catch (error) { formatted = String(value); } const MAX_OUTPUT = 4e3; if (formatted.length > MAX_OUTPUT) { formatted = `${formatted.slice(0, MAX_OUTPUT)}\u2026 (truncated)`; } return { content: [ { type: "text", text: `Evaluation result: \`\`\`json ${formatted} \`\`\`` } ] }; } }; const handleDialogTool = { schema: { name: "browser_handle_dialog", description: "Accept or dismiss the currently open alert/confirm/prompt dialog in the active tab.", inputSchema: zodToJsonSchema(HandleDialogSchema) }, handle: async (context, params) => { const { action, promptText } = HandleDialogSchema.parse(params); await context.call("handleDialog", { action, promptText }); const summary = `Dialog ${action === "accept" ? "accepted" : "dismissed"}${promptText ? ` with prompt text "${promptText}"` : ""}.`; return { content: [ { type: "text", text: summary } ] }; } }; const connectionInfoTool = { schema: { name: "browser_connection_info", description: "Show the MCP bridge WebSocket port, connection state, and extension info", inputSchema: noInputSchema() }, handle: async (context) => { const info = context.getConnectionInfo(); const lines = [ `WebSocket port: ${info.wsPort}`, `Extension connected: ${info.connected ? "yes" : "no"}` ]; if (info.extension) { const versionSuffix = info.extension.version ? ` v${info.extension.version}` : ""; lines.push(`Extension hello: ${info.extension.client}${versionSuffix}`); } return { content: [ { type: "text", text: lines.join("\n") } ] }; } }; return [ snapshotTool, snapshotDiffTool, buildSnapshotTool("browser_navigate", "Navigate to a URL", "navigate"), buildSnapshotTool("browser_go_back", "Go back to the previous page", "goBack"), buildSnapshotTool("browser_go_forward", "Go forward to the next page", "goForward"), waitTool, waitForTool, pressKeyTool, clickTool, hoverTool, dragTool, typeTool, fillFormTool, selectOptionTool, screenshotTool, consoleLogsTool, pageStateTool, evaluateTool, handleDialogTool, connectionInfoTool ]; } // src/server.ts async function createMcpServer(options) { const { name, version, bridge } = options; const context = new ExtensionContext(bridge); const tools = createTools(); const server = new Server( { name, version }, { capabilities: { tools: {}, resources: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: tools.map((tool) => tool.schema) }; }); server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [] }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const tool = tools.find((candidate) => candidate.schema.name === request.params.name); if (!tool) { const response = { content: [ { type: "text", text: `Tool "${request.params.name}" not found` } ], isError: true }; return response; } try { const result = await tool.handle(context, request.params.arguments ?? {}); return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); const response = { content: [ { type: "text", text: message } ], isError: true }; return response; } }); server.setRequestHandler(ReadResourceRequestSchema, async () => ({ contents: [] })); return server; } // src/index.ts var require2 = createRequire(import.meta.url); var packageJson = require2("../package.json"); var AUTO_WS_PORTS = [9010, 9011, 9012, 9013, 9014, 9015, 9016, 9017, 9018, 9019, 9020]; function parsePort(value) { const port = Number(value); if (!Number.isInteger(port) || port <= 0 || port > 65535) { throw new InvalidArgumentError("Port must be an integer between 1 and 65535"); } return port; } function buildPortCandidates(preferred) { const index = AUTO_WS_PORTS.indexOf(preferred); if (index === -1) { return [preferred]; } return [...AUTO_WS_PORTS.slice(index), ...AUTO_WS_PORTS.slice(0, index)]; } async function startBridgeWithFallback(portCandidates) { let lastError; for (const candidatePort of portCandidates) { const candidateBridge = new ExtensionBridge({ port: candidatePort }); try { await candidateBridge.start(); if (candidatePort !== portCandidates[0]) { console.error(`[yetibrowser] WebSocket port ${portCandidates[0]} busy, switched to ${candidatePort}`); } return { bridge: candidateBridge, port: candidatePort }; } catch (error) { lastError = error; await candidateBridge.close().catch(() => { }); if (error instanceof Error && error.message.includes("already in use")) { continue; } const message2 = error instanceof Error ? error.message : String(error); console.error(`[yetibrowser] Failed to start WebSocket bridge: ${message2}`); process.exit(1); } } const message = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error"); console.error(`[yetibrowser] Failed to find available WebSocket port near ${portCandidates[0]}: ${message}`); process.exit(1); } var program = new Command(); program.name("yetibrowser-mcp").version(packageJson.version).description("YetiBrowser MCP server").option( "--ws-port <port>", "WebSocket port exposed for the browser extension", parsePort, 9010 ).option( "--http-port <port>", "Optional Streamable HTTP endpoint for sharing the server across multiple MCP clients", parsePort ).action( async ({ wsPort, httpPort }) => { const portCandidates = buildPortCandidates(wsPort); const { bridge, port: activeWsPort } = await startBridgeWithFallback(portCandidates); wsPort = activeWsPort; const server = await createMcpServer({ name: "YetiBrowser MCP", version: packageJson.version, bridge }); let httpServer; let httpTransport; let stdioTransport; let shuttingDown = false; const shutdown = async () => { if (shuttingDown) { return; } shuttingDown = true; console.error("[yetibrowser] shutting down"); const tasks = [server.close(), bridge.close()]; if (httpTransport) { tasks.push(httpTransport.close()); } if (httpServer) { tasks.push( new Promise((resolve) => { httpServer.close(() => resolve()); }) ); } if (stdioTransport) { tasks.push(stdioTransport.close()); } await Promise.allSettled(tasks); process.exit(0); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); if (httpPort !== void 0) { httpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID2(), enableJsonResponse: true }); httpTransport.onerror = (error) => { console.error("[yetibrowser] HTTP transport error", error); }; await server.connect(httpTransport); httpServer = createServer(async (req, res) => { try { if (!req.url) { res.writeHead(400).end("Missing request URL"); return; } const requestUrl = new URL(req.url, `http://${req.headers.host ?? `localhost:${httpPort}`}`); if (requestUrl.pathname !== "/mcp") { res.writeHead(404).end("Not Found"); return; } const acceptHeader = req.headers.accept; if (typeof acceptHeader === "string") { const parts = acceptHeader.split(",").map((value) => value.trim()); if (!parts.includes("text/event-stream")) { parts.push("text/event-stream"); } if (!parts.includes("application/json")) { parts.unshift("application/json"); } req.headers.accept = parts.join(", "); } else if (Array.isArray(acceptHeader)) { const headerValues = acceptHeader; const combined = headerValues.join(","); const entries = combined.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0); const parts = new Set(entries); parts.add("application/json"); parts.add("text/event-stream"); req.headers.accept = Array.from(parts).join(", "); } else { req.headers.accept = "application/json, text/event-stream"; } await httpTransport.handleRequest(req, res); } catch (error) { console.error("[yetibrowser] Failed to handle HTTP request", error); if (!res.headersSent) { res.writeHead(500).end("Internal Server Error"); } else { res.end(); } } }); const listenPromise = Promise.race([ once(httpServer, "listening"), once(httpServer, "error").then(([error]) => { throw error; }) ]); httpServer.listen(httpPort, "127.0.0.1"); try { await listenPromise; } catch (error) { const err = error; if (err?.code === "EADDRINUSE") { console.error( `[yetibrowser] Failed to start HTTP transport: port ${httpPort} is already in use. Pick a different --http-port value.` ); } else { console.error("[yetibrowser] Failed to start HTTP transport", err); } await shutdown(); return; } console.error(`[yetibrowser] Streamable HTTP endpoint ready at http://127.0.0.1:${httpPort}/mcp`); process.stdin.on("close", shutdown); } else { stdioTransport = new StdioServerTransport(); await server.connect(stdioTransport); process.stdin.on("close", shutdown); } } ); program.parse();

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/yetidevworks/yetibrowser-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server