1
0
mirror of https://github.com/BrowserMCP/mcp.git synced 2025-05-04 16:21:43 +03:00

extract mcp server from monorepo

This commit is contained in:
Namu
2025-03-27 21:04:11 -05:00
parent 92b9eecd79
commit 6d7662657e
16 changed files with 716 additions and 2 deletions

36
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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.

40
package.json Normal file
View File

@@ -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"
}
}

52
src/context.ts Normal file
View File

@@ -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<T extends MessageType<SocketMessageMap>>(
type: T,
payload: MessagePayload<SocketMessageMap, T>,
options: { timeoutMs?: number } = { timeoutMs: 30000 }
) {
const { sendSocketMessage } = createSocketMessageSender<SocketMessageMap>(
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();
}
}

66
src/index.ts Normal file
View File

@@ -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<Server> {
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);

20
src/resources/resource.ts Normal file
View File

@@ -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<ResourceResult[]>;
};

92
src/server.ts Normal file
View File

@@ -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<Server> {
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;
}

120
src/tools/common.ts Normal file
View File

@@ -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}`,
},
],
};
},
};

25
src/tools/custom.ts Normal file
View File

@@ -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 }],
};
},
};

136
src/tools/snapshot.ts Normal file
View File

@@ -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,
],
};
},
};

28
src/tools/tool.ts Normal file
View File

@@ -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<string, any>,
) => Promise<ToolResult>;
};
export type ToolFactory = (snapshot: boolean) => Tool;

View File

@@ -0,0 +1,26 @@
import { Context } from "@/context";
import { ToolResult } from "@/tools/tool";
export async function captureAriaSnapshot(
context: Context,
status: string = "",
): Promise<ToolResult> {
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}
\`\`\`
`,
},
],
};
}

8
src/utils/log.ts Normal file
View File

@@ -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);
};

23
src/utils/port.ts Normal file
View File

@@ -0,0 +1,23 @@
import { execSync } from "node:child_process";
import net from "node:net";
export async function isPortInUse(port: number): Promise<boolean> {
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`);
}
}

17
src/ws.ts Normal file
View File

@@ -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<WebSocketServer> {
killProcessOnPort(port);
// Wait until the port is free
while (await isPortInUse(port)) {
await wait(100);
}
return new WebSocketServer({ port });
}

19
tsconfig.json Normal file
View File

@@ -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"]
}