From 6d7662657e62fd6a05280cbab477d521b1a5628a Mon Sep 17 00:00:00 2001 From: Namu <535248+namukang@users.noreply.github.com> Date: Thu, 27 Mar 2025 21:04:11 -0500 Subject: [PATCH] extract mcp server from monorepo --- .gitignore | 36 ++++++++++ README.md | 10 ++- package.json | 40 +++++++++++ src/context.ts | 52 ++++++++++++++ src/index.ts | 66 ++++++++++++++++++ src/resources/resource.ts | 20 ++++++ src/server.ts | 92 +++++++++++++++++++++++++ src/tools/common.ts | 120 ++++++++++++++++++++++++++++++++ src/tools/custom.ts | 25 +++++++ src/tools/snapshot.ts | 136 +++++++++++++++++++++++++++++++++++++ src/tools/tool.ts | 28 ++++++++ src/utils/aria-snapshot.ts | 26 +++++++ src/utils/log.ts | 8 +++ src/utils/port.ts | 23 +++++++ src/ws.ts | 17 +++++ tsconfig.json | 19 ++++++ 16 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/context.ts create mode 100644 src/index.ts create mode 100644 src/resources/resource.ts create mode 100644 src/server.ts create mode 100644 src/tools/common.ts create mode 100644 src/tools/custom.ts create mode 100644 src/tools/snapshot.ts create mode 100644 src/tools/tool.ts create mode 100644 src/utils/aria-snapshot.ts create mode 100644 src/utils/log.ts create mode 100644 src/utils/port.ts create mode 100644 src/ws.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25a4c82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem diff --git a/README.md b/README.md index 2ba6d83..aa851a3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ -# mcp -Autobrowser MCP is a Model Context Provider (MCP) server that allows AI applications to control your browser +# Browser MCP + +MCP server for Browser MCP. + +- Website: https://browsermcp.io +- Docs: https://docs.browsermcp.io + +This repo contains all the core MCP code for Browser MCP, but currently cannot be built on its own due to dependencies on utils and types from the monorepo where it's developed. diff --git a/package.json b/package.json new file mode 100644 index 0000000..36812be --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "@browsermcp/mcp", + "version": "0.1.0", + "description": "MCP server for browser automation using Browser MCP", + "author": "Browser MCP", + "homepage": "https://browsermcp.io", + "bugs": "https://github.com/browsermcp/mcp/issues", + "type": "module", + "bin": { + "mcp-server-browsermcp": "dist/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsup src/index.ts --format esm && shx chmod +x dist/*.js", + "prepare": "npm run build", + "watch": "tsup src/index.ts --format esm --watch ", + "inspector": "CLIENT_PORT=9001 SERVER_PORT=9002 pnpx @modelcontextprotocol/inspector node dist/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.8.0", + "commander": "^13.1.0", + "ws": "^8.18.1", + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.3" + }, + "devDependencies": { + "@r2r/messaging": "workspace:*", + "@repo/config": "workspace:*", + "@repo/messaging": "workspace:*", + "@repo/types": "workspace:*", + "@repo/utils": "workspace:*", + "@types/ws": "^8.18.0", + "shx": "^0.3.4", + "tsup": "^8.4.0", + "typescript": "^5.6.2" + } +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..3138e89 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,52 @@ +import { createSocketMessageSender } from "@r2r/messaging/ws/sender"; +import { WebSocket } from "ws"; + +import { mcpConfig } from "@repo/config/mcp.config"; +import { MessagePayload, MessageType } from "@repo/messaging/types"; +import { SocketMessageMap } from "@repo/types/messages/ws"; + +const noConnectionMessage = `No connection to browser extension. In order to proceed, you must first connect a tab by clicking the Browser MCP extension icon in the browser toolbar and clicking the 'Connect' button.`; + +export class Context { + private _ws: WebSocket | undefined; + + get ws(): WebSocket { + if (!this._ws) { + throw new Error(noConnectionMessage); + } + return this._ws; + } + + set ws(ws: WebSocket) { + this._ws = ws; + } + + hasWs(): boolean { + return !!this._ws; + } + + async sendSocketMessage>( + type: T, + payload: MessagePayload, + options: { timeoutMs?: number } = { timeoutMs: 30000 } + ) { + const { sendSocketMessage } = createSocketMessageSender( + this.ws + ); + try { + return await sendSocketMessage(type, payload, options); + } catch (e) { + if (e instanceof Error && e.message === mcpConfig.errors.noConnectedTab) { + throw new Error(noConnectionMessage); + } + throw e; + } + } + + async close() { + if (!this._ws) { + return; + } + await this._ws.close(); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3a40565 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { program } from "commander"; + +import { appConfig } from "@repo/config/app.config"; + +import type { Resource } from "@/resources/resource"; +import { createServerWithTools } from "@/server"; +import * as common from "@/tools/common"; +import * as custom from "@/tools/custom"; +import * as snapshot from "@/tools/snapshot"; +import type { Tool } from "@/tools/tool"; + +import packageJSON from "../package.json"; + +function setupExitWatchdog(server: Server) { + process.stdin.on("close", async () => { + setTimeout(() => process.exit(0), 15000); + await server.close(); + process.exit(0); + }); +} + +const commonTools: Tool[] = [common.pressKey, common.wait]; + +const customTools: Tool[] = [custom.getConsoleLogs]; + +const snapshotTools: Tool[] = [ + common.navigate(true), + common.goBack(true), + common.goForward(true), + snapshot.snapshot, + snapshot.click, + snapshot.hover, + snapshot.type, + snapshot.selectOption, + ...commonTools, + ...customTools, +]; + +const resources: Resource[] = []; + +async function createServer(): Promise { + return createServerWithTools({ + name: appConfig.name, + version: packageJSON.version, + tools: snapshotTools, + resources, + }); +} + +/** + * Note: Tools must be defined *before* calling `createServer` because only declarations are hoisted, not the initializations + */ +program + .version("Version " + packageJSON.version) + .name(packageJSON.name) + .action(async () => { + const server = await createServer(); + setupExitWatchdog(server); + + const transport = new StdioServerTransport(); + await server.connect(transport); + }); +program.parse(process.argv); diff --git a/src/resources/resource.ts b/src/resources/resource.ts new file mode 100644 index 0000000..dd122f5 --- /dev/null +++ b/src/resources/resource.ts @@ -0,0 +1,20 @@ +import type { Context } from "../context"; + +export type ResourceSchema = { + uri: string; + name: string; + description?: string; + mimeType?: string; +}; + +export type ResourceResult = { + uri: string; + mimeType?: string; + text?: string; + blob?: string; +}; + +export type Resource = { + schema: ResourceSchema; + read: (context: Context, uri: string) => Promise; +}; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..7c118f0 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,92 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +import { Context } from "@/context"; +import type { Resource } from "@/resources/resource"; +import type { Tool } from "@/tools/tool"; +import { createWebSocketServer } from "@/ws"; + +type Options = { + name: string; + version: string; + tools: Tool[]; + resources: Resource[]; +}; + +export async function createServerWithTools(options: Options): Promise { + const { name, version, tools, resources } = options; + const context = new Context(); + const server = new Server( + { name, version }, + { + capabilities: { + tools: {}, + resources: {}, + }, + }, + ); + + const wss = await createWebSocketServer(); + wss.on("connection", (websocket) => { + // Close any existing connections + if (context.hasWs()) { + context.ws.close(); + } + context.ws = websocket; + }); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: tools.map((tool) => tool.schema) }; + }); + + server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { resources: resources.map((resource) => resource.schema) }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const tool = tools.find((tool) => tool.schema.name === request.params.name); + if (!tool) { + return { + content: [ + { type: "text", text: `Tool "${request.params.name}" not found` }, + ], + isError: true, + }; + } + + try { + const result = await tool.handle(context, request.params.arguments); + return result; + } catch (error) { + return { + content: [{ type: "text", text: String(error) }], + isError: true, + }; + } + }); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const resource = resources.find( + (resource) => resource.schema.uri === request.params.uri, + ); + if (!resource) { + return { contents: [] }; + } + + const contents = await resource.read(context, request.params.uri); + return { contents }; + }); + + server.close = async () => { + await server.close(); + await wss.close(); + await context.close(); + }; + + return server; +} diff --git a/src/tools/common.ts b/src/tools/common.ts new file mode 100644 index 0000000..3dae834 --- /dev/null +++ b/src/tools/common.ts @@ -0,0 +1,120 @@ +import { zodToJsonSchema } from "zod-to-json-schema"; + +import { + GoBackTool, + GoForwardTool, + NavigateTool, + PressKeyTool, + WaitTool, +} from "@repo/types/mcp/tool"; + +import { captureAriaSnapshot } from "@/utils/aria-snapshot"; + +import type { Tool, ToolFactory } from "./tool"; + +export const navigate: ToolFactory = (snapshot) => ({ + schema: { + name: NavigateTool.shape.name.value, + description: NavigateTool.shape.description.value, + inputSchema: zodToJsonSchema(NavigateTool.shape.arguments), + }, + handle: async (context, params) => { + const { url } = NavigateTool.shape.arguments.parse(params); + await context.sendSocketMessage("browser_navigate", { url }); + if (snapshot) { + return captureAriaSnapshot(context); + } + return { + content: [ + { + type: "text", + text: `Navigated to ${url}`, + }, + ], + }; + }, +}); + +export const goBack: ToolFactory = (snapshot) => ({ + schema: { + name: GoBackTool.shape.name.value, + description: GoBackTool.shape.description.value, + inputSchema: zodToJsonSchema(GoBackTool.shape.arguments), + }, + handle: async (context) => { + await context.sendSocketMessage("browser_go_back", {}); + if (snapshot) { + return captureAriaSnapshot(context); + } + return { + content: [ + { + type: "text", + text: "Navigated back", + }, + ], + }; + }, +}); + +export const goForward: ToolFactory = (snapshot) => ({ + schema: { + name: GoForwardTool.shape.name.value, + description: GoForwardTool.shape.description.value, + inputSchema: zodToJsonSchema(GoForwardTool.shape.arguments), + }, + handle: async (context) => { + await context.sendSocketMessage("browser_go_forward", {}); + if (snapshot) { + return captureAriaSnapshot(context); + } + return { + content: [ + { + type: "text", + text: "Navigated forward", + }, + ], + }; + }, +}); + +export const wait: Tool = { + schema: { + name: WaitTool.shape.name.value, + description: WaitTool.shape.description.value, + inputSchema: zodToJsonSchema(WaitTool.shape.arguments), + }, + handle: async (context, params) => { + const { time } = WaitTool.shape.arguments.parse(params); + await context.sendSocketMessage("browser_wait", { time }); + return { + content: [ + { + type: "text", + text: `Waited for ${time} seconds`, + }, + ], + }; + }, +}; + +export const pressKey: Tool = { + schema: { + name: PressKeyTool.shape.name.value, + description: PressKeyTool.shape.description.value, + inputSchema: zodToJsonSchema(PressKeyTool.shape.arguments), + }, + handle: async (context, params) => { + const { key } = PressKeyTool.shape.arguments.parse(params); + await context.sendSocketMessage("browser_press_key", { key }); + return { + content: [ + { + type: "text", + text: `Pressed key ${key}`, + }, + ], + }; + }, +}; diff --git a/src/tools/custom.ts b/src/tools/custom.ts new file mode 100644 index 0000000..1d0f419 --- /dev/null +++ b/src/tools/custom.ts @@ -0,0 +1,25 @@ +import { zodToJsonSchema } from "zod-to-json-schema"; + +import { GetConsoleLogsTool } from "@repo/types/mcp/tool"; + +import { Tool } from "./tool"; + +export const getConsoleLogs: Tool = { + schema: { + name: GetConsoleLogsTool.shape.name.value, + description: GetConsoleLogsTool.shape.description.value, + inputSchema: zodToJsonSchema(GetConsoleLogsTool.shape.arguments), + }, + handle: async (context, _params) => { + const consoleLogs = await context.sendSocketMessage( + "browser_get_console_logs", + {}, + ); + const text: string = consoleLogs + .map((log) => JSON.stringify(log)) + .join("\n"); + return { + content: [{ type: "text", text }], + }; + }, +}; diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts new file mode 100644 index 0000000..6598f3b --- /dev/null +++ b/src/tools/snapshot.ts @@ -0,0 +1,136 @@ +import zodToJsonSchema from "zod-to-json-schema"; + +import { + ClickTool, + DragTool, + HoverTool, + SelectOptionTool, + SnapshotTool, + TypeTool, +} from "@repo/types/mcp/tool"; + +import type { Context } from "@/context"; +import { captureAriaSnapshot } from "@/utils/aria-snapshot"; + +import type { Tool } from "./tool"; + +export const snapshot: Tool = { + schema: { + name: SnapshotTool.shape.name.value, + description: SnapshotTool.shape.description.value, + inputSchema: zodToJsonSchema(SnapshotTool.shape.arguments), + }, + handle: async (context: Context) => { + return await captureAriaSnapshot(context); + }, +}; + +export const click: Tool = { + schema: { + name: ClickTool.shape.name.value, + description: ClickTool.shape.description.value, + inputSchema: zodToJsonSchema(ClickTool.shape.arguments), + }, + handle: async (context: Context, params) => { + const validatedParams = ClickTool.shape.arguments.parse(params); + await context.sendSocketMessage("browser_click", validatedParams); + const snapshot = await captureAriaSnapshot(context); + return { + content: [ + { + type: "text", + text: `Clicked "${validatedParams.element}"`, + }, + ...snapshot.content, + ], + }; + }, +}; + +export const drag: Tool = { + schema: { + name: DragTool.shape.name.value, + description: DragTool.shape.description.value, + inputSchema: zodToJsonSchema(DragTool.shape.arguments), + }, + handle: async (context: Context, params) => { + const validatedParams = DragTool.shape.arguments.parse(params); + await context.sendSocketMessage("browser_drag", validatedParams); + const snapshot = await captureAriaSnapshot(context); + return { + content: [ + { + type: "text", + text: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`, + }, + ...snapshot.content, + ], + }; + }, +}; + +export const hover: Tool = { + schema: { + name: HoverTool.shape.name.value, + description: HoverTool.shape.description.value, + inputSchema: zodToJsonSchema(HoverTool.shape.arguments), + }, + handle: async (context: Context, params) => { + const validatedParams = HoverTool.shape.arguments.parse(params); + await context.sendSocketMessage("browser_hover", validatedParams); + const snapshot = await captureAriaSnapshot(context); + return { + content: [ + { + type: "text", + text: `Hovered over "${validatedParams.element}"`, + }, + ...snapshot.content, + ], + }; + }, +}; + +export const type: Tool = { + schema: { + name: TypeTool.shape.name.value, + description: TypeTool.shape.description.value, + inputSchema: zodToJsonSchema(TypeTool.shape.arguments), + }, + handle: async (context: Context, params) => { + const validatedParams = TypeTool.shape.arguments.parse(params); + await context.sendSocketMessage("browser_type", validatedParams); + const snapshot = await captureAriaSnapshot(context); + return { + content: [ + { + type: "text", + text: `Typed "${validatedParams.text}" into "${validatedParams.element}"`, + }, + ...snapshot.content, + ], + }; + }, +}; + +export const selectOption: Tool = { + schema: { + name: SelectOptionTool.shape.name.value, + description: SelectOptionTool.shape.description.value, + inputSchema: zodToJsonSchema(SelectOptionTool.shape.arguments), + }, + handle: async (context: Context, params) => { + const validatedParams = SelectOptionTool.shape.arguments.parse(params); + await context.sendSocketMessage("browser_select_option", validatedParams); + const snapshot = await captureAriaSnapshot(context); + return { + content: [ + { + type: "text", + text: `Selected option in "${validatedParams.element}"`, + }, + ...snapshot.content, + ], + }; + }, +}; diff --git a/src/tools/tool.ts b/src/tools/tool.ts new file mode 100644 index 0000000..a5a3654 --- /dev/null +++ b/src/tools/tool.ts @@ -0,0 +1,28 @@ +import type { + ImageContent, + TextContent, +} from "@modelcontextprotocol/sdk/types.js"; +import type { JsonSchema7Type } from "zod-to-json-schema"; + +import type { Context } from "@/context"; + +export type ToolSchema = { + name: string; + description: string; + inputSchema: JsonSchema7Type; +}; + +export type ToolResult = { + content: (ImageContent | TextContent)[]; + isError?: boolean; +}; + +export type Tool = { + schema: ToolSchema; + handle: ( + context: Context, + params?: Record, + ) => Promise; +}; + +export type ToolFactory = (snapshot: boolean) => Tool; diff --git a/src/utils/aria-snapshot.ts b/src/utils/aria-snapshot.ts new file mode 100644 index 0000000..de763f1 --- /dev/null +++ b/src/utils/aria-snapshot.ts @@ -0,0 +1,26 @@ +import { Context } from "@/context"; +import { ToolResult } from "@/tools/tool"; + +export async function captureAriaSnapshot( + context: Context, + status: string = "", +): Promise { + const url = await context.sendSocketMessage("getUrl", undefined); + const title = await context.sendSocketMessage("getTitle", undefined); + const snapshot = await context.sendSocketMessage("browser_snapshot", {}); + return { + content: [ + { + type: "text", + text: `${status ? `${status}\n` : ""} +- Page URL: ${url} +- Page Title: ${title} +- Page Snapshot +\`\`\`yaml +${snapshot} +\`\`\` +`, + }, + ], + }; +} diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 0000000..ff495dc --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,8 @@ +/** + * Logs a message to the console + * + * `console.error` is used since standard input/output is used as transport for MCP + */ +export const debugLog: typeof console.error = (...args) => { + console.error(...args); +}; diff --git a/src/utils/port.ts b/src/utils/port.ts new file mode 100644 index 0000000..d42c3b3 --- /dev/null +++ b/src/utils/port.ts @@ -0,0 +1,23 @@ +import { execSync } from "node:child_process"; +import net from "node:net"; + +export async function isPortInUse(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(true)); // Port is still in use + server.once("listening", () => { + server.close(() => resolve(false)); // Port is free + }); + server.listen(port); + }); +} + +export function killProcessOnPort(port: number) { + if (process.platform === "win32") { + execSync( + `FOR /F "tokens=5" %a in ('netstat -ano ^| findstr :${port}') do taskkill /F /PID %a`, + ); + } else { + execSync(`lsof -ti:${port} | xargs kill -9`); + } +} diff --git a/src/ws.ts b/src/ws.ts new file mode 100644 index 0000000..fb9f25a --- /dev/null +++ b/src/ws.ts @@ -0,0 +1,17 @@ +import { WebSocketServer } from "ws"; + +import { mcpConfig } from "@repo/config/mcp.config"; +import { wait } from "@repo/utils"; + +import { isPortInUse, killProcessOnPort } from "@/utils/port"; + +export async function createWebSocketServer( + port: number = mcpConfig.defaultWsPort, +): Promise { + killProcessOnPort(port); + // Wait until the port is free + while (await isPortInUse(port)) { + await wait(100); + } + return new WebSocketServer({ port }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d466d95 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +}