From ca1778bc4823a20c0c05264e0c2cd5247521afd6 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 11:50:17 +0800 Subject: [PATCH 01/39] docs: add MCP Streamable HTTP implementation plan --- .../plans/2026-06-26-mcp-streamable-http.md | 2277 +++++++++++++++++ 1 file changed, 2277 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-26-mcp-streamable-http.md diff --git a/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md b/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md new file mode 100644 index 0000000..0caff09 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md @@ -0,0 +1,2277 @@ +# Browser Bridge MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a FastMCP-based Streamable HTTP MCP server to the existing `apps/websocket` package so MCP-compatible agents can list and control connected browsers. + +**Architecture:** FastMCP runs in the same Bun process as the WebSocket server on a dedicated port (default 3003). Each MCP tool creates a short-lived `@browser-bridge/websocket/client` connection to the WebSocket server, sends an `Envelope` command, and returns the `ResponsePayload` mapped to MCP content. + +**Tech Stack:** Bun, TypeScript, `fastmcp`, `zod`, `@browser-bridge/shared`, `@browser-bridge/websocket/client`. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/websocket/package.json` | Add `fastmcp` and `zod` dependencies | +| `apps/websocket/src/index.ts` | Bootstrap FastMCP alongside Bun.serve | +| `apps/websocket/src/mcp/browser-session.ts` | Per-MCP-session state: selected browserId and timeout | +| `apps/websocket/src/mcp/tool-context.ts` | `ToolContext` and `ServerContext` types | +| `apps/websocket/src/mcp/browser-resolver.ts` | Resolve target browserId: explicit → auto-detect single → error | +| `apps/websocket/src/mcp/command-client.ts` | Short-lived WebSocket client wrappers: `sendCommand`, `sendEvent` | +| `apps/websocket/src/mcp/browser-lookup.ts` | Fetch browser list from ws-server and resolve target browser | +| `apps/websocket/src/mcp/tools/list-browsers.ts` | `list_browsers` tool | +| `apps/websocket/src/mcp/tools/set-browser.ts` | `set_browser` tool | +| `apps/websocket/src/mcp/tools/navigate.ts` | `navigate` tool | +| `apps/websocket/src/mcp/tools/click.ts` | `click` tool | +| `apps/websocket/src/mcp/tools/type.ts` | `type` tool | +| `apps/websocket/src/mcp/tools/screenshot.ts` | `screenshot` tool | +| `apps/websocket/src/mcp/tools/pageinfo.ts` | `pageinfo` tool | +| `apps/websocket/src/mcp/server.ts` | FastMCP instance, registers all tools, starts `httpStream` transport | +| `apps/websocket/src/mcp/index.ts` | Public MCP exports | +| `apps/websocket/src/mcp/__tests__/browser-session.test.ts` | Unit tests for session state | +| `apps/websocket/src/mcp/__tests__/browser-resolver.test.ts` | Unit tests for browser resolution | +| `apps/websocket/src/mcp/__tests__/command-client.test.ts` | Unit tests for command/event client | +| `apps/websocket/src/mcp/__tests__/browser-lookup.test.ts` | Unit tests for browser lookup helper | +| `apps/websocket/src/mcp/__tests__/tools/*.test.ts` | Tests for each tool | +| `apps/websocket/src/mcp/__tests__/server.test.ts` | Integration test for FastMCP server | +| `docs/mcp-setup.md` | User-facing MCP setup guide | +| `README.md` | Add MCP section | + +--- + +## Task 1: Add Dependencies + +**Files:** +- Modify: `apps/websocket/package.json` + +**Goal:** Add `fastmcp` and `zod` to `apps/websocket`. + +- [ ] **Step 1: Add dependencies** + +Update `apps/websocket/package.json`: + +```json +{ + "name": "@browser-bridge/websocket", + "version": "1.0.0", + "private": true, + "description": "WebSocket server, client, and protocol for Browser Bridge", + "exports": { + "./client": "./src/client/index.ts", + "./protocol": "./src/protocol/index.ts" + }, + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@browser-bridge/shared": "workspace:*", + "fastmcp": "^3.35.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.4.0" + } +} +``` + +- [ ] **Step 2: Install packages** + +Run: + +```bash +bun install +``` + +Expected: `bun.lockb` updated, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/websocket/package.json bun.lockb +git commit -m "chore: add fastmcp and zod to websocket app" +``` + +--- + +## Task 2: Browser Session State + +**Files:** +- Create: `apps/websocket/src/mcp/browser-session.ts` +- Create: `apps/websocket/src/mcp/__tests__/browser-session.test.ts` + +**Goal:** Store selected browserId and timeout per MCP session. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/browser-session.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { createBrowserSessionStore } from '../browser-session'; + +describe('BrowserSessionStore', () => { + it('creates a session with defaults', () => { + const store = createBrowserSessionStore(15000); + const session = store.createSession('session-1'); + expect(session.getBrowser()).toBeUndefined(); + expect(session.defaultTimeoutMs).toBe(15000); + }); + + it('sets and retrieves browserId', () => { + const store = createBrowserSessionStore(10000); + const session = store.createSession('session-1'); + session.setBrowser('browser-a'); + expect(session.getBrowser()).toBe('browser-a'); + }); + + it('clears browserId', () => { + const store = createBrowserSessionStore(10000); + const session = store.createSession('session-1'); + session.setBrowser('browser-a'); + session.clearBrowser(); + expect(session.getBrowser()).toBeUndefined(); + }); + + it('is isolated per session', () => { + const store = createBrowserSessionStore(10000); + const a = store.createSession('a'); + const b = store.createSession('b'); + a.setBrowser('browser-a'); + expect(b.getBrowser()).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/browser-session.test.ts +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/browser-session.ts`: + +```typescript +export interface BrowserSession { + readonly defaultTimeoutMs: number; + getBrowser(): string | undefined; + setBrowser(browserId: string): void; + clearBrowser(): void; +} + +export interface BrowserSessionStore { + createSession(sessionId: string): BrowserSession; +} + +export function createBrowserSessionStore(defaultTimeoutMs: number): BrowserSessionStore { + const sessions = new Map(); + + return { + createSession(sessionId: string): BrowserSession { + const existing = sessions.get(sessionId); + if (existing) return existing; + + let browserId: string | undefined; + + const session: BrowserSession = { + defaultTimeoutMs, + getBrowser: () => browserId, + setBrowser: (id: string) => { + browserId = id; + }, + clearBrowser: () => { + browserId = undefined; + }, + }; + + sessions.set(sessionId, session); + return session; + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/browser-session.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/browser-session.ts apps/websocket/src/mcp/__tests__/browser-session.test.ts +git commit -m "feat(mcp): add per-session browser state store" +``` + +--- + +## Task 3: Tool Context Types + +**Files:** +- Create: `apps/websocket/src/mcp/tool-context.ts` + +**Goal:** Define shared context types used by every tool handler. + +- [ ] **Step 1: Write implementation** + +Create `apps/websocket/src/mcp/tool-context.ts`: + +```typescript +import type { BrowserSession, BrowserSessionStore } from './browser-session'; + +export interface ToolContext { + session: BrowserSession; + websocketUrl: string; +} + +export interface ServerContext { + websocketUrl: string; + sessions: BrowserSessionStore; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/websocket/src/mcp/tool-context.ts +git commit -m "feat(mcp): add tool and server context types" +``` + +--- + +## Task 4: Browser Resolver + +**Files:** +- Create: `apps/websocket/src/mcp/browser-resolver.ts` +- Create: `apps/websocket/src/mcp/__tests__/browser-resolver.test.ts` + +**Goal:** Implement browser resolution: explicit → auto-detect single → error. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/browser-resolver.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { resolveBrowser } from '../browser-resolver'; +import type { BrowserConnection } from '@browser-bridge/shared'; + +const makeBrowser = (id: string, status: BrowserConnection['status']): BrowserConnection => ({ + browserId: id, + userId: 'user-1', + status, + lastSeen: Date.now(), +}); + +describe('resolveBrowser', () => { + it('returns explicit browserId when set', () => { + const result = resolveBrowser('browser-a', [makeBrowser('browser-a', 'online')]); + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('browser-a'); + }); + + it('auto-detects single online browser', () => { + const result = resolveBrowser(undefined, [makeBrowser('browser-a', 'online')]); + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('browser-a'); + }); + + it('fails when no browser is online', () => { + const result = resolveBrowser(undefined, [makeBrowser('browser-a', 'offline')]); + expect(result.success).toBe(false); + if (!result.success) expect(result.message).toContain('No browser connected'); + }); + + it('fails when multiple browsers are online', () => { + const browsers = [makeBrowser('browser-a', 'online'), makeBrowser('browser-b', 'online')]; + const result = resolveBrowser(undefined, browsers); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.message).toContain('Multiple browsers'); + expect(result.availableBrowsers).toHaveLength(2); + } + }); + + it('ignores offline browsers during auto-detect', () => { + const browsers = [makeBrowser('browser-a', 'online'), makeBrowser('browser-b', 'offline')]; + const result = resolveBrowser(undefined, browsers); + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('browser-a'); + }); + + it('fails when explicit browser is not online', () => { + const result = resolveBrowser('browser-a', [makeBrowser('browser-a', 'offline')]); + expect(result.success).toBe(false); + if (!result.success) expect(result.message).toContain('not online'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/browser-resolver.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/browser-resolver.ts`: + +```typescript +import type { BrowserConnection } from '@browser-bridge/shared'; + +export type BrowserResolutionSuccess = { success: true; browserId: string }; +export type BrowserResolutionFailure = { + success: false; + message: string; + availableBrowsers?: BrowserConnection[]; +}; +export type BrowserResolutionResult = BrowserResolutionSuccess | BrowserResolutionFailure; + +function formatBrowserList(browsers: BrowserConnection[]): string { + return browsers.map((b) => `- ${b.browserId} (${b.status})`).join('\n'); +} + +export function resolveBrowser( + explicitBrowserId: string | undefined, + browsers: BrowserConnection[], +): BrowserResolutionResult { + if (explicitBrowserId) { + const match = browsers.find((b) => b.browserId === explicitBrowserId); + if (!match) { + return { + success: false, + message: `Browser "${explicitBrowserId}" is not connected.`, + availableBrowsers: browsers, + }; + } + if (match.status !== 'online') { + return { + success: false, + message: `Browser "${explicitBrowserId}" is not online (status: ${match.status}).`, + availableBrowsers: browsers, + }; + } + return { success: true, browserId: explicitBrowserId }; + } + + const online = browsers.filter((b) => b.status === 'online'); + + if (online.length === 0) { + return { + success: false, + message: 'No browser connected. Start the extension/local-proxy first.', + availableBrowsers: browsers, + }; + } + + if (online.length === 1) { + return { success: true, browserId: online[0].browserId }; + } + + return { + success: false, + message: `Multiple browsers are online. Call set_browser with one of:\n${formatBrowserList(online)}`, + availableBrowsers: online, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/browser-resolver.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/browser-resolver.ts apps/websocket/src/mcp/__tests__/browser-resolver.test.ts +git commit -m "feat(mcp): add browser resolution logic" +``` + +--- + +## Task 5: Command/Event Client Wrapper + +**Files:** +- Create: `apps/websocket/src/mcp/command-client.ts` +- Create: `apps/websocket/src/mcp/__tests__/command-client.test.ts` + +**Goal:** Wrap `@browser-bridge/websocket/client` so each tool call can send commands and events with a timeout. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/command-client.test.ts`: + +```typescript +import { describe, expect, it, afterEach } from 'bun:test'; +import { sendCommand, sendEvent } from '../command-client'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('sendCommand', () => { + let server: ReturnType | undefined; + + afterEach(() => { + server?.stop(); + server = undefined; + }); + + it('sends a command and returns the response payload', async () => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + const url = new URL(req.url); + if (url.pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: { status: 'ok', data: { title: 'Example' } }, + timestamp: Date.now(), + }; + ws.send(encode(response)); + }, + close() {}, + }, + }); + + const result = await sendCommand({ + serverUrl: `ws://127.0.0.1:${server.port}/ws`, + browserId: 'browser-a', + command: 'pageinfo', + params: {}, + timeoutMs: 1000, + }); + + expect(result.status).toBe('ok'); + expect(result.data).toEqual({ title: 'Example' }); + }); + + it('rejects on timeout', async () => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + const url = new URL(req.url); + if (url.pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { open() {}, message() {}, close() {} }, + }); + + await expect( + sendCommand({ + serverUrl: `ws://127.0.0.1:${server.port}/ws`, + browserId: 'browser-a', + command: 'pageinfo', + params: {}, + timeoutMs: 50, + }), + ).rejects.toThrow('timeout'); + }); +}); + +describe('sendEvent', () => { + let server: ReturnType | undefined; + + afterEach(() => { + server?.stop(); + server = undefined; + }); + + it('sends an event and returns the response payload', async () => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + const url = new URL(req.url); + if (url.pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: { status: 'ok', data: [{ browserId: 'a', status: 'online' }] }, + timestamp: Date.now(), + }; + ws.send(encode(response)); + }, + close() {}, + }, + }); + + const result = await sendEvent({ + serverUrl: `ws://127.0.0.1:${server.port}/ws`, + event: 'list_browsers', + payload: {}, + timeoutMs: 1000, + }); + + expect(result.status).toBe('ok'); + expect(Array.isArray(result.data)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/command-client.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/command-client.ts`: + +```typescript +import { createClient } from '@browser-bridge/websocket/client'; +import type { CommandPayload, CommandType, ResponsePayload } from '@browser-bridge/shared'; + +export interface SendCommandOptions { + serverUrl: string; + browserId: string; + command: CommandType; + params: Record; + timeoutMs: number; +} + +export interface SendEventOptions { + serverUrl: string; + event: string; + payload: Record; + timeoutMs: number; +} + +export async function sendCommand(options: SendCommandOptions): Promise { + const client = createClient({ url: options.serverUrl }); + + try { + await waitForOpen(client, Math.min(options.timeoutMs, 5000)); + + const payload: CommandPayload = { + command: options.command, + params: options.params, + }; + + const envelope = await client.sendCommand(options.browserId, payload, { + timeout: options.timeoutMs, + }); + + return (envelope.payload ?? { status: 'error', error: 'Empty response' }) as ResponsePayload; + } finally { + client.close(); + } +} + +export async function sendEvent(options: SendEventOptions): Promise { + const client = createClient({ url: options.serverUrl }); + + try { + await waitForOpen(client, Math.min(options.timeoutMs, 5000)); + + const envelope = await client.request( + 'event', + { event: options.event, ...options.payload }, + { timeout: options.timeoutMs }, + ); + + return (envelope.payload ?? { status: 'error', error: 'Empty response' }) as ResponsePayload; + } finally { + client.close(); + } +} + +function waitForOpen( + client: ReturnType, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + if (client.readyState === WebSocket.OPEN) { + resolve(); + return; + } + + const start = Date.now(); + const interval = setInterval(() => { + if (client.readyState === WebSocket.OPEN) { + clearInterval(interval); + resolve(); + return; + } + if (Date.now() - start > timeoutMs) { + clearInterval(interval); + client.close(); + reject(new Error('Failed to connect to WebSocket server')); + } + }, 10); + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/command-client.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/command-client.ts apps/websocket/src/mcp/__tests__/command-client.test.ts +git commit -m "feat(mcp): add command and event client wrappers" +``` + +--- + +## Task 6: Browser Lookup Helper + +**Files:** +- Create: `apps/websocket/src/mcp/browser-lookup.ts` +- Create: `apps/websocket/src/mcp/__tests__/browser-lookup.test.ts` + +**Goal:** Shared functions to fetch browser list and resolve target browser for tools. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/browser-lookup.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { fetchBrowserList, resolveTargetBrowser } from '../browser-lookup'; +import { createBrowserSessionStore } from '../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +function createListServer(browsers: unknown[]) { + return Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: { status: 'ok', data: browsers }, + timestamp: Date.now(), + }; + ws.send(encode(response)); + }, + close() {}, + }, + }); +} + +describe('fetchBrowserList', () => { + it('returns parsed browser list', async () => { + const server = createListServer([{ browserId: 'a', status: 'online' }]); + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + const list = await fetchBrowserList( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + 1000, + ); + expect(list).toHaveLength(1); + expect(list[0].browserId).toBe('a'); + server.stop(); + }); +}); + +describe('resolveTargetBrowser', () => { + it('uses explicit session browser', async () => { + const server = createListServer([ + { browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }, + { browserId: 'b', userId: 'u', status: 'online', lastSeen: Date.now() }, + ]); + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + session.setBrowser('a'); + + const result = await resolveTargetBrowser( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + 1000, + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('a'); + server.stop(); + }); + + it('auto-detects single browser', async () => { + const server = createListServer([ + { browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }, + ]); + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + + const result = await resolveTargetBrowser( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + 1000, + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('a'); + server.stop(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/browser-lookup.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/browser-lookup.ts`: + +```typescript +import type { BrowserConnection } from '@browser-bridge/shared'; +import { sendEvent } from './command-client'; +import { resolveBrowser, type BrowserResolutionResult } from './browser-resolver'; +import type { ToolContext } from './tool-context'; + +export async function fetchBrowserList( + context: ToolContext, + timeoutMs: number, +): Promise { + const result = await sendEvent({ + serverUrl: context.websocketUrl, + event: 'list_browsers', + payload: {}, + timeoutMs, + }); + + if (result.status !== 'ok') { + throw new Error(result.error ?? 'Failed to list browsers'); + } + + return Array.isArray(result.data) ? (result.data as BrowserConnection[]) : []; +} + +export async function resolveTargetBrowser( + context: ToolContext, + timeoutMs: number, +): Promise { + const explicit = context.session.getBrowser(); + const browsers = await fetchBrowserList(context, timeoutMs); + return resolveBrowser(explicit, browsers); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/browser-lookup.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/browser-lookup.ts apps/websocket/src/mcp/__tests__/browser-lookup.test.ts +git commit -m "feat(mcp): add browser lookup helper" +``` + +--- + +## Task 7: list_browsers Tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/list-browsers.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts` + +**Goal:** Expose `list_browsers` MCP tool. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { executeListBrowsers } from '../../tools/list-browsers'; +import { createBrowserSessionStore } from '../../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('executeListBrowsers', () => { + it('returns a list of browsers', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: { + status: 'ok', + data: [ + { browserId: 'browser-a', userId: 'u1', status: 'online', lastSeen: Date.now() }, + ], + }, + timestamp: Date.now(), + }; + ws.send(encode(response)); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + const result = await executeListBrowsers( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + {}, + ); + + expect(result).toContain('browser-a'); + server.stop(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/tools/list-browsers.ts`: + +```typescript +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import { sendEvent } from '../command-client'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const ListBrowsersInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeListBrowsers( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + + const result = await sendEvent({ + serverUrl: context.websocketUrl, + event: 'list_browsers', + payload: {}, + timeoutMs, + }); + + if (result.status !== 'ok') { + throw new Error(result.error ?? 'Failed to list browsers'); + } + + const browsers = Array.isArray(result.data) ? result.data : []; + if (browsers.length === 0) { + return 'No browsers connected.'; + } + + return browsers + .map((b: { browserId: string; status: string }) => `- ${b.browserId} (${b.status})`) + .join('\n'); +} + +export function registerListBrowsersTool(server: FastMCP, serverContext: ServerContext): void { + server.addTool({ + name: 'list_browsers', + description: 'List all browsers connected to Browser Bridge.', + parameters: ListBrowsersInputSchema, + execute: async (args, { sessionId }) => { + const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); + return executeListBrowsers({ websocketUrl: serverContext.websocketUrl, session }, args); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/list-browsers.ts apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts +git commit -m "feat(mcp): add list_browsers tool" +``` + +--- + +## Task 8: set_browser Tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/set-browser.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts` + +**Goal:** Allow agents to pin a browser for the MCP session. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { executeSetBrowser } from '../../tools/set-browser'; +import { createBrowserSessionStore } from '../../browser-session'; + +describe('executeSetBrowser', () => { + it('sets the browser in session state', async () => { + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + const result = await executeSetBrowser( + { session, websocketUrl: 'ws://localhost:3001' }, + { browserId: 'browser-a' }, + ); + expect(result).toContain('browser-a'); + expect(session.getBrowser()).toBe('browser-a'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/tools/set-browser.ts`: + +```typescript +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const SetBrowserInputSchema = z.object({ + browserId: z.string().min(1), +}); + +export async function executeSetBrowser( + context: ToolContext, + args: z.infer, +): Promise { + context.session.setBrowser(args.browserId); + return `Browser set to "${args.browserId}" for this session.`; +} + +export function registerSetBrowserTool(server: FastMCP, serverContext: ServerContext): void { + server.addTool({ + name: 'set_browser', + description: 'Explicitly choose which connected browser to control for this MCP session.', + parameters: SetBrowserInputSchema, + execute: async (args, { sessionId }) => { + const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); + return executeSetBrowser({ websocketUrl: serverContext.websocketUrl, session }, args); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/set-browser.ts apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts +git commit -m "feat(mcp): add set_browser tool" +``` + +--- + +## Task 9: navigate Tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/navigate.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/navigate.test.ts` + +**Goal:** Expose `navigate` tool. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/tools/navigate.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { executeNavigate } from '../../tools/navigate'; +import { createBrowserSessionStore } from '../../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('executeNavigate', () => { + it('navigates to url and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [{ browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }], + } + : { status: 'ok', message: 'Navigated' }; + ws.send( + encode({ + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload, + timestamp: Date.now(), + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + + const result = await executeNavigate( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { url: 'https://example.com' }, + ); + + expect(result).toContain('Navigated'); + server.stop(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/navigate.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/tools/navigate.ts`: + +```typescript +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import { sendCommand } from '../command-client'; +import { resolveTargetBrowser } from '../browser-lookup'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const NavigateInputSchema = z.object({ + url: z.string().url(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeNavigate( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + + if (!resolution.success) { + throw new Error(resolution.message); + } + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'navigate', + params: { url: args.url }, + timeoutMs, + }); + + if (result.status !== 'ok') { + throw new Error(result.error ?? 'Navigation failed'); + } + + return result.message ?? `Navigated to ${args.url}`; +} + +export function registerNavigateTool(server: FastMCP, serverContext: ServerContext): void { + server.addTool({ + name: 'navigate', + description: 'Navigate the active tab of the selected browser to a URL.', + parameters: NavigateInputSchema, + execute: async (args, { sessionId }) => { + const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); + return executeNavigate({ websocketUrl: serverContext.websocketUrl, session }, args); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/navigate.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/navigate.ts apps/websocket/src/mcp/__tests__/tools/navigate.test.ts +git commit -m "feat(mcp): add navigate tool" +``` + +--- + +## Task 10: click Tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/click.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/click.test.ts` + +**Goal:** Expose `click` tool. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/tools/click.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { executeClick } from '../../tools/click'; +import { createBrowserSessionStore } from '../../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('executeClick', () => { + it('sends click command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [{ browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }], + } + : { status: 'ok', message: 'Clicked' }; + ws.send( + encode({ + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload, + timestamp: Date.now(), + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + const result = await executeClick( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { selector: '#submit' }, + ); + expect(result).toContain('Clicked'); + server.stop(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/click.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/tools/click.ts`: + +```typescript +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import { sendCommand } from '../command-client'; +import { resolveTargetBrowser } from '../browser-lookup'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const ClickInputSchema = z.object({ + selector: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeClick( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'click', + params: { selector: args.selector }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Click failed'); + return result.message ?? `Clicked ${args.selector}`; +} + +export function registerClickTool(server: FastMCP, serverContext: ServerContext): void { + server.addTool({ + name: 'click', + description: 'Click an element in the selected browser by CSS selector.', + parameters: ClickInputSchema, + execute: async (args, { sessionId }) => { + const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); + return executeClick({ websocketUrl: serverContext.websocketUrl, session }, args); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/click.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/click.ts apps/websocket/src/mcp/__tests__/tools/click.test.ts +git commit -m "feat(mcp): add click tool" +``` + +--- + +## Task 11: type Tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/type.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/type.test.ts` + +**Goal:** Expose `type` tool. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/tools/type.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { executeType } from '../../tools/type'; +import { createBrowserSessionStore } from '../../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('executeType', () => { + it('sends type command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [{ browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }], + } + : { status: 'ok', message: 'Typed' }; + ws.send( + encode({ + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload, + timestamp: Date.now(), + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + const result = await executeType( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { selector: '#search', text: 'hello' }, + ); + expect(result).toContain('Typed'); + server.stop(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/type.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/tools/type.ts`: + +```typescript +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import { sendCommand } from '../command-client'; +import { resolveTargetBrowser } from '../browser-lookup'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const TypeInputSchema = z.object({ + selector: z.string().min(1), + text: z.string(), + submit: z.boolean().optional(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeType( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'type', + params: { selector: args.selector, text: args.text, submit: args.submit }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Type failed'); + return result.message ?? `Typed into ${args.selector}`; +} + +export function registerTypeTool(server: FastMCP, serverContext: ServerContext): void { + server.addTool({ + name: 'type', + description: 'Type text into an input element in the selected browser.', + parameters: TypeInputSchema, + execute: async (args, { sessionId }) => { + const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); + return executeType({ websocketUrl: serverContext.websocketUrl, session }, args); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/type.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/type.ts apps/websocket/src/mcp/__tests__/tools/type.test.ts +git commit -m "feat(mcp): add type tool" +``` + +--- + +## Task 12: screenshot Tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/screenshot.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts` + +**Goal:** Expose `screenshot` tool returning image content. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { executeScreenshot } from '../../tools/screenshot'; +import { createBrowserSessionStore } from '../../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('executeScreenshot', () => { + it('returns image content', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [{ browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }], + } + : { + status: 'ok', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + }; + ws.send( + encode({ + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload, + timestamp: Date.now(), + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + const result = await executeScreenshot( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + {}, + ); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('image'); + server.stop(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/tools/screenshot.ts`: + +```typescript +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import { sendCommand } from '../command-client'; +import { resolveTargetBrowser } from '../browser-lookup'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const ScreenshotInputSchema = z.object({ + full_page: z.boolean().optional(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeScreenshot( + context: ToolContext, + args: z.infer, +): Promise<{ content: Array<{ type: 'image'; data: string; mimeType: 'image/png' }> }> { + const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'screenshot', + params: { fullPage: args.full_page }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Screenshot failed'); + const data = typeof result.data === 'string' ? result.data : ''; + + return { + content: [{ type: 'image', data, mimeType: 'image/png' }], + }; +} + +export function registerScreenshotTool(server: FastMCP, serverContext: ServerContext): void { + server.addTool({ + name: 'screenshot', + description: 'Take a screenshot of the selected browser.', + parameters: ScreenshotInputSchema, + execute: async (args, { sessionId }) => { + const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); + return executeScreenshot({ websocketUrl: serverContext.websocketUrl, session }, args); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/screenshot.ts apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts +git commit -m "feat(mcp): add screenshot tool" +``` + +--- + +## Task 13: pageinfo Tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/pageinfo.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts` + +**Goal:** Expose `pageinfo` tool. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts`: + +```typescript +import { describe, expect, it } from 'bun:test'; +import { executePageinfo } from '../../tools/pageinfo'; +import { createBrowserSessionStore } from '../../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('executePageinfo', () => { + it('returns page info as JSON string', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [{ browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }], + } + : { status: 'ok', data: { title: 'Example', url: 'https://example.com' } }; + ws.send( + encode({ + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload, + timestamp: Date.now(), + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const session = sessions.createSession('s1'); + const result = await executePageinfo( + { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + {}, + ); + expect(result).toContain('Example'); + expect(result).toContain('https://example.com'); + server.stop(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/tools/pageinfo.ts`: + +```typescript +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import { sendCommand } from '../command-client'; +import { resolveTargetBrowser } from '../browser-lookup'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const PageinfoInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executePageinfo( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'pageinfo', + params: {}, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'pageinfo failed'); + return JSON.stringify(result.data ?? {}, null, 2); +} + +export function registerPageinfoTool(server: FastMCP, serverContext: ServerContext): void { + server.addTool({ + name: 'pageinfo', + description: 'Get title, URL, and tab list from the selected browser.', + parameters: PageinfoInputSchema, + execute: async (args, { sessionId }) => { + const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); + return executePageinfo({ websocketUrl: serverContext.websocketUrl, session }, args); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/pageinfo.ts apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts +git commit -m "feat(mcp): add pageinfo tool" +``` + +--- + +## Task 14: FastMCP Server Bootstrap + +**Files:** +- Create: `apps/websocket/src/mcp/server.ts` +- Create: `apps/websocket/src/mcp/index.ts` +- Create: `apps/websocket/src/mcp/__tests__/server.test.ts` + +**Goal:** Wire all tools into FastMCP and expose `startMcpServer(options)`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/websocket/src/mcp/__tests__/server.test.ts`: + +```typescript +import { describe, expect, it, afterEach } from 'bun:test'; +import type { FastMCP } from 'fastmcp'; +import { startMcpServer } from '../server'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('startMcpServer', () => { + let wsServer: ReturnType | undefined; + let mcpServer: FastMCP | undefined; + + afterEach(async () => { + await mcpServer?.stop(); + wsServer?.stop(); + }); + + it('starts without error', async () => { + wsServer = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServerInstance) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServerInstance.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: { status: 'ok', data: [{ browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }] }, + timestamp: Date.now(), + }; + ws.send(encode(response)); + }, + close() {}, + }, + }); + + mcpServer = startMcpServer({ + websocketUrl: `ws://127.0.0.1:${wsServer.port}/ws`, + port: 0, + hostname: '127.0.0.1', + defaultTimeoutMs: 1000, + version: 'test', + }); + + expect(mcpServer).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/server.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/websocket/src/mcp/server.ts`: + +```typescript +import { FastMCP } from 'fastmcp'; +import { createBrowserSessionStore } from './browser-session'; +import { registerListBrowsersTool } from './tools/list-browsers'; +import { registerSetBrowserTool } from './tools/set-browser'; +import { registerNavigateTool } from './tools/navigate'; +import { registerClickTool } from './tools/click'; +import { registerTypeTool } from './tools/type'; +import { registerScreenshotTool } from './tools/screenshot'; +import { registerPageinfoTool } from './tools/pageinfo'; + +export interface McpServerOptions { + websocketUrl: string; + port: number; + hostname: string; + defaultTimeoutMs: number; + version: string; +} + +export function startMcpServer(options: McpServerOptions): FastMCP { + const server = new FastMCP({ + name: 'Browser Bridge', + version: options.version, + }); + + const sessions = createBrowserSessionStore(options.defaultTimeoutMs); + const serverContext = { + websocketUrl: options.websocketUrl, + sessions, + }; + + registerListBrowsersTool(server, serverContext); + registerSetBrowserTool(server, serverContext); + registerNavigateTool(server, serverContext); + registerClickTool(server, serverContext); + registerTypeTool(server, serverContext); + registerScreenshotTool(server, serverContext); + registerPageinfoTool(server, serverContext); + + server.start({ + transportType: 'httpStream', + httpStream: { + port: options.port, + host: options.hostname, + endpoint: '/mcp', + }, + }); + + return server; +} +``` + +Create `apps/websocket/src/mcp/index.ts`: + +```typescript +export { startMcpServer, type McpServerOptions } from './server'; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +bun test apps/websocket/src/mcp/__tests__/server.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/server.ts apps/websocket/src/mcp/index.ts apps/websocket/src/mcp/__tests__/server.test.ts +git commit -m "feat(mcp): add FastMCP server bootstrap" +``` + +--- + +## Task 15: Integrate MCP Server into WebSocket App + +**Files:** +- Modify: `apps/websocket/src/index.ts` + +**Goal:** Start FastMCP alongside the WebSocket server when the app launches. + +- [ ] **Step 1: Modify index.ts** + +Update `apps/websocket/src/index.ts`: + +```typescript +import { ApiKeyAuthProvider } from '@browser-bridge/shared/auth'; +import { WEBSOCKET_PORT } from '@browser-bridge/shared'; +import { startServer } from './server'; +import { startMcpServer } from './mcp'; +import pkg from '../package.json'; + +const apiKeys = process.env.BRIDGE_API_KEYS; +const port = process.env.BRIDGE_WS_PORT ? Number(process.env.BRIDGE_WS_PORT) : undefined; +const hostname = process.env.BRIDGE_WS_HOSTNAME; + +const authProvider = apiKeys + ? new ApiKeyAuthProvider(apiKeys.split(',').map((k) => k.trim()).filter(Boolean)) + : undefined; + +startServer(port, authProvider, hostname); + +const mcpPort = process.env.BRIDGE_MCP_PORT ? Number(process.env.BRIDGE_MCP_PORT) : 3003; +const mcpHostname = process.env.BRIDGE_MCP_HOSTNAME ?? '127.0.0.1'; +const mcpTimeout = process.env.BRIDGE_MCP_TIMEOUT_MS + ? Number(process.env.BRIDGE_MCP_TIMEOUT_MS) + : 10000; + +startMcpServer({ + websocketUrl: `ws://${hostname ?? 'localhost'}:${port ?? WEBSOCKET_PORT}`, + port: mcpPort, + hostname: mcpHostname, + defaultTimeoutMs: mcpTimeout, + version: pkg.version, +}); + +console.log(`MCP server listening on http://${mcpHostname}:${mcpPort}/mcp`); +``` + +- [ ] **Step 2: Run type check** + +Run: + +```bash +bun run type-check +``` + +Expected: No errors. + +- [ ] **Step 3: Run tests** + +Run: + +```bash +bun test apps/websocket/src/mcp +``` + +Expected: All MCP tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add apps/websocket/src/index.ts +git commit -m "feat(mcp): start MCP server alongside websocket server" +``` + +--- + +## Task 16: Documentation + +**Files:** +- Create: `docs/mcp-setup.md` +- Modify: `README.md` + +**Goal:** Guide users to configure the MCP endpoint in their agent client. + +- [ ] **Step 1: Create docs/mcp-setup.md** + +Create `docs/mcp-setup.md`: + +```markdown +# Browser Bridge MCP Setup + +Browser Bridge exposes an MCP server over Streamable HTTP so agents can control browsers directly. + +## Start the server + +```bash +bun run dev:websocket +``` + +The MCP endpoint is available at `http://localhost:3003/mcp`. + +## Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "browser-bridge": { + "transport": "streamableHttp", + "url": "http://localhost:3003/mcp" + } + } +} +``` + +## Cursor + +Add to Cursor MCP settings: + +```json +{ + "mcpServers": { + "browser-bridge": { + "transport": "streamableHttp", + "url": "http://localhost:3003/mcp" + } + } +} +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `BRIDGE_MCP_PORT` | `3003` | MCP HTTP port | +| `BRIDGE_MCP_HOSTNAME` | `127.0.0.1` | Bind address | +| `BRIDGE_MCP_TIMEOUT_MS` | `10000` | Default command timeout | + +## Available tools + +- `list_browsers` — list connected browsers +- `set_browser` — choose a browser for this session +- `navigate` — open a URL +- `click` — click an element by selector +- `type` — type text into an input +- `screenshot` — capture the page +- `pageinfo` — get title, URL, tabs + +Each browser-control tool accepts an optional `timeout_ms` argument. +``` + +- [ ] **Step 2: Update README.md** + +Add an MCP section near the usage examples. Keep it short and link to `docs/mcp-setup.md`: + +```markdown +## Use with MCP + +Browser Bridge also exposes a [Streamable HTTP MCP server](docs/mcp-setup.md). Once the WebSocket server is running, add `http://localhost:3003/mcp` to your MCP client (Claude Desktop, Cursor, etc.) to control browsers directly. +``` + +- [ ] **Step 3: Run format/lint** + +Run: + +```bash +bunx @biomejs/biome check --write . +``` + +Expected: No unfixable errors. + +- [ ] **Step 4: Commit** + +```bash +git add docs/mcp-setup.md README.md +git commit -m "docs: add MCP setup guide and README section" +``` + +--- + +## Task 17: Final Verification + +**Goal:** Ensure everything works together. + +- [ ] **Step 1: Run full test suite** + +Run: + +```bash +bun run test +``` + +Expected: All tests pass. + +- [ ] **Step 2: Run type check** + +Run: + +```bash +bun run type-check +``` + +Expected: No errors. + +- [ ] **Step 3: Run Biome check** + +Run: + +```bash +bunx @biomejs/biome check . +``` + +Expected: No errors. + +- [ ] **Step 4: Smoke test** + +Run: + +```bash +bun run dev:websocket +``` + +In another terminal, verify the port is open and responds: + +```bash +lsof -i :3003 | grep LISTEN +``` + +Expected: A process is listening on port 3003. + +To verify the MCP protocol works, write a small script using `@modelcontextprotocol/sdk` (or FastMCP's own client) to initialize a session and call `tools/list`. Place this script in `scripts/mcp-smoke-test.ts` if you want to reuse it. + +Example smoke test script: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +const client = new Client({ name: 'smoke-test', version: '1.0.0' }); +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3003/mcp')); +await client.connect(transport); +const tools = await client.listTools(); +console.log(tools.tools.map((t) => t.name)); +await client.close(); +``` + +Run it with `bun run scripts/mcp-smoke-test.ts`. Expected: prints the names of the registered tools. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add . +git commit -m "chore: final MCP verification fixes" +``` + +--- + +## Self-Review Checklist + +- [x] Spec coverage: every design requirement maps to one or more tasks. +- [x] No placeholders: each step contains concrete code or commands. +- [x] Type consistency: `ToolContext`, `ServerContext`, `BrowserSession`, and `BrowserSessionStore` are used consistently. +- [x] File paths match the monorepo structure. +- [x] Each task is small enough for one focused implementation session. + +**Spec coverage mapping:** + +| Spec Section | Plan Task(s) | +|--------------|--------------| +| Co-located MCP in websocket app | 14, 15 | +| Dedicated port 3003 | 14, 15 | +| No localhost auth | 14 (binds to 127.0.0.1) | +| Curated tools | 7, 9–13 | +| Auto-detect single browser | 4, 6, 9–13 | +| Per-tool `timeout_ms` | 7, 9–13 schemas | +| README + docs/mcp-setup.md | 16 | +| 80%+ test coverage | every task includes tests | From d66f0b5e4f3ba75e68f442c0649e306b5bb71e4c Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 11:54:10 +0800 Subject: [PATCH 02/39] chore: add fastmcp and zod to websocket app --- apps/websocket/package.json | 4 +++- bun.lockb | Bin 33248 -> 99912 bytes 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/websocket/package.json b/apps/websocket/package.json index b41f3be..c050214 100644 --- a/apps/websocket/package.json +++ b/apps/websocket/package.json @@ -13,7 +13,9 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@browser-bridge/shared": "workspace:*" + "@browser-bridge/shared": "workspace:*", + "fastmcp": "^3.35.0", + "zod": "^3.25.0" }, "devDependencies": { "@types/bun": "latest", diff --git a/bun.lockb b/bun.lockb index cea255c709936c5cd4d521188973e0f2cda0179d..64ad869a2e3c03000a7ee069f765f51bd2a9f1a4 100755 GIT binary patch literal 99912 zcmeFac|2BM+dh05GlyiBnUHx_=6NO}$~?=IDWsC2C}l`yWhnEMA)?5bM1@j>WKK$& zL;TjtwZHd%?%(~C^1gq(@8@2hcE`1b<2cW?*16YSYhMy4yKsP?uduzFm#~vpAe()F z7YVooJ-i)`IJpUvh2z)TOcwoJ+cYwc>FNgs!`OrT~fUw-b+xv(c=x3mvZ!jERZ!ZUs!(fj2+WGi^ zl5r5~uw6ekK3V`myOmgU_4e|H?Q=mM>YfJ(`w@o4DgGnNO@6 z`lmo#?*#R>0HHl|fN)&kYEEaKW*t-G{_9F=(?0+=p z20HmU*m>AFIyrc_1-Jnn8Lkce;{aj%U{M(`OBjs5hod{R;}hT&?BETeIl$8gmS5!F z@XHO@gSw178@walcCZ?gj?E+Y2XcQQ_roNphdkuIaCGwX_w^3OB!F_*&yD%?!+;@n z@pbdT`1|=eK+DMe{O@t`@AsuS@C$@iSQh_=pLRiR-mo7U0vq$50}$Gm0tn-MH$XV= ztN@|?J9{?xOoAKZTMqJYzHWj%Y-i^e?B#I8&EJ4jh` zQEdJRK!g#LJ8(GrUqSQNwJE-aE@ z@r%p`ZxSFJx5rq#gT-PjrUHa(h#PIE$6_N6JpEXs%(tgBY@!6532+SZuPK4Z2l-f5VB#$0HMAJHcyGoe^B1w zJp#xH>dOFf0K5ng>Ud+(7$DS_1PJ}1#g;ECZRB49g!9-B5XQlNsSR}1--t_dEaDh! z*b@|a%?yx-qQBL;VgnV~&MNWS@zTnauP5p)=LD=#FPp!Nx zvG=~tdE@lP+)R(cqisCp2jY~%%XKC=8`7QVHIv-i6a|Zj+=q89PJb6aGR299d#6-O zk=b14>h_N*$*c;Gviqc}Rhl1uur3{@m2CN9&^LWiiIFWjMomy^@{Ok2sTN6ddI{Ds zMcK+;>hC^!GRNH0X)3t%=80t9%B5LtJ4=PMtFRbol6lf`O;D#({iu-lu2EIxkx}F2 zB7@<;WiOv5{*2oKbqc{Z!-*gI7@_c>^=G;dNL;K`xh>=2vvQaEY=^CPR-+4p!vmaK{Pz`ydzzv% zYHA86Lbgknf4Uffk$A<}-RVo&T=(Jo;du8$!j^Xi@VS_5KPE&TqBYvn{dA%1@eXxA zn^l(hZ_Ÿ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 Cz_N@0 delta 4602 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 S4WAGgSFa7MXL9LU{@|}PUUUQi From a7029d89b454516006ebcddf74a2da74d34fd2b3 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 11:57:40 +0800 Subject: [PATCH 03/39] feat(mcp): add per-session browser state store --- .../src/mcp/__tests__/browser-session.test.ts | 34 ++++++++++++++++ apps/websocket/src/mcp/browser-session.ts | 39 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/browser-session.test.ts create mode 100644 apps/websocket/src/mcp/browser-session.ts diff --git a/apps/websocket/src/mcp/__tests__/browser-session.test.ts b/apps/websocket/src/mcp/__tests__/browser-session.test.ts new file mode 100644 index 0000000..8c9f14e --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/browser-session.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test'; +import { createBrowserSessionStore } from '../browser-session'; + +describe('BrowserSessionStore', () => { + it('creates a session with defaults', () => { + const store = createBrowserSessionStore(15000); + const session = store.createSession('session-1'); + expect(session.getBrowser()).toBeUndefined(); + expect(session.defaultTimeoutMs).toBe(15000); + }); + + it('sets and retrieves browserId', () => { + const store = createBrowserSessionStore(10000); + const session = store.createSession('session-1'); + session.setBrowser('browser-a'); + expect(session.getBrowser()).toBe('browser-a'); + }); + + it('clears browserId', () => { + const store = createBrowserSessionStore(10000); + const session = store.createSession('session-1'); + session.setBrowser('browser-a'); + session.clearBrowser(); + expect(session.getBrowser()).toBeUndefined(); + }); + + it('is isolated per session', () => { + const store = createBrowserSessionStore(10000); + const a = store.createSession('a'); + const b = store.createSession('b'); + a.setBrowser('browser-a'); + expect(b.getBrowser()).toBeUndefined(); + }); +}); diff --git a/apps/websocket/src/mcp/browser-session.ts b/apps/websocket/src/mcp/browser-session.ts new file mode 100644 index 0000000..43f0583 --- /dev/null +++ b/apps/websocket/src/mcp/browser-session.ts @@ -0,0 +1,39 @@ +export interface BrowserSession { + readonly defaultTimeoutMs: number; + getBrowser(): string | undefined; + setBrowser(browserId: string): void; + clearBrowser(): void; +} + +export interface BrowserSessionStore { + createSession(sessionId: string): BrowserSession; +} + +export function createBrowserSessionStore( + defaultTimeoutMs: number, +): BrowserSessionStore { + const sessions = new Map(); + + return { + createSession(sessionId: string): BrowserSession { + const existing = sessions.get(sessionId); + if (existing) return existing; + + let browserId: string | undefined; + + const session: BrowserSession = { + defaultTimeoutMs, + getBrowser: () => browserId, + setBrowser: (id: string) => { + browserId = id; + }, + clearBrowser: () => { + browserId = undefined; + }, + }; + + sessions.set(sessionId, session); + return session; + }, + }; +} From f11d9b6eed5f25d65b394bc8c09fdbc6c970e89b Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 12:08:27 +0800 Subject: [PATCH 04/39] docs: update plan to use immutable session snapshots --- .../plans/2026-06-26-mcp-streamable-http.md | 222 ++++++++++-------- 1 file changed, 125 insertions(+), 97 deletions(-) diff --git a/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md b/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md index 0caff09..1866dc2 100644 --- a/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md +++ b/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md @@ -16,7 +16,7 @@ |------|----------------| | `apps/websocket/package.json` | Add `fastmcp` and `zod` dependencies | | `apps/websocket/src/index.ts` | Bootstrap FastMCP alongside Bun.serve | -| `apps/websocket/src/mcp/browser-session.ts` | Per-MCP-session state: selected browserId and timeout | +| `apps/websocket/src/mcp/browser-session.ts` | Per-MCP-session state: immutable session snapshots, mutable store | | `apps/websocket/src/mcp/tool-context.ts` | `ToolContext` and `ServerContext` types | | `apps/websocket/src/mcp/browser-resolver.ts` | Resolve target browserId: explicit → auto-detect single → error | | `apps/websocket/src/mcp/command-client.ts` | Short-lived WebSocket client wrappers: `sendCommand`, `sendEvent` | @@ -104,7 +104,7 @@ git commit -m "chore: add fastmcp and zod to websocket app" - Create: `apps/websocket/src/mcp/browser-session.ts` - Create: `apps/websocket/src/mcp/__tests__/browser-session.test.ts` -**Goal:** Store selected browserId and timeout per MCP session. +**Goal:** Store selected browserId and timeout per MCP session. Session snapshots are immutable; the store owns mutable state. - [ ] **Step 1: Write the failing test** @@ -117,32 +117,46 @@ import { createBrowserSessionStore } from '../browser-session'; describe('BrowserSessionStore', () => { it('creates a session with defaults', () => { const store = createBrowserSessionStore(15000); - const session = store.createSession('session-1'); - expect(session.getBrowser()).toBeUndefined(); + const session = store.getSession('session-1'); + expect(session.browserId).toBeUndefined(); expect(session.defaultTimeoutMs).toBe(15000); }); it('sets and retrieves browserId', () => { const store = createBrowserSessionStore(10000); - const session = store.createSession('session-1'); - session.setBrowser('browser-a'); - expect(session.getBrowser()).toBe('browser-a'); + const session = store.setBrowser('session-1', 'browser-a'); + expect(session.browserId).toBe('browser-a'); + expect(store.getSession('session-1').browserId).toBe('browser-a'); }); it('clears browserId', () => { const store = createBrowserSessionStore(10000); - const session = store.createSession('session-1'); - session.setBrowser('browser-a'); - session.clearBrowser(); - expect(session.getBrowser()).toBeUndefined(); + store.setBrowser('session-1', 'browser-a'); + const session = store.clearBrowser('session-1'); + expect(session.browserId).toBeUndefined(); + expect(store.getSession('session-1').browserId).toBeUndefined(); }); it('is isolated per session', () => { const store = createBrowserSessionStore(10000); - const a = store.createSession('a'); - const b = store.createSession('b'); - a.setBrowser('browser-a'); - expect(b.getBrowser()).toBeUndefined(); + store.setBrowser('a', 'browser-a'); + expect(store.getSession('b').browserId).toBeUndefined(); + }); + + it('returns the same session object for repeated getSession calls', () => { + const store = createBrowserSessionStore(10000); + const a = store.getSession('session-1'); + const b = store.getSession('session-1'); + expect(a).toBe(b); + }); + + it('returns an updated immutable session when setBrowser is called', () => { + const store = createBrowserSessionStore(10000); + const original = store.getSession('session-1'); + const updated = store.setBrowser('session-1', 'browser-a'); + expect(updated).not.toBe(original); + expect(original.browserId).toBeUndefined(); + expect(updated.browserId).toBe('browser-a'); }); }); ``` @@ -164,38 +178,40 @@ Create `apps/websocket/src/mcp/browser-session.ts`: ```typescript export interface BrowserSession { readonly defaultTimeoutMs: number; - getBrowser(): string | undefined; - setBrowser(browserId: string): void; - clearBrowser(): void; + readonly browserId: string | undefined; } export interface BrowserSessionStore { - createSession(sessionId: string): BrowserSession; + getSession(sessionId: string): BrowserSession; + setBrowser(sessionId: string, browserId: string): BrowserSession; + clearBrowser(sessionId: string): BrowserSession; } export function createBrowserSessionStore(defaultTimeoutMs: number): BrowserSessionStore { const sessions = new Map(); - return { - createSession(sessionId: string): BrowserSession { - const existing = sessions.get(sessionId); - if (existing) return existing; - - let browserId: string | undefined; - - const session: BrowserSession = { - defaultTimeoutMs, - getBrowser: () => browserId, - setBrowser: (id: string) => { - browserId = id; - }, - clearBrowser: () => { - browserId = undefined; - }, - }; - + function ensureSession(sessionId: string): BrowserSession { + let session = sessions.get(sessionId); + if (!session) { + session = { defaultTimeoutMs, browserId: undefined }; sessions.set(sessionId, session); - return session; + } + return session; + } + + return { + getSession: ensureSession, + setBrowser(sessionId: string, browserId: string): BrowserSession { + const session = ensureSession(sessionId); + const updated = { ...session, browserId }; + sessions.set(sessionId, updated); + return updated; + }, + clearBrowser(sessionId: string): BrowserSession { + const session = ensureSession(sessionId); + const updated = { ...session, browserId: undefined }; + sessions.set(sessionId, updated); + return updated; }, }; } @@ -215,7 +231,7 @@ Expected: PASS. ```bash git add apps/websocket/src/mcp/browser-session.ts apps/websocket/src/mcp/__tests__/browser-session.test.ts -git commit -m "feat(mcp): add per-session browser state store" +git commit -m "feat(mcp): add immutable per-session browser state store" ``` --- @@ -232,10 +248,11 @@ git commit -m "feat(mcp): add per-session browser state store" Create `apps/websocket/src/mcp/tool-context.ts`: ```typescript -import type { BrowserSession, BrowserSessionStore } from './browser-session'; +import type { BrowserSessionStore } from './browser-session'; export interface ToolContext { - session: BrowserSession; + sessionId: string; + sessions: BrowserSessionStore; websocketUrl: string; } @@ -725,15 +742,20 @@ function createListServer(browsers: unknown[]) { }); } +function makeContext(server: ReturnType, sessionId: string) { + const sessions = createBrowserSessionStore(10000); + return { + sessionId, + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }; +} + describe('fetchBrowserList', () => { it('returns parsed browser list', async () => { const server = createListServer([{ browserId: 'a', status: 'online' }]); - const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); - const list = await fetchBrowserList( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, - 1000, - ); + const context = makeContext(server, 's1'); + const list = await fetchBrowserList(context, 1000); expect(list).toHaveLength(1); expect(list[0].browserId).toBe('a'); server.stop(); @@ -746,14 +768,10 @@ describe('resolveTargetBrowser', () => { { browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }, { browserId: 'b', userId: 'u', status: 'online', lastSeen: Date.now() }, ]); - const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); - session.setBrowser('a'); + const context = makeContext(server, 's1'); + context.sessions.setBrowser('s1', 'a'); - const result = await resolveTargetBrowser( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, - 1000, - ); + const result = await resolveTargetBrowser(context, 1000); expect(result.success).toBe(true); if (result.success) expect(result.browserId).toBe('a'); @@ -764,13 +782,9 @@ describe('resolveTargetBrowser', () => { const server = createListServer([ { browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }, ]); - const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); + const context = makeContext(server, 's1'); - const result = await resolveTargetBrowser( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, - 1000, - ); + const result = await resolveTargetBrowser(context, 1000); expect(result.success).toBe(true); if (result.success) expect(result.browserId).toBe('a'); @@ -821,7 +835,7 @@ export async function resolveTargetBrowser( context: ToolContext, timeoutMs: number, ): Promise { - const explicit = context.session.getBrowser(); + const explicit = context.sessions.getSession(context.sessionId).browserId; const browsers = await fetchBrowserList(context, timeoutMs); return resolveBrowser(explicit, browsers); } @@ -900,9 +914,8 @@ describe('executeListBrowsers', () => { }); const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); const result = await executeListBrowsers( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { sessionId: 's1', sessions, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, {}, ); @@ -940,7 +953,7 @@ export async function executeListBrowsers( context: ToolContext, args: z.infer, ): Promise { - const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const timeoutMs = args.timeout_ms ?? context.sessions.getSession(context.sessionId).defaultTimeoutMs; const result = await sendEvent({ serverUrl: context.websocketUrl, @@ -969,8 +982,11 @@ export function registerListBrowsersTool(server: FastMCP, serverContext: ServerC description: 'List all browsers connected to Browser Bridge.', parameters: ListBrowsersInputSchema, execute: async (args, { sessionId }) => { - const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); - return executeListBrowsers({ websocketUrl: serverContext.websocketUrl, session }, args); + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeListBrowsers( + { sessionId: resolvedSessionId, sessions: serverContext.sessions, websocketUrl: serverContext.websocketUrl }, + args, + ); }, }); } @@ -1015,13 +1031,12 @@ import { createBrowserSessionStore } from '../../browser-session'; describe('executeSetBrowser', () => { it('sets the browser in session state', async () => { const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); const result = await executeSetBrowser( - { session, websocketUrl: 'ws://localhost:3001' }, + { sessionId: 's1', sessions, websocketUrl: 'ws://localhost:3001' }, { browserId: 'browser-a' }, ); expect(result).toContain('browser-a'); - expect(session.getBrowser()).toBe('browser-a'); + expect(sessions.getSession('s1').browserId).toBe('browser-a'); }); }); ``` @@ -1053,7 +1068,7 @@ export async function executeSetBrowser( context: ToolContext, args: z.infer, ): Promise { - context.session.setBrowser(args.browserId); + context.sessions.setBrowser(context.sessionId, args.browserId); return `Browser set to "${args.browserId}" for this session.`; } @@ -1063,8 +1078,11 @@ export function registerSetBrowserTool(server: FastMCP, serverContext: ServerCon description: 'Explicitly choose which connected browser to control for this MCP session.', parameters: SetBrowserInputSchema, execute: async (args, { sessionId }) => { - const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); - return executeSetBrowser({ websocketUrl: serverContext.websocketUrl, session }, args); + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeSetBrowser( + { sessionId: resolvedSessionId, sessions: serverContext.sessions, websocketUrl: serverContext.websocketUrl }, + args, + ); }, }); } @@ -1146,10 +1164,9 @@ describe('executeNavigate', () => { }); const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); const result = await executeNavigate( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { sessionId: 's1', sessions, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, { url: 'https://example.com' }, ); @@ -1189,7 +1206,7 @@ export async function executeNavigate( context: ToolContext, args: z.infer, ): Promise { - const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const timeoutMs = args.timeout_ms ?? context.sessions.getSession(context.sessionId).defaultTimeoutMs; const resolution = await resolveTargetBrowser(context, timeoutMs); if (!resolution.success) { @@ -1217,8 +1234,11 @@ export function registerNavigateTool(server: FastMCP, serverContext: ServerConte description: 'Navigate the active tab of the selected browser to a URL.', parameters: NavigateInputSchema, execute: async (args, { sessionId }) => { - const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); - return executeNavigate({ websocketUrl: serverContext.websocketUrl, session }, args); + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeNavigate( + { sessionId: resolvedSessionId, sessions: serverContext.sessions, websocketUrl: serverContext.websocketUrl }, + args, + ); }, }); } @@ -1300,9 +1320,8 @@ describe('executeClick', () => { }); const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); const result = await executeClick( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { sessionId: 's1', sessions, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, { selector: '#submit' }, ); expect(result).toContain('Clicked'); @@ -1341,7 +1360,7 @@ export async function executeClick( context: ToolContext, args: z.infer, ): Promise { - const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const timeoutMs = args.timeout_ms ?? context.sessions.getSession(context.sessionId).defaultTimeoutMs; const resolution = await resolveTargetBrowser(context, timeoutMs); if (!resolution.success) throw new Error(resolution.message); @@ -1363,8 +1382,11 @@ export function registerClickTool(server: FastMCP, serverContext: ServerContext) description: 'Click an element in the selected browser by CSS selector.', parameters: ClickInputSchema, execute: async (args, { sessionId }) => { - const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); - return executeClick({ websocketUrl: serverContext.websocketUrl, session }, args); + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeClick( + { sessionId: resolvedSessionId, sessions: serverContext.sessions, websocketUrl: serverContext.websocketUrl }, + args, + ); }, }); } @@ -1446,9 +1468,8 @@ describe('executeType', () => { }); const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); const result = await executeType( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { sessionId: 's1', sessions, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, { selector: '#search', text: 'hello' }, ); expect(result).toContain('Typed'); @@ -1489,7 +1510,7 @@ export async function executeType( context: ToolContext, args: z.infer, ): Promise { - const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const timeoutMs = args.timeout_ms ?? context.sessions.getSession(context.sessionId).defaultTimeoutMs; const resolution = await resolveTargetBrowser(context, timeoutMs); if (!resolution.success) throw new Error(resolution.message); @@ -1511,8 +1532,11 @@ export function registerTypeTool(server: FastMCP, serverContext: ServerContext): description: 'Type text into an input element in the selected browser.', parameters: TypeInputSchema, execute: async (args, { sessionId }) => { - const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); - return executeType({ websocketUrl: serverContext.websocketUrl, session }, args); + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeType( + { sessionId: resolvedSessionId, sessions: serverContext.sessions, websocketUrl: serverContext.websocketUrl }, + args, + ); }, }); } @@ -1597,9 +1621,8 @@ describe('executeScreenshot', () => { }); const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); const result = await executeScreenshot( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { sessionId: 's1', sessions, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, {}, ); @@ -1640,7 +1663,7 @@ export async function executeScreenshot( context: ToolContext, args: z.infer, ): Promise<{ content: Array<{ type: 'image'; data: string; mimeType: 'image/png' }> }> { - const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const timeoutMs = args.timeout_ms ?? context.sessions.getSession(context.sessionId).defaultTimeoutMs; const resolution = await resolveTargetBrowser(context, timeoutMs); if (!resolution.success) throw new Error(resolution.message); @@ -1666,8 +1689,11 @@ export function registerScreenshotTool(server: FastMCP, serverContext: ServerCon description: 'Take a screenshot of the selected browser.', parameters: ScreenshotInputSchema, execute: async (args, { sessionId }) => { - const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); - return executeScreenshot({ websocketUrl: serverContext.websocketUrl, session }, args); + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeScreenshot( + { sessionId: resolvedSessionId, sessions: serverContext.sessions, websocketUrl: serverContext.websocketUrl }, + args, + ); }, }); } @@ -1749,9 +1775,8 @@ describe('executePageinfo', () => { }); const sessions = createBrowserSessionStore(10000); - const session = sessions.createSession('s1'); const result = await executePageinfo( - { session, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, + { sessionId: 's1', sessions, websocketUrl: `ws://127.0.0.1:${server.port}/ws` }, {}, ); expect(result).toContain('Example'); @@ -1790,7 +1815,7 @@ export async function executePageinfo( context: ToolContext, args: z.infer, ): Promise { - const timeoutMs = args.timeout_ms ?? context.session.defaultTimeoutMs; + const timeoutMs = args.timeout_ms ?? context.sessions.getSession(context.sessionId).defaultTimeoutMs; const resolution = await resolveTargetBrowser(context, timeoutMs); if (!resolution.success) throw new Error(resolution.message); @@ -1812,8 +1837,11 @@ export function registerPageinfoTool(server: FastMCP, serverContext: ServerConte description: 'Get title, URL, and tab list from the selected browser.', parameters: PageinfoInputSchema, execute: async (args, { sessionId }) => { - const session = serverContext.sessions.createSession(sessionId ?? 'anonymous'); - return executePageinfo({ websocketUrl: serverContext.websocketUrl, session }, args); + const resolvedSessionId = sessionId ?? 'anonymous'; + return executePageinfo( + { sessionId: resolvedSessionId, sessions: serverContext.sessions, websocketUrl: serverContext.websocketUrl }, + args, + ); }, }); } From 249b1e4f3b323de48b0089f56e8e04a6a89d7132 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 12:09:43 +0800 Subject: [PATCH 05/39] feat(mcp): add immutable per-session browser state store --- .../src/mcp/__tests__/browser-session.test.ts | 40 +++++++++++----- apps/websocket/src/mcp/browser-session.ts | 48 ++++++++++--------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/apps/websocket/src/mcp/__tests__/browser-session.test.ts b/apps/websocket/src/mcp/__tests__/browser-session.test.ts index 8c9f14e..ba4fb17 100644 --- a/apps/websocket/src/mcp/__tests__/browser-session.test.ts +++ b/apps/websocket/src/mcp/__tests__/browser-session.test.ts @@ -4,31 +4,45 @@ import { createBrowserSessionStore } from '../browser-session'; describe('BrowserSessionStore', () => { it('creates a session with defaults', () => { const store = createBrowserSessionStore(15000); - const session = store.createSession('session-1'); - expect(session.getBrowser()).toBeUndefined(); + const session = store.getSession('session-1'); + expect(session.browserId).toBeUndefined(); expect(session.defaultTimeoutMs).toBe(15000); }); it('sets and retrieves browserId', () => { const store = createBrowserSessionStore(10000); - const session = store.createSession('session-1'); - session.setBrowser('browser-a'); - expect(session.getBrowser()).toBe('browser-a'); + const session = store.setBrowser('session-1', 'browser-a'); + expect(session.browserId).toBe('browser-a'); + expect(store.getSession('session-1').browserId).toBe('browser-a'); }); it('clears browserId', () => { const store = createBrowserSessionStore(10000); - const session = store.createSession('session-1'); - session.setBrowser('browser-a'); - session.clearBrowser(); - expect(session.getBrowser()).toBeUndefined(); + store.setBrowser('session-1', 'browser-a'); + const session = store.clearBrowser('session-1'); + expect(session.browserId).toBeUndefined(); + expect(store.getSession('session-1').browserId).toBeUndefined(); }); it('is isolated per session', () => { const store = createBrowserSessionStore(10000); - const a = store.createSession('a'); - const b = store.createSession('b'); - a.setBrowser('browser-a'); - expect(b.getBrowser()).toBeUndefined(); + store.setBrowser('a', 'browser-a'); + expect(store.getSession('b').browserId).toBeUndefined(); + }); + + it('returns the same session object for repeated getSession calls', () => { + const store = createBrowserSessionStore(10000); + const a = store.getSession('session-1'); + const b = store.getSession('session-1'); + expect(a).toBe(b); + }); + + it('returns an updated immutable session when setBrowser is called', () => { + const store = createBrowserSessionStore(10000); + const original = store.getSession('session-1'); + const updated = store.setBrowser('session-1', 'browser-a'); + expect(updated).not.toBe(original); + expect(original.browserId).toBeUndefined(); + expect(updated.browserId).toBe('browser-a'); }); }); diff --git a/apps/websocket/src/mcp/browser-session.ts b/apps/websocket/src/mcp/browser-session.ts index 43f0583..8fe896a 100644 --- a/apps/websocket/src/mcp/browser-session.ts +++ b/apps/websocket/src/mcp/browser-session.ts @@ -1,12 +1,12 @@ export interface BrowserSession { readonly defaultTimeoutMs: number; - getBrowser(): string | undefined; - setBrowser(browserId: string): void; - clearBrowser(): void; + readonly browserId: string | undefined; } export interface BrowserSessionStore { - createSession(sessionId: string): BrowserSession; + getSession(sessionId: string): BrowserSession; + setBrowser(sessionId: string, browserId: string): BrowserSession; + clearBrowser(sessionId: string): BrowserSession; } export function createBrowserSessionStore( @@ -14,26 +14,28 @@ export function createBrowserSessionStore( ): BrowserSessionStore { const sessions = new Map(); - return { - createSession(sessionId: string): BrowserSession { - const existing = sessions.get(sessionId); - if (existing) return existing; - - let browserId: string | undefined; - - const session: BrowserSession = { - defaultTimeoutMs, - getBrowser: () => browserId, - setBrowser: (id: string) => { - browserId = id; - }, - clearBrowser: () => { - browserId = undefined; - }, - }; - + function ensureSession(sessionId: string): BrowserSession { + let session = sessions.get(sessionId); + if (!session) { + session = { defaultTimeoutMs, browserId: undefined }; sessions.set(sessionId, session); - return session; + } + return session; + } + + return { + getSession: ensureSession, + setBrowser(sessionId: string, browserId: string): BrowserSession { + const session = ensureSession(sessionId); + const updated = { ...session, browserId }; + sessions.set(sessionId, updated); + return updated; + }, + clearBrowser(sessionId: string): BrowserSession { + const session = ensureSession(sessionId); + const updated = { ...session, browserId: undefined }; + sessions.set(sessionId, updated); + return updated; }, }; } From caf9477e208f79d0f3a1d07e183aea417f19d98e Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 12:12:22 +0800 Subject: [PATCH 06/39] feat(mcp): add tool and server context types --- apps/websocket/src/mcp/tool-context.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 apps/websocket/src/mcp/tool-context.ts diff --git a/apps/websocket/src/mcp/tool-context.ts b/apps/websocket/src/mcp/tool-context.ts new file mode 100644 index 0000000..4e9c6f1 --- /dev/null +++ b/apps/websocket/src/mcp/tool-context.ts @@ -0,0 +1,12 @@ +import type { BrowserSessionStore } from './browser-session'; + +export interface ToolContext { + sessionId: string; + sessions: BrowserSessionStore; + websocketUrl: string; +} + +export interface ServerContext { + websocketUrl: string; + sessions: BrowserSessionStore; +} From d97524346cc1d51fc4bcd055d7d535354ebfa33b Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 12:21:43 +0800 Subject: [PATCH 07/39] feat(mcp): add command and event client wrappers --- .../src/mcp/__tests__/command-client.test.ts | 143 ++++++++++++++++++ apps/websocket/src/mcp/command-client.ts | 96 ++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/command-client.test.ts create mode 100644 apps/websocket/src/mcp/command-client.ts diff --git a/apps/websocket/src/mcp/__tests__/command-client.test.ts b/apps/websocket/src/mcp/__tests__/command-client.test.ts new file mode 100644 index 0000000..2ed9766 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/command-client.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, afterEach } from 'bun:test'; +import type { Server } from 'bun'; +import { sendCommand, sendEvent } from '../command-client'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('sendCommand', () => { + let server: Server | undefined; + + afterEach(() => { + server?.stop(); + server = undefined; + }); + + it('sends a command and returns the response payload', async () => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + const url = new URL(req.url); + if (url.pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: { status: 'ok', data: { title: 'Example' } }, + timestamp: Date.now(), + }; + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); + }, + close() {}, + }, + }); + + const result = await sendCommand({ + serverUrl: `ws://127.0.0.1:${server.port}/ws`, + browserId: 'browser-a', + command: 'pageinfo', + params: {}, + timeoutMs: 1000, + }); + + expect(result.status).toBe('ok'); + expect(result.data).toEqual({ title: 'Example' }); + }); + + it('rejects on timeout', async () => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + const url = new URL(req.url); + if (url.pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { open() {}, message() {}, close() {} }, + }); + + await expect( + sendCommand({ + serverUrl: `ws://127.0.0.1:${server.port}/ws`, + browserId: 'browser-a', + command: 'pageinfo', + params: {}, + timeoutMs: 50, + }), + ).rejects.toThrow('timeout'); + }); +}); + +describe('sendEvent', () => { + let server: Server | undefined; + + afterEach(() => { + server?.stop(); + server = undefined; + }); + + it('sends an event and returns the response payload', async () => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + const url = new URL(req.url); + if (url.pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: { + status: 'ok', + data: [{ browserId: 'a', status: 'online' }], + }, + timestamp: Date.now(), + }; + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); + }, + close() {}, + }, + }); + + const result = await sendEvent({ + serverUrl: `ws://127.0.0.1:${server.port}/ws`, + event: 'list_browsers', + payload: {}, + timeoutMs: 1000, + }); + + expect(result.status).toBe('ok'); + expect(Array.isArray(result.data)).toBe(true); + }); +}); diff --git a/apps/websocket/src/mcp/command-client.ts b/apps/websocket/src/mcp/command-client.ts new file mode 100644 index 0000000..bd06492 --- /dev/null +++ b/apps/websocket/src/mcp/command-client.ts @@ -0,0 +1,96 @@ +import { createClient } from '@browser-bridge/websocket/client'; +import type { + CommandPayload, + CommandType, + ResponsePayload, +} from '@browser-bridge/shared'; + +export interface SendCommandOptions { + serverUrl: string; + browserId: string; + command: CommandType; + params: Record; + timeoutMs: number; +} + +export interface SendEventOptions { + serverUrl: string; + event: string; + payload: Record; + timeoutMs: number; +} + +export async function sendCommand( + options: SendCommandOptions, +): Promise { + const client = createClient({ url: options.serverUrl }); + + try { + await waitForOpen(client, Math.min(options.timeoutMs, 5000)); + + const payload: CommandPayload = { + command: options.command, + params: options.params, + }; + + const envelope = await client.sendCommand(options.browserId, payload, { + timeout: options.timeoutMs, + }); + + return (envelope.payload ?? { + status: 'error', + error: 'Empty response', + }) as ResponsePayload; + } finally { + client.close(); + } +} + +export async function sendEvent( + options: SendEventOptions, +): Promise { + const client = createClient({ url: options.serverUrl }); + + try { + await waitForOpen(client, Math.min(options.timeoutMs, 5000)); + + const envelope = await client.request( + 'event', + { event: options.event, ...options.payload }, + { timeout: options.timeoutMs }, + ); + + return (envelope.payload ?? { + status: 'error', + error: 'Empty response', + }) as ResponsePayload; + } finally { + client.close(); + } +} + +function waitForOpen( + client: ReturnType, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + if (client.readyState === WebSocket.OPEN) { + resolve(); + return; + } + + const start = Date.now(); + const interval = setInterval(() => { + if (client.readyState === WebSocket.OPEN) { + clearInterval(interval); + resolve(); + return; + } + if (Date.now() - start > timeoutMs) { + clearInterval(interval); + client.close(); + reject(new Error('Failed to connect to WebSocket server')); + } + }, 10); + }); +} From 4640fdf6d52b716190bc86c1436e6ad118b7c02b Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 13:35:59 +0800 Subject: [PATCH 08/39] docs: fix encode signature in MCP plan test templates --- .../plans/2026-06-26-mcp-streamable-http.md | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md b/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md index 1866dc2..86005ba 100644 --- a/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md +++ b/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md @@ -481,7 +481,12 @@ describe('sendCommand', () => { payload: { status: 'ok', data: { title: 'Example' } }, timestamp: Date.now(), }; - ws.send(encode(response)); + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); }, close() {}, }, @@ -557,7 +562,12 @@ describe('sendEvent', () => { payload: { status: 'ok', data: [{ browserId: 'a', status: 'online' }] }, timestamp: Date.now(), }; - ws.send(encode(response)); + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); }, close() {}, }, @@ -735,7 +745,12 @@ function createListServer(browsers: unknown[]) { payload: { status: 'ok', data: browsers }, timestamp: Date.now(), }; - ws.send(encode(response)); + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); }, close() {}, }, @@ -907,7 +922,12 @@ describe('executeListBrowsers', () => { }, timestamp: Date.now(), }; - ws.send(encode(response)); + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); }, close() {}, }, @@ -1150,12 +1170,9 @@ describe('executeNavigate', () => { } : { status: 'ok', message: 'Navigated' }; ws.send( - encode({ + encode('response', payload, { id: envelope.id, - type: 'response', browserId: envelope.browserId, - payload, - timestamp: Date.now(), }), ); }, @@ -1306,12 +1323,9 @@ describe('executeClick', () => { } : { status: 'ok', message: 'Clicked' }; ws.send( - encode({ + encode('response', payload, { id: envelope.id, - type: 'response', browserId: envelope.browserId, - payload, - timestamp: Date.now(), }), ); }, @@ -1454,12 +1468,9 @@ describe('executeType', () => { } : { status: 'ok', message: 'Typed' }; ws.send( - encode({ + encode('response', payload, { id: envelope.id, - type: 'response', browserId: envelope.browserId, - payload, - timestamp: Date.now(), }), ); }, @@ -1607,12 +1618,9 @@ describe('executeScreenshot', () => { data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', }; ws.send( - encode({ + encode('response', payload, { id: envelope.id, - type: 'response', browserId: envelope.browserId, - payload, - timestamp: Date.now(), }), ); }, @@ -1761,12 +1769,9 @@ describe('executePageinfo', () => { } : { status: 'ok', data: { title: 'Example', url: 'https://example.com' } }; ws.send( - encode({ + encode('response', payload, { id: envelope.id, - type: 'response', browserId: envelope.browserId, - payload, - timestamp: Date.now(), }), ); }, @@ -1917,7 +1922,12 @@ describe('startMcpServer', () => { payload: { status: 'ok', data: [{ browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }] }, timestamp: Date.now(), }; - ws.send(encode(response)); + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); }, close() {}, }, From 9afee23b7d4f2c900671c9ace0f3174a73d726fc Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 13:41:57 +0800 Subject: [PATCH 09/39] feat(mcp): add browser lookup helper --- .../src/mcp/__tests__/browser-lookup.test.ts | 183 ++++++++++++++++++ apps/websocket/src/mcp/browser-lookup.ts | 59 ++++++ 2 files changed, 242 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/browser-lookup.test.ts create mode 100644 apps/websocket/src/mcp/browser-lookup.ts diff --git a/apps/websocket/src/mcp/__tests__/browser-lookup.test.ts b/apps/websocket/src/mcp/__tests__/browser-lookup.test.ts new file mode 100644 index 0000000..aae9479 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/browser-lookup.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'bun:test'; +import type { Server } from 'bun'; +import { fetchBrowserList, resolveTargetBrowser } from '../browser-lookup'; +import { createBrowserSessionStore } from '../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope, ResponsePayload } from '@browser-bridge/shared'; + +function createListServer(responsePayload: ResponsePayload) { + return Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: responsePayload, + timestamp: Date.now(), + }; + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); + }, + close() {}, + }, + }); +} + +function createListServerWithBrowsers(browsers: unknown[]) { + return createListServer({ status: 'ok', data: browsers }); +} + +function makeContext(server: Server, sessionId: string) { + const sessions = createBrowserSessionStore(10000); + return { + sessionId, + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }; +} + +describe('fetchBrowserList', () => { + it('returns parsed browser list', async () => { + const server = createListServerWithBrowsers([ + { browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }, + ]); + const context = makeContext(server, 's1'); + try { + const list = await fetchBrowserList(context, 1000); + expect(list).toHaveLength(1); + expect(list[0].browserId).toBe('a'); + } finally { + server.stop(); + } + }); + + it('filters out malformed browser objects', async () => { + const server = createListServerWithBrowsers([ + { browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }, + { notABrowser: true }, + { browserId: 'b', userId: 'u', status: 'online', lastSeen: Date.now() }, + ]); + const context = makeContext(server, 's1'); + try { + const list = await fetchBrowserList(context, 1000); + expect(list).toHaveLength(2); + expect(list.map((b) => b.browserId)).toEqual(['a', 'b']); + } finally { + server.stop(); + } + }); + + it('throws when server returns error', async () => { + const server = createListServer({ + status: 'error', + error: 'something went wrong', + }); + const context = makeContext(server, 's1'); + try { + await expect(fetchBrowserList(context, 1000)).rejects.toThrow( + 'something went wrong', + ); + } finally { + server.stop(); + } + }); + + it('returns empty array when data is not an array', async () => { + const server = createListServer({ + status: 'ok', + data: { notAnArray: true }, + }); + const context = makeContext(server, 's1'); + try { + const list = await fetchBrowserList(context, 1000); + expect(list).toEqual([]); + } finally { + server.stop(); + } + }); +}); + +describe('resolveTargetBrowser', () => { + it('uses explicit session browser', async () => { + const server = createListServerWithBrowsers([ + { browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }, + { browserId: 'b', userId: 'u', status: 'online', lastSeen: Date.now() }, + ]); + const context = makeContext(server, 's1'); + context.sessions.setBrowser('s1', 'a'); + + try { + const result = await resolveTargetBrowser(context, 1000); + + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('a'); + } finally { + server.stop(); + } + }); + + it('auto-detects single browser', async () => { + const server = createListServerWithBrowsers([ + { browserId: 'a', userId: 'u', status: 'online', lastSeen: Date.now() }, + ]); + const context = makeContext(server, 's1'); + + try { + const result = await resolveTargetBrowser(context, 1000); + + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('a'); + } finally { + server.stop(); + } + }); + + it('fails when no browsers are connected', async () => { + const server = createListServerWithBrowsers([]); + const context = makeContext(server, 's1'); + + try { + const result = await resolveTargetBrowser(context, 1000); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.message).toContain('No browser connected'); + } + } finally { + server.stop(); + } + }); + + it('returns failure when listing browsers fails', async () => { + const server = createListServer({ + status: 'error', + error: 'server blew up', + }); + const context = makeContext(server, 's1'); + try { + const result = await resolveTargetBrowser(context, 1000); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.message).toBe('server blew up'); + } + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/browser-lookup.ts b/apps/websocket/src/mcp/browser-lookup.ts new file mode 100644 index 0000000..450656c --- /dev/null +++ b/apps/websocket/src/mcp/browser-lookup.ts @@ -0,0 +1,59 @@ +import type { BrowserConnection } from '@browser-bridge/shared'; +import { sendEvent } from './command-client'; +import { + resolveBrowser, + type BrowserResolutionResult, +} from './browser-resolver'; +import type { ToolContext } from './tool-context'; + +function isBrowserConnection(value: unknown): value is BrowserConnection { + return ( + typeof value === 'object' && + value !== null && + 'browserId' in value && + typeof (value as Record).browserId === 'string' && + 'userId' in value && + typeof (value as Record).userId === 'string' && + 'status' in value && + typeof (value as Record).status === 'string' && + 'lastSeen' in value && + typeof (value as Record).lastSeen === 'number' + ); +} + +export async function fetchBrowserList( + context: ToolContext, + timeoutMs: number, +): Promise { + const result = await sendEvent({ + serverUrl: context.websocketUrl, + event: 'list_browsers', + payload: {}, + timeoutMs, + }); + + if (result.status !== 'ok') { + throw new Error(result.error ?? 'Failed to list browsers'); + } + + if (!Array.isArray(result.data)) { + return []; + } + + return result.data.filter(isBrowserConnection); +} + +export async function resolveTargetBrowser( + context: ToolContext, + timeoutMs: number, +): Promise { + const explicit = context.sessions.getSession(context.sessionId).browserId; + try { + const browsers = await fetchBrowserList(context, timeoutMs); + return resolveBrowser(explicit, browsers); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to list browsers'; + return { success: false, message }; + } +} From 6c22dc887122e24ac8ca7a31385d2cbc2e3377ff Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 14:19:42 +0800 Subject: [PATCH 10/39] feat(mcp): add list_browsers tool --- .../mcp/__tests__/tools/list-browsers.test.ts | 111 ++++++++++++++++++ apps/websocket/src/mcp/tools/list-browsers.ts | 50 ++++++++ 2 files changed, 161 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts create mode 100644 apps/websocket/src/mcp/tools/list-browsers.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts b/apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts new file mode 100644 index 0000000..fe23f78 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/list-browsers.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope, ResponsePayload } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeListBrowsers } from '../../tools/list-browsers'; + +function createListServer(responsePayload: ResponsePayload) { + return Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: responsePayload, + timestamp: Date.now(), + }; + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); + }, + close() {}, + }, + }); +} + +describe('executeListBrowsers', () => { + it('returns a list of browsers', async () => { + const server = createListServer({ + status: 'ok', + data: [ + { + browserId: 'browser-a', + userId: 'u1', + status: 'online', + lastSeen: Date.now(), + }, + ], + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeListBrowsers( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + + expect(result).toContain('browser-a'); + } finally { + server.stop(); + } + }); + + it('returns "No browsers connected" when list is empty', async () => { + const server = createListServer({ status: 'ok', data: [] }); + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeListBrowsers( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + expect(result).toBe('No browsers connected.'); + } finally { + server.stop(); + } + }); + + it('throws when server returns an error', async () => { + const server = createListServer({ + status: 'error', + error: 'ws server down', + }); + const sessions = createBrowserSessionStore(10000); + try { + await expect( + executeListBrowsers( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ), + ).rejects.toThrow('ws server down'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/list-browsers.ts b/apps/websocket/src/mcp/tools/list-browsers.ts new file mode 100644 index 0000000..9025c22 --- /dev/null +++ b/apps/websocket/src/mcp/tools/list-browsers.ts @@ -0,0 +1,50 @@ +import type { BrowserConnection } from '@browser-bridge/shared'; +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { fetchBrowserList } from '../browser-lookup'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const ListBrowsersInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeListBrowsers( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + + const browsers = await fetchBrowserList(context, timeoutMs); + + if (browsers.length === 0) { + return 'No browsers connected.'; + } + + return browsers + .map((b: BrowserConnection) => `- ${b.browserId} (${b.status})`) + .join('\n'); +} + +export function registerListBrowsersTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'list_browsers', + description: 'List all browsers connected to Browser Bridge.', + parameters: ListBrowsersInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeListBrowsers( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From ed0a1fb1184343a075753cfdc056b03a6b78e3dd Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 14:36:14 +0800 Subject: [PATCH 11/39] feat(mcp): add set_browser tool --- .../mcp/__tests__/tools/set-browser.test.ts | 15 ++++++++ apps/websocket/src/mcp/tools/set-browser.ts | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts create mode 100644 apps/websocket/src/mcp/tools/set-browser.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts b/apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts new file mode 100644 index 0000000..34ae24e --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'bun:test'; +import { executeSetBrowser } from '../../tools/set-browser'; +import { createBrowserSessionStore } from '../../browser-session'; + +describe('executeSetBrowser', () => { + it('sets the browser in session state', async () => { + const sessions = createBrowserSessionStore(10000); + const result = await executeSetBrowser( + { sessionId: 's1', sessions, websocketUrl: 'ws://localhost:3001' }, + { browserId: 'browser-a' }, + ); + expect(result).toContain('browser-a'); + expect(sessions.getSession('s1').browserId).toBe('browser-a'); + }); +}); diff --git a/apps/websocket/src/mcp/tools/set-browser.ts b/apps/websocket/src/mcp/tools/set-browser.ts new file mode 100644 index 0000000..8a1b276 --- /dev/null +++ b/apps/websocket/src/mcp/tools/set-browser.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const SetBrowserInputSchema = z.object({ + browserId: z.string().min(1), +}); + +export async function executeSetBrowser( + context: ToolContext, + args: z.infer, +): Promise { + context.sessions.setBrowser(context.sessionId, args.browserId); + return `Browser set to "${args.browserId}" for this session.`; +} + +export function registerSetBrowserTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'set_browser', + description: + 'Explicitly choose which connected browser to control for this MCP session.', + parameters: SetBrowserInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeSetBrowser( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 966c959f67a3ca3f4f28eec2fe8d355c8db8d249 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 14:48:04 +0800 Subject: [PATCH 12/39] feat(mcp): add navigate tool --- .../src/mcp/__tests__/tools/navigate.test.ts | 66 +++++++++++++++++++ apps/websocket/src/mcp/tools/navigate.ts | 60 +++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/navigate.test.ts create mode 100644 apps/websocket/src/mcp/tools/navigate.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts b/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts new file mode 100644 index 0000000..74e2bce --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'bun:test'; +import type { Server } from 'bun'; +import { executeNavigate } from '../../tools/navigate'; +import { createBrowserSessionStore } from '../../browser-session'; +import { encode, decode } from '@browser-bridge/websocket/protocol'; +import type { Envelope } from '@browser-bridge/shared'; + +describe('executeNavigate', () => { + it('navigates to url and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Navigated' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + + try { + const result = await executeNavigate( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { url: 'https://example.com' }, + ); + + expect(result).toContain('Navigated'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/navigate.ts b/apps/websocket/src/mcp/tools/navigate.ts new file mode 100644 index 0000000..7d14c9b --- /dev/null +++ b/apps/websocket/src/mcp/tools/navigate.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import type { FastMCP } from 'fastmcp'; +import { sendCommand } from '../command-client'; +import { resolveTargetBrowser } from '../browser-lookup'; +import type { ToolContext, ServerContext } from '../tool-context'; + +export const NavigateInputSchema = z.object({ + url: z.string().url(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeNavigate( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + + if (!resolution.success) { + throw new Error(resolution.message); + } + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'navigate', + params: { url: args.url }, + timeoutMs, + }); + + if (result.status !== 'ok') { + throw new Error(result.error ?? 'Navigation failed'); + } + + return result.message ?? `Navigated to ${args.url}`; +} + +export function registerNavigateTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'navigate', + description: 'Navigate the active tab of the selected browser to a URL.', + parameters: NavigateInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeNavigate( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From a56a31ab396db7cb0b9e5dd05bf5366069f33195 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:01:01 +0800 Subject: [PATCH 13/39] feat(mcp): add click tool --- .../src/mcp/__tests__/tools/click.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/click.ts | 54 ++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/click.test.ts create mode 100644 apps/websocket/src/mcp/tools/click.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/click.test.ts b/apps/websocket/src/mcp/__tests__/tools/click.test.ts new file mode 100644 index 0000000..9e310ab --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/click.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeClick } from '../../tools/click'; + +describe('executeClick', () => { + it('sends click command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Clicked' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeClick( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: '#submit' }, + ); + expect(result).toContain('Clicked'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/click.ts b/apps/websocket/src/mcp/tools/click.ts new file mode 100644 index 0000000..f3964a3 --- /dev/null +++ b/apps/websocket/src/mcp/tools/click.ts @@ -0,0 +1,54 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const ClickInputSchema = z.object({ + selector: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeClick( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'click', + params: { selector: args.selector }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Click failed'); + return result.message ?? `Clicked ${args.selector}`; +} + +export function registerClickTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'click', + description: 'Click an element in the selected browser by CSS selector.', + parameters: ClickInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeClick( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 8ba7964b3330e61e2ce267659130c217dca35e6b Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:05:17 +0800 Subject: [PATCH 14/39] chore(mcp): remove unused import and format navigate tool --- apps/websocket/src/mcp/__tests__/tools/navigate.test.ts | 7 +++---- apps/websocket/src/mcp/tools/navigate.ts | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts b/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts index 74e2bce..38b9032 100644 --- a/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts +++ b/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import type { Server } from 'bun'; -import { executeNavigate } from '../../tools/navigate'; -import { createBrowserSessionStore } from '../../browser-session'; -import { encode, decode } from '@browser-bridge/websocket/protocol'; import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeNavigate } from '../../tools/navigate'; describe('executeNavigate', () => { it('navigates to url and returns success', async () => { diff --git a/apps/websocket/src/mcp/tools/navigate.ts b/apps/websocket/src/mcp/tools/navigate.ts index 7d14c9b..3b3bafe 100644 --- a/apps/websocket/src/mcp/tools/navigate.ts +++ b/apps/websocket/src/mcp/tools/navigate.ts @@ -1,8 +1,8 @@ -import { z } from 'zod'; import type { FastMCP } from 'fastmcp'; -import { sendCommand } from '../command-client'; +import { z } from 'zod'; import { resolveTargetBrowser } from '../browser-lookup'; -import type { ToolContext, ServerContext } from '../tool-context'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; export const NavigateInputSchema = z.object({ url: z.string().url(), From 5784442feecc725f2353ef5b1c02f8efeef374ab Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:07:10 +0800 Subject: [PATCH 15/39] feat(mcp): add type tool --- .../src/mcp/__tests__/tools/type.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/type.ts | 56 +++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/type.test.ts create mode 100644 apps/websocket/src/mcp/tools/type.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/type.test.ts b/apps/websocket/src/mcp/__tests__/tools/type.test.ts new file mode 100644 index 0000000..e83ed11 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/type.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeType } from '../../tools/type'; + +describe('executeType', () => { + it('sends type command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Typed' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeType( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: '#search', text: 'hello' }, + ); + expect(result).toContain('Typed'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/type.ts b/apps/websocket/src/mcp/tools/type.ts new file mode 100644 index 0000000..66a39f5 --- /dev/null +++ b/apps/websocket/src/mcp/tools/type.ts @@ -0,0 +1,56 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const TypeInputSchema = z.object({ + selector: z.string().min(1), + text: z.string(), + submit: z.boolean().optional(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeType( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'type', + params: { selector: args.selector, text: args.text, submit: args.submit }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Type failed'); + return result.message ?? `Typed into ${args.selector}`; +} + +export function registerTypeTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'type', + description: 'Type text into an input element in the selected browser.', + parameters: TypeInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeType( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From e0580096d76dd18bd35cb5eb51a64372e1acb0db Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:11:46 +0800 Subject: [PATCH 16/39] feat(mcp): add screenshot tool --- .../mcp/__tests__/tools/screenshot.test.ts | 68 +++++++++++++++++++ apps/websocket/src/mcp/tools/screenshot.ts | 61 +++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts create mode 100644 apps/websocket/src/mcp/tools/screenshot.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts b/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts new file mode 100644 index 0000000..afcd999 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeScreenshot } from '../../tools/screenshot'; + +describe('executeScreenshot', () => { + it('returns image content', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { + status: 'ok', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeScreenshot( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('image'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/screenshot.ts b/apps/websocket/src/mcp/tools/screenshot.ts new file mode 100644 index 0000000..b88b6ac --- /dev/null +++ b/apps/websocket/src/mcp/tools/screenshot.ts @@ -0,0 +1,61 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const ScreenshotInputSchema = z.object({ + full_page: z.boolean().optional(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeScreenshot( + context: ToolContext, + args: z.infer, +): Promise<{ + content: Array<{ type: 'image'; data: string; mimeType: 'image/png' }>; +}> { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'screenshot', + params: { fullPage: args.full_page }, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'Screenshot failed'); + const data = typeof result.data === 'string' ? result.data : ''; + + return { + content: [{ type: 'image', data, mimeType: 'image/png' }], + }; +} + +export function registerScreenshotTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'screenshot', + description: 'Take a screenshot of the selected browser.', + parameters: ScreenshotInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeScreenshot( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 3d29d8638f9b890b12f0517aad818034f0350ab1 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:15:59 +0800 Subject: [PATCH 17/39] feat(mcp): add pageinfo tool --- .../src/mcp/__tests__/tools/pageinfo.test.ts | 67 +++++++++++++++++++ apps/websocket/src/mcp/tools/pageinfo.ts | 54 +++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts create mode 100644 apps/websocket/src/mcp/tools/pageinfo.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts b/apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts new file mode 100644 index 0000000..c25954e --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/pageinfo.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executePageinfo } from '../../tools/pageinfo'; + +describe('executePageinfo', () => { + it('returns page info as JSON string', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { + status: 'ok', + data: { title: 'Example', url: 'https://example.com' }, + }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executePageinfo( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + expect(result).toContain('Example'); + expect(result).toContain('https://example.com'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/pageinfo.ts b/apps/websocket/src/mcp/tools/pageinfo.ts new file mode 100644 index 0000000..b893812 --- /dev/null +++ b/apps/websocket/src/mcp/tools/pageinfo.ts @@ -0,0 +1,54 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const PageinfoInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executePageinfo( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'pageinfo', + params: {}, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'pageinfo failed'); + return JSON.stringify(result.data ?? {}, null, 2); +} + +export function registerPageinfoTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'pageinfo', + description: 'Get title, URL, and tab list from the selected browser.', + parameters: PageinfoInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executePageinfo( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From c726b7fbc1000f759f93c2a379af14d20f9e9067 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:26:24 +0800 Subject: [PATCH 18/39] feat(mcp): add FastMCP server bootstrap --- .../src/mcp/__tests__/server.test.ts | 69 +++++++++++++++++++ apps/websocket/src/mcp/index.ts | 1 + apps/websocket/src/mcp/server.ts | 51 ++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/server.test.ts create mode 100644 apps/websocket/src/mcp/index.ts create mode 100644 apps/websocket/src/mcp/server.ts diff --git a/apps/websocket/src/mcp/__tests__/server.test.ts b/apps/websocket/src/mcp/__tests__/server.test.ts new file mode 100644 index 0000000..c43d14d --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/server.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import type { FastMCP } from 'fastmcp'; +import { startMcpServer } from '../server'; + +describe('startMcpServer', () => { + let wsServer: ReturnType | undefined; + let mcpServer: FastMCP | undefined; + + afterEach(async () => { + await mcpServer?.stop(); + wsServer?.stop(); + }); + + it('starts without error', async () => { + wsServer = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServerInstance) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServerInstance.upgrade(req, { data: {} }); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const response: Envelope = { + id: envelope.id, + type: 'response', + browserId: envelope.browserId, + payload: { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + }, + timestamp: Date.now(), + }; + ws.send( + encode('response', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); + }, + close() {}, + }, + }); + + mcpServer = await startMcpServer({ + websocketUrl: `ws://127.0.0.1:${wsServer.port}/ws`, + port: 0, + hostname: '127.0.0.1', + defaultTimeoutMs: 1000, + version: '0.0.0', + }); + + expect(mcpServer).toBeDefined(); + }); +}); diff --git a/apps/websocket/src/mcp/index.ts b/apps/websocket/src/mcp/index.ts new file mode 100644 index 0000000..c7173aa --- /dev/null +++ b/apps/websocket/src/mcp/index.ts @@ -0,0 +1 @@ +export { type McpServerOptions, startMcpServer } from './server'; diff --git a/apps/websocket/src/mcp/server.ts b/apps/websocket/src/mcp/server.ts new file mode 100644 index 0000000..c13a3ee --- /dev/null +++ b/apps/websocket/src/mcp/server.ts @@ -0,0 +1,51 @@ +import { FastMCP } from 'fastmcp'; +import { createBrowserSessionStore } from './browser-session'; +import { registerClickTool } from './tools/click'; +import { registerListBrowsersTool } from './tools/list-browsers'; +import { registerNavigateTool } from './tools/navigate'; +import { registerPageinfoTool } from './tools/pageinfo'; +import { registerScreenshotTool } from './tools/screenshot'; +import { registerSetBrowserTool } from './tools/set-browser'; +import { registerTypeTool } from './tools/type'; + +export interface McpServerOptions { + websocketUrl: string; + port: number; + hostname: string; + defaultTimeoutMs: number; + version: string; +} + +export async function startMcpServer( + options: McpServerOptions, +): Promise { + const server = new FastMCP({ + name: 'Browser Bridge', + version: options.version as `${number}.${number}.${number}`, + }); + + const sessions = createBrowserSessionStore(options.defaultTimeoutMs); + const serverContext = { + websocketUrl: options.websocketUrl, + sessions, + }; + + registerListBrowsersTool(server, serverContext); + registerSetBrowserTool(server, serverContext); + registerNavigateTool(server, serverContext); + registerClickTool(server, serverContext); + registerTypeTool(server, serverContext); + registerScreenshotTool(server, serverContext); + registerPageinfoTool(server, serverContext); + + await server.start({ + transportType: 'httpStream', + httpStream: { + port: options.port, + host: options.hostname, + endpoint: '/mcp', + }, + }); + + return server; +} From 4ee82783a99a8001f4efa8d0274e106ad2339627 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:34:28 +0800 Subject: [PATCH 19/39] feat(mcp): start MCP server alongside websocket server --- apps/websocket/src/index.ts | 51 +++++++++++++++++++++++++++----- apps/websocket/src/mcp/server.ts | 5 +++- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/websocket/src/index.ts b/apps/websocket/src/index.ts index 2cf67b8..9db7849 100644 --- a/apps/websocket/src/index.ts +++ b/apps/websocket/src/index.ts @@ -1,4 +1,7 @@ +import { WEBSOCKET_PORT } from '@browser-bridge/shared'; import { ApiKeyAuthProvider } from '@browser-bridge/shared/auth'; +import pkg from '../package.json'; +import { startMcpServer } from './mcp'; import { startServer } from './server'; const apiKeys = process.env.BRIDGE_API_KEYS; @@ -7,12 +10,44 @@ const port = process.env.BRIDGE_WS_PORT : undefined; const hostname = process.env.BRIDGE_WS_HOSTNAME; -if (apiKeys) { - const keys = apiKeys - .split(',') - .map((k) => k.trim()) - .filter(Boolean); - startServer(port, new ApiKeyAuthProvider(keys), hostname); -} else { - startServer(port, undefined, hostname); +const authProvider = apiKeys + ? new ApiKeyAuthProvider( + apiKeys + .split(',') + .map((k) => k.trim()) + .filter(Boolean), + ) + : undefined; + +const mcpPort = process.env.BRIDGE_MCP_PORT + ? Number(process.env.BRIDGE_MCP_PORT) + : 3003; +if (Number.isNaN(mcpPort)) { + throw new Error(`Invalid BRIDGE_MCP_PORT: ${process.env.BRIDGE_MCP_PORT}`); +} + +const mcpHostname = process.env.BRIDGE_MCP_HOSTNAME ?? '127.0.0.1'; + +const mcpTimeout = process.env.BRIDGE_MCP_TIMEOUT_MS + ? Number(process.env.BRIDGE_MCP_TIMEOUT_MS) + : 10000; +if (Number.isNaN(mcpTimeout)) { + throw new Error( + `Invalid BRIDGE_MCP_TIMEOUT_MS: ${process.env.BRIDGE_MCP_TIMEOUT_MS}`, + ); +} + +try { + startServer(port, authProvider, hostname); + await startMcpServer({ + websocketUrl: `ws://${hostname ?? 'localhost'}:${port ?? WEBSOCKET_PORT}`, + port: mcpPort, + hostname: mcpHostname, + defaultTimeoutMs: mcpTimeout, + version: pkg.version, + }); + console.log(`MCP server listening on http://${mcpHostname}:${mcpPort}/mcp`); +} catch (error) { + console.error('Failed to start servers:', error); + process.exit(1); } diff --git a/apps/websocket/src/mcp/server.ts b/apps/websocket/src/mcp/server.ts index c13a3ee..9064684 100644 --- a/apps/websocket/src/mcp/server.ts +++ b/apps/websocket/src/mcp/server.ts @@ -19,9 +19,12 @@ export interface McpServerOptions { export async function startMcpServer( options: McpServerOptions, ): Promise { + const semverVersion = + options.version.match(/^(\d+\.\d+\.\d+)/)?.[0] ?? '0.0.0'; + const server = new FastMCP({ name: 'Browser Bridge', - version: options.version as `${number}.${number}.${number}`, + version: semverVersion as `${number}.${number}.${number}`, }); const sessions = createBrowserSessionStore(options.defaultTimeoutMs); From f7c8fde861ad2ecb8291ffdcbe45564ce8fb896c Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:41:01 +0800 Subject: [PATCH 20/39] docs: add MCP setup guide and README section --- README.md | 4 ++++ docs/mcp-setup.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 docs/mcp-setup.md diff --git a/README.md b/README.md index d03c0f4..6f79341 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ That’s it. The command travels from CLI → WebSocket server → local proxy The `bridge` CLI is just one consumer of the bridge protocol. Browser Bridge ships with a ready-to-use Claude Code skill in [`./skills`](./skills/browser-bridge-user/SKILL.md), and anything that can open a WebSocket — for example, an MCP server you build, a custom SDK, or another agent framework — can send commands the same way. +## Use with MCP + +Browser Bridge also exposes a [Streamable HTTP MCP server](docs/mcp-setup.md). Once the WebSocket server is running, add `http://localhost:3003/mcp` to your MCP client (Claude Desktop, Cursor, etc.) to control browsers directly. + --- ## 🏗️ Architecture diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md new file mode 100644 index 0000000..2713c70 --- /dev/null +++ b/docs/mcp-setup.md @@ -0,0 +1,61 @@ +# Browser Bridge MCP Setup + +Browser Bridge exposes an MCP server over Streamable HTTP so agents can control browsers directly. + +## Start the server + +```bash +bun run dev:websocket +``` + +The MCP endpoint is available at `http://localhost:3003/mcp`. + +## Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "browser-bridge": { + "transport": "streamableHttp", + "url": "http://localhost:3003/mcp" + } + } +} +``` + +## Cursor + +Add to Cursor MCP settings: + +```json +{ + "mcpServers": { + "browser-bridge": { + "transport": "streamableHttp", + "url": "http://localhost:3003/mcp" + } + } +} +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `BRIDGE_MCP_PORT` | `3003` | MCP HTTP port | +| `BRIDGE_MCP_HOSTNAME` | `127.0.0.1` | Bind address | +| `BRIDGE_MCP_TIMEOUT_MS` | `10000` | Default command timeout | + +## Available tools + +- `list_browsers` — list connected browsers +- `set_browser` — choose a browser for this session +- `navigate` — open a URL +- `click` — click an element by selector +- `type` — type text into an input +- `screenshot` — capture the page +- `pageinfo` — get title, URL, tabs + +Each browser-control tool accepts an optional `timeout_ms` argument. From fb71d524eb0b58adad9865766d8f31f31dcc0802 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:42:39 +0800 Subject: [PATCH 21/39] feat(mcp): add browser resolution logic --- .../mcp/__tests__/browser-resolver.test.ts | 71 +++++++++++++++++++ apps/websocket/src/mcp/browser-resolver.ts | 59 +++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/browser-resolver.test.ts create mode 100644 apps/websocket/src/mcp/browser-resolver.ts diff --git a/apps/websocket/src/mcp/__tests__/browser-resolver.test.ts b/apps/websocket/src/mcp/__tests__/browser-resolver.test.ts new file mode 100644 index 0000000..02c726b --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/browser-resolver.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'bun:test'; +import type { BrowserConnection } from '@browser-bridge/shared'; +import { resolveBrowser } from '../browser-resolver'; + +const makeBrowser = ( + id: string, + status: BrowserConnection['status'], +): BrowserConnection => ({ + browserId: id, + userId: 'user-1', + status, + lastSeen: Date.now(), +}); + +describe('resolveBrowser', () => { + it('returns explicit browserId when set', () => { + const result = resolveBrowser('browser-a', [ + makeBrowser('browser-a', 'online'), + ]); + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('browser-a'); + }); + + it('auto-detects single online browser', () => { + const result = resolveBrowser(undefined, [ + makeBrowser('browser-a', 'online'), + ]); + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('browser-a'); + }); + + it('fails when no browser is online', () => { + const result = resolveBrowser(undefined, [ + makeBrowser('browser-a', 'offline'), + ]); + expect(result.success).toBe(false); + if (!result.success) + expect(result.message).toContain('No browser connected'); + }); + + it('fails when multiple browsers are online', () => { + const browsers = [ + makeBrowser('browser-a', 'online'), + makeBrowser('browser-b', 'online'), + ]; + const result = resolveBrowser(undefined, browsers); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.message).toContain('Multiple browsers'); + expect(result.availableBrowsers).toHaveLength(2); + } + }); + + it('ignores offline browsers during auto-detect', () => { + const browsers = [ + makeBrowser('browser-a', 'online'), + makeBrowser('browser-b', 'offline'), + ]; + const result = resolveBrowser(undefined, browsers); + expect(result.success).toBe(true); + if (result.success) expect(result.browserId).toBe('browser-a'); + }); + + it('fails when explicit browser is not online', () => { + const result = resolveBrowser('browser-a', [ + makeBrowser('browser-a', 'offline'), + ]); + expect(result.success).toBe(false); + if (!result.success) expect(result.message).toContain('not online'); + }); +}); diff --git a/apps/websocket/src/mcp/browser-resolver.ts b/apps/websocket/src/mcp/browser-resolver.ts new file mode 100644 index 0000000..cfa9597 --- /dev/null +++ b/apps/websocket/src/mcp/browser-resolver.ts @@ -0,0 +1,59 @@ +import type { BrowserConnection } from '@browser-bridge/shared'; + +export type BrowserResolutionSuccess = { success: true; browserId: string }; +export type BrowserResolutionFailure = { + success: false; + message: string; + availableBrowsers?: BrowserConnection[]; +}; +export type BrowserResolutionResult = + | BrowserResolutionSuccess + | BrowserResolutionFailure; + +function formatBrowserList(browsers: BrowserConnection[]): string { + return browsers.map((b) => `- ${b.browserId} (${b.status})`).join('\n'); +} + +export function resolveBrowser( + explicitBrowserId: string | undefined, + browsers: BrowserConnection[], +): BrowserResolutionResult { + if (explicitBrowserId) { + const match = browsers.find((b) => b.browserId === explicitBrowserId); + if (!match) { + return { + success: false, + message: `Browser "${explicitBrowserId}" is not connected.`, + availableBrowsers: browsers, + }; + } + if (match.status !== 'online') { + return { + success: false, + message: `Browser "${explicitBrowserId}" is not online (status: ${match.status}).`, + availableBrowsers: browsers, + }; + } + return { success: true, browserId: explicitBrowserId }; + } + + const online = browsers.filter((b) => b.status === 'online'); + + if (online.length === 0) { + return { + success: false, + message: 'No browser connected. Start the extension/local-proxy first.', + availableBrowsers: browsers, + }; + } + + if (online.length === 1) { + return { success: true, browserId: online[0].browserId }; + } + + return { + success: false, + message: `Multiple browsers are online. Call set_browser with one of:\n${formatBrowserList(online)}`, + availableBrowsers: online, + }; +} From 0a73ca0abff465e50415e20b13156d4cad9b6eb6 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:42:48 +0800 Subject: [PATCH 22/39] chore(mcp): apply Biome formatting fixes --- apps/websocket/src/mcp/__tests__/browser-lookup.test.ts | 4 ++-- apps/websocket/src/mcp/__tests__/command-client.test.ts | 6 +++--- apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts | 2 +- apps/websocket/src/mcp/browser-lookup.ts | 4 ++-- apps/websocket/src/mcp/command-client.ts | 2 +- apps/websocket/src/mcp/tools/set-browser.ts | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/websocket/src/mcp/__tests__/browser-lookup.test.ts b/apps/websocket/src/mcp/__tests__/browser-lookup.test.ts index aae9479..3ae1d56 100644 --- a/apps/websocket/src/mcp/__tests__/browser-lookup.test.ts +++ b/apps/websocket/src/mcp/__tests__/browser-lookup.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'bun:test'; +import type { Envelope, ResponsePayload } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; import type { Server } from 'bun'; import { fetchBrowserList, resolveTargetBrowser } from '../browser-lookup'; import { createBrowserSessionStore } from '../browser-session'; -import { encode, decode } from '@browser-bridge/websocket/protocol'; -import type { Envelope, ResponsePayload } from '@browser-bridge/shared'; function createListServer(responsePayload: ResponsePayload) { return Bun.serve({ diff --git a/apps/websocket/src/mcp/__tests__/command-client.test.ts b/apps/websocket/src/mcp/__tests__/command-client.test.ts index 2ed9766..fe4553e 100644 --- a/apps/websocket/src/mcp/__tests__/command-client.test.ts +++ b/apps/websocket/src/mcp/__tests__/command-client.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it, afterEach } from 'bun:test'; +import { afterEach, describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; import type { Server } from 'bun'; import { sendCommand, sendEvent } from '../command-client'; -import { encode, decode } from '@browser-bridge/websocket/protocol'; -import type { Envelope } from '@browser-bridge/shared'; describe('sendCommand', () => { let server: Server | undefined; diff --git a/apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts b/apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts index 34ae24e..0243060 100644 --- a/apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts +++ b/apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { executeSetBrowser } from '../../tools/set-browser'; import { createBrowserSessionStore } from '../../browser-session'; +import { executeSetBrowser } from '../../tools/set-browser'; describe('executeSetBrowser', () => { it('sets the browser in session state', async () => { diff --git a/apps/websocket/src/mcp/browser-lookup.ts b/apps/websocket/src/mcp/browser-lookup.ts index 450656c..be68d3d 100644 --- a/apps/websocket/src/mcp/browser-lookup.ts +++ b/apps/websocket/src/mcp/browser-lookup.ts @@ -1,9 +1,9 @@ import type { BrowserConnection } from '@browser-bridge/shared'; -import { sendEvent } from './command-client'; import { - resolveBrowser, type BrowserResolutionResult, + resolveBrowser, } from './browser-resolver'; +import { sendEvent } from './command-client'; import type { ToolContext } from './tool-context'; function isBrowserConnection(value: unknown): value is BrowserConnection { diff --git a/apps/websocket/src/mcp/command-client.ts b/apps/websocket/src/mcp/command-client.ts index bd06492..7572c58 100644 --- a/apps/websocket/src/mcp/command-client.ts +++ b/apps/websocket/src/mcp/command-client.ts @@ -1,9 +1,9 @@ -import { createClient } from '@browser-bridge/websocket/client'; import type { CommandPayload, CommandType, ResponsePayload, } from '@browser-bridge/shared'; +import { createClient } from '@browser-bridge/websocket/client'; export interface SendCommandOptions { serverUrl: string; diff --git a/apps/websocket/src/mcp/tools/set-browser.ts b/apps/websocket/src/mcp/tools/set-browser.ts index 8a1b276..20e75d9 100644 --- a/apps/websocket/src/mcp/tools/set-browser.ts +++ b/apps/websocket/src/mcp/tools/set-browser.ts @@ -1,6 +1,6 @@ -import { z } from 'zod'; import type { FastMCP } from 'fastmcp'; -import type { ToolContext, ServerContext } from '../tool-context'; +import { z } from 'zod'; +import type { ServerContext, ToolContext } from '../tool-context'; export const SetBrowserInputSchema = z.object({ browserId: z.string().min(1), From 6be537ea05873211c3c4a5ceb26c43c5278235d8 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 15:48:38 +0800 Subject: [PATCH 23/39] fix(mcp): use loopback websocket URL and remove startup log --- apps/websocket/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/websocket/src/index.ts b/apps/websocket/src/index.ts index 9db7849..c59c2b0 100644 --- a/apps/websocket/src/index.ts +++ b/apps/websocket/src/index.ts @@ -40,13 +40,12 @@ if (Number.isNaN(mcpTimeout)) { try { startServer(port, authProvider, hostname); await startMcpServer({ - websocketUrl: `ws://${hostname ?? 'localhost'}:${port ?? WEBSOCKET_PORT}`, + websocketUrl: `ws://127.0.0.1:${port ?? WEBSOCKET_PORT}`, port: mcpPort, hostname: mcpHostname, defaultTimeoutMs: mcpTimeout, version: pkg.version, }); - console.log(`MCP server listening on http://${mcpHostname}:${mcpPort}/mcp`); } catch (error) { console.error('Failed to start servers:', error); process.exit(1); From a4e5d8336849b8d69b1d855410a542b6f614d56b Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:11:45 +0800 Subject: [PATCH 24/39] feat(mcp): add wait_element tool Co-Authored-By: Claude --- .../mcp/__tests__/tools/wait-element.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/wait-element.ts | 56 +++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/wait-element.test.ts create mode 100644 apps/websocket/src/mcp/tools/wait-element.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/wait-element.test.ts b/apps/websocket/src/mcp/__tests__/tools/wait-element.test.ts new file mode 100644 index 0000000..63f640b --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/wait-element.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeWaitElement } from '../../tools/wait-element'; + +describe('executeWaitElement', () => { + it('sends wait:element command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Element found' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeWaitElement( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: '#submit' }, + ); + expect(result).toContain('Element found'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/wait-element.ts b/apps/websocket/src/mcp/tools/wait-element.ts new file mode 100644 index 0000000..8039947 --- /dev/null +++ b/apps/websocket/src/mcp/tools/wait-element.ts @@ -0,0 +1,56 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const WaitElementInputSchema = z.object({ + selector: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeWaitElement( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'wait:element', + params: { selector: args.selector, timeout: timeoutMs }, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'Wait element failed'); + return result.message ?? `Element ${args.selector} found`; +} + +export function registerWaitElementTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'wait_element', + description: + 'Wait for an element to appear in the selected browser by CSS selector.', + parameters: WaitElementInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeWaitElement( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 9d15c1640d32c5026431ed90872083fd26f3a4b2 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:11:48 +0800 Subject: [PATCH 25/39] feat(mcp): add tab_list tool Add tab_list MCP tool to list all tabs in the selected browser. Co-Authored-By: Claude --- .../src/mcp/__tests__/tools/tab-list.test.ts | 71 +++++++++++++++++++ apps/websocket/src/mcp/tools/tab-list.ts | 54 ++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/tab-list.test.ts create mode 100644 apps/websocket/src/mcp/tools/tab-list.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/tab-list.test.ts b/apps/websocket/src/mcp/__tests__/tools/tab-list.test.ts new file mode 100644 index 0000000..4417437 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/tab-list.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeTabList } from '../../tools/tab-list'; + +describe('executeTabList', () => { + it('returns tab list as JSON string', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { + status: 'ok', + data: [ + { id: 1, title: 'Tab 1', url: 'https://example.com/1' }, + { id: 2, title: 'Tab 2', url: 'https://example.com/2' }, + ], + }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeTabList( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + expect(result).toContain('Tab 1'); + expect(result).toContain('Tab 2'); + expect(result).toContain('https://example.com/1'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/tab-list.ts b/apps/websocket/src/mcp/tools/tab-list.ts new file mode 100644 index 0000000..1dbcf1a --- /dev/null +++ b/apps/websocket/src/mcp/tools/tab-list.ts @@ -0,0 +1,54 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const TabListInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeTabList( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'tab:list', + params: {}, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'tab:list failed'); + return JSON.stringify(result.data ?? [], null, 2); +} + +export function registerTabListTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'tab_list', + description: 'List all tabs in the selected browser.', + parameters: TabListInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeTabList( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 404de72a567ee438489da40ee629107146fb8702 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:11:54 +0800 Subject: [PATCH 26/39] feat(mcp): add go_back tool Add MCP tool to navigate back in browser history. Co-Authored-By: Claude --- .../src/mcp/__tests__/tools/go-back.test.ts | 63 +++++++++++++++++++ .../__tests__/tools/wait-navigation.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/go-back.ts | 53 ++++++++++++++++ .../src/mcp/tools/wait-navigation.ts | 54 ++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/go-back.test.ts create mode 100644 apps/websocket/src/mcp/__tests__/tools/wait-navigation.test.ts create mode 100644 apps/websocket/src/mcp/tools/go-back.ts create mode 100644 apps/websocket/src/mcp/tools/wait-navigation.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/go-back.test.ts b/apps/websocket/src/mcp/__tests__/tools/go-back.test.ts new file mode 100644 index 0000000..948207b --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/go-back.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeGoBack } from '../../tools/go-back'; + +describe('executeGoBack', () => { + it('sends goBack command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Went back' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeGoBack( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + expect(result).toContain('Went back'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/__tests__/tools/wait-navigation.test.ts b/apps/websocket/src/mcp/__tests__/tools/wait-navigation.test.ts new file mode 100644 index 0000000..93a0517 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/wait-navigation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeWaitNavigation } from '../../tools/wait-navigation'; + +describe('executeWaitNavigation', () => { + it('sends wait:navigation command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Navigation complete' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeWaitNavigation( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + expect(result).toContain('Navigation complete'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/go-back.ts b/apps/websocket/src/mcp/tools/go-back.ts new file mode 100644 index 0000000..ee68583 --- /dev/null +++ b/apps/websocket/src/mcp/tools/go-back.ts @@ -0,0 +1,53 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const GoBackInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeGoBack( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'goBack', + params: {}, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Go back failed'); + return result.message ?? 'Went back'; +} + +export function registerGoBackTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'go_back', + description: 'Go back one page in the browser history.', + parameters: GoBackInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeGoBack( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} diff --git a/apps/websocket/src/mcp/tools/wait-navigation.ts b/apps/websocket/src/mcp/tools/wait-navigation.ts new file mode 100644 index 0000000..a84856f --- /dev/null +++ b/apps/websocket/src/mcp/tools/wait-navigation.ts @@ -0,0 +1,54 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const WaitNavigationInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeWaitNavigation( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'wait:navigation', + params: { timeout: timeoutMs }, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'Wait navigation failed'); + return result.message ?? 'Navigation complete'; +} + +export function registerWaitNavigationTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'wait_navigation', + description: 'Wait for navigation to complete in the selected browser.', + parameters: WaitNavigationInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeWaitNavigation( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From f9c7e466aa48228cc3024499ca533e38d9051d4b Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:11:58 +0800 Subject: [PATCH 27/39] feat(mcp): add go_forward tool Add MCP tool to navigate forward in browser history. Co-Authored-By: Claude --- .../mcp/__tests__/tools/go-forward.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/go-forward.ts | 54 ++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/go-forward.test.ts create mode 100644 apps/websocket/src/mcp/tools/go-forward.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/go-forward.test.ts b/apps/websocket/src/mcp/__tests__/tools/go-forward.test.ts new file mode 100644 index 0000000..7115468 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/go-forward.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeGoForward } from '../../tools/go-forward'; + +describe('executeGoForward', () => { + it('sends goForward command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Went forward' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeGoForward( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + expect(result).toContain('Went forward'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/go-forward.ts b/apps/websocket/src/mcp/tools/go-forward.ts new file mode 100644 index 0000000..5b0f329 --- /dev/null +++ b/apps/websocket/src/mcp/tools/go-forward.ts @@ -0,0 +1,54 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const GoForwardInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeGoForward( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'goForward', + params: {}, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'Go forward failed'); + return result.message ?? 'Went forward'; +} + +export function registerGoForwardTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'go_forward', + description: 'Go forward one page in the browser history.', + parameters: GoForwardInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeGoForward( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From f71abe3995621be600b002d5cfa812e5ec80d5a9 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:12:24 +0800 Subject: [PATCH 28/39] feat(mcp): add wait_navigation tool Co-Authored-By: Claude --- .../src/mcp/__tests__/tools/get-text.test.ts | 66 +++++++++++++++++++ .../src/mcp/__tests__/tools/refresh.test.ts | 63 ++++++++++++++++++ apps/websocket/src/mcp/tools/get-text.ts | 54 +++++++++++++++ apps/websocket/src/mcp/tools/refresh.ts | 53 +++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/get-text.test.ts create mode 100644 apps/websocket/src/mcp/__tests__/tools/refresh.test.ts create mode 100644 apps/websocket/src/mcp/tools/get-text.ts create mode 100644 apps/websocket/src/mcp/tools/refresh.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/get-text.test.ts b/apps/websocket/src/mcp/__tests__/tools/get-text.test.ts new file mode 100644 index 0000000..358779a --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/get-text.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeGettext } from '../../tools/get-text'; + +describe('executeGettext', () => { + it('returns text content as string', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { + status: 'ok', + data: 'Hello World', + }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeGettext( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: 'h1' }, + ); + expect(result).toBe('Hello World'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/__tests__/tools/refresh.test.ts b/apps/websocket/src/mcp/__tests__/tools/refresh.test.ts new file mode 100644 index 0000000..e68f845 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/refresh.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeRefresh } from '../../tools/refresh'; + +describe('executeRefresh', () => { + it('sends refresh command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Refreshed' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeRefresh( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + expect(result).toContain('Refreshed'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/get-text.ts b/apps/websocket/src/mcp/tools/get-text.ts new file mode 100644 index 0000000..cebbd84 --- /dev/null +++ b/apps/websocket/src/mcp/tools/get-text.ts @@ -0,0 +1,54 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const GettextInputSchema = z.object({ + selector: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeGettext( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'gettext', + params: { selector: args.selector }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'gettext failed'); + return String(result.data ?? ''); +} + +export function registerGettextTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'get_text', + description: 'Get the text content of an element by CSS selector.', + parameters: GettextInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeGettext( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} diff --git a/apps/websocket/src/mcp/tools/refresh.ts b/apps/websocket/src/mcp/tools/refresh.ts new file mode 100644 index 0000000..d5f4fea --- /dev/null +++ b/apps/websocket/src/mcp/tools/refresh.ts @@ -0,0 +1,53 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const RefreshInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeRefresh( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'refresh', + params: {}, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Refresh failed'); + return result.message ?? 'Refreshed'; +} + +export function registerRefreshTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'refresh', + description: 'Refresh the current page.', + parameters: RefreshInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeRefresh( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 8cbfe5e5cb44700ff63a746a98946ee76d1bb1fd Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:13:24 +0800 Subject: [PATCH 29/39] feat(mcp): add refresh tool Add MCP tool to refresh the current page. Co-Authored-By: Claude --- .../src/mcp/__tests__/tools/tab-new.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/tab-new.ts | 54 ++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/tab-new.test.ts create mode 100644 apps/websocket/src/mcp/tools/tab-new.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/tab-new.test.ts b/apps/websocket/src/mcp/__tests__/tools/tab-new.test.ts new file mode 100644 index 0000000..f9a169a --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/tab-new.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeTabNew } from '../../tools/tab-new'; + +describe('executeTabNew', () => { + it('creates a new tab and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'New tab opened' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeTabNew( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { url: 'https://example.com' }, + ); + expect(result).toContain('New tab opened'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/tab-new.ts b/apps/websocket/src/mcp/tools/tab-new.ts new file mode 100644 index 0000000..5e0aa29 --- /dev/null +++ b/apps/websocket/src/mcp/tools/tab-new.ts @@ -0,0 +1,54 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const TabNewInputSchema = z.object({ + url: z.string().url().optional(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeTabNew( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'tab:new', + params: { url: args.url }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'tab:new failed'); + return result.message ?? 'New tab opened'; +} + +export function registerTabNewTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'tab_new', + description: 'Open a new tab in the selected browser.', + parameters: TabNewInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeTabNew( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From fa0c1c0fa68a35bc5d93f752ed91ba9c36157fd7 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:14:03 +0800 Subject: [PATCH 30/39] feat(mcp): add tab_new tool Add tab_new MCP tool to open a new tab in the selected browser. Co-Authored-By: Claude --- .../src/mcp/__tests__/tools/select.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/select.ts | 56 +++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/select.test.ts create mode 100644 apps/websocket/src/mcp/tools/select.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/select.test.ts b/apps/websocket/src/mcp/__tests__/tools/select.test.ts new file mode 100644 index 0000000..db25227 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/select.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeSelect } from '../../tools/select'; + +describe('executeSelect', () => { + it('sends select command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Selected' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeSelect( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: '#dropdown', value: 'option1' }, + ); + expect(result).toContain('Selected'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/select.ts b/apps/websocket/src/mcp/tools/select.ts new file mode 100644 index 0000000..8bf7b3a --- /dev/null +++ b/apps/websocket/src/mcp/tools/select.ts @@ -0,0 +1,56 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const SelectInputSchema = z.object({ + selector: z.string().min(1), + value: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeSelect( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'select', + params: { selector: args.selector, value: args.value }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Select failed'); + return result.message ?? `Selected ${args.value} in ${args.selector}`; +} + +export function registerSelectTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'select', + description: + 'Select an option from a dropdown in the selected browser by CSS selector.', + parameters: SelectInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeSelect( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 987a4fb3d2265a79ffa640960ef3479fc6fc44f3 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:14:50 +0800 Subject: [PATCH 31/39] feat(mcp): add get_html tool --- .../src/mcp/__tests__/tools/get-html.test.ts | 66 +++++++++++++++++++ apps/websocket/src/mcp/tools/get-html.ts | 54 +++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/get-html.test.ts create mode 100644 apps/websocket/src/mcp/tools/get-html.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/get-html.test.ts b/apps/websocket/src/mcp/__tests__/tools/get-html.test.ts new file mode 100644 index 0000000..449d1ff --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/get-html.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeGethtml } from '../../tools/get-html'; + +describe('executeGethtml', () => { + it('returns html content as string', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { + status: 'ok', + data: '
Hello World
', + }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeGethtml( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: 'div' }, + ); + expect(result).toBe('
Hello World
'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/get-html.ts b/apps/websocket/src/mcp/tools/get-html.ts new file mode 100644 index 0000000..9e2ab7c --- /dev/null +++ b/apps/websocket/src/mcp/tools/get-html.ts @@ -0,0 +1,54 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const GethtmlInputSchema = z.object({ + selector: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeGethtml( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'gethtml', + params: { selector: args.selector }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'gethtml failed'); + return String(result.data ?? ''); +} + +export function registerGethtmlTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'get_html', + description: 'Get the HTML content of an element by CSS selector.', + parameters: GethtmlInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeGethtml( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 3c0af8bc15ce9d9868cd3956b7b4f1956b731ec7 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:15:20 +0800 Subject: [PATCH 32/39] feat(mcp): add hover tool Add MCP tool to hover over an element by CSS selector. --- .../src/mcp/__tests__/tools/hover.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/hover.ts | 55 ++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/hover.test.ts create mode 100644 apps/websocket/src/mcp/tools/hover.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/hover.test.ts b/apps/websocket/src/mcp/__tests__/tools/hover.test.ts new file mode 100644 index 0000000..2299625 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/hover.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeHover } from '../../tools/hover'; + +describe('executeHover', () => { + it('sends hover command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Hovered' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeHover( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: '#menu' }, + ); + expect(result).toContain('Hovered'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/hover.ts b/apps/websocket/src/mcp/tools/hover.ts new file mode 100644 index 0000000..52d89b1 --- /dev/null +++ b/apps/websocket/src/mcp/tools/hover.ts @@ -0,0 +1,55 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const HoverInputSchema = z.object({ + selector: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeHover( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'hover', + params: { selector: args.selector }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Hover failed'); + return result.message ?? `Hovered ${args.selector}`; +} + +export function registerHoverTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'hover', + description: + 'Hover over an element in the selected browser by CSS selector.', + parameters: HoverInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeHover( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 6df434ee69025846a3e9356119d87b462d0730d5 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:15:29 +0800 Subject: [PATCH 33/39] feat(mcp): add scroll tool Add MCP tool to scroll the page or an element. --- .../src/mcp/__tests__/tools/scroll.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/scroll.ts | 56 +++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/scroll.test.ts create mode 100644 apps/websocket/src/mcp/tools/scroll.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/scroll.test.ts b/apps/websocket/src/mcp/__tests__/tools/scroll.test.ts new file mode 100644 index 0000000..bfcc83e --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/scroll.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeScroll } from '../../tools/scroll'; + +describe('executeScroll', () => { + it('sends scroll command and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Scrolled' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeScroll( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { x: 0, y: 500, selector: '#container' }, + ); + expect(result).toContain('Scrolled'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/scroll.ts b/apps/websocket/src/mcp/tools/scroll.ts new file mode 100644 index 0000000..747f5df --- /dev/null +++ b/apps/websocket/src/mcp/tools/scroll.ts @@ -0,0 +1,56 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const ScrollInputSchema = z.object({ + x: z.number().int(), + y: z.number().int(), + selector: z.string().min(1).optional().default('page'), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeScroll( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'scroll', + params: { selector: args.selector, x: args.x, y: args.y }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'Scroll failed'); + return result.message ?? `Scrolled to (${args.x}, ${args.y})`; +} + +export function registerScrollTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'scroll', + description: 'Scroll the page or an element in the selected browser.', + parameters: ScrollInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeScroll( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 144bf86993d53d92f236445507bce38b3e355e43 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:16:15 +0800 Subject: [PATCH 34/39] feat(mcp): add tab_close tool Add tab_close MCP tool to close a tab by ID in the selected browser. Co-Authored-By: Claude --- .../src/mcp/__tests__/tools/tab-close.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/tab-close.ts | 55 ++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/tab-close.test.ts create mode 100644 apps/websocket/src/mcp/tools/tab-close.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/tab-close.test.ts b/apps/websocket/src/mcp/__tests__/tools/tab-close.test.ts new file mode 100644 index 0000000..be567bd --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/tab-close.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeTabClose } from '../../tools/tab-close'; + +describe('executeTabClose', () => { + it('closes a tab and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Tab closed' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeTabClose( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { tabId: 1 }, + ); + expect(result).toContain('Tab closed'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/tab-close.ts b/apps/websocket/src/mcp/tools/tab-close.ts new file mode 100644 index 0000000..520a6dc --- /dev/null +++ b/apps/websocket/src/mcp/tools/tab-close.ts @@ -0,0 +1,55 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const TabCloseInputSchema = z.object({ + tabId: z.number().int().min(0), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeTabClose( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'tab:close', + params: { tabId: args.tabId }, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'tab:close failed'); + return result.message ?? `Tab ${args.tabId} closed`; +} + +export function registerTabCloseTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'tab_close', + description: 'Close a tab in the selected browser by tab ID.', + parameters: TabCloseInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeTabClose( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 418393a959644bdfee99cb7bfb9837af5a46f991 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:17:15 +0800 Subject: [PATCH 35/39] feat(mcp): add tab_switch tool --- .../mcp/__tests__/tools/tab-switch.test.ts | 63 +++++++++++++++++++ apps/websocket/src/mcp/tools/tab-switch.ts | 55 ++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tools/tab-switch.test.ts create mode 100644 apps/websocket/src/mcp/tools/tab-switch.ts diff --git a/apps/websocket/src/mcp/__tests__/tools/tab-switch.test.ts b/apps/websocket/src/mcp/__tests__/tools/tab-switch.test.ts new file mode 100644 index 0000000..1a5cca5 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/tab-switch.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeTabSwitch } from '../../tools/tab-switch'; + +describe('executeTabSwitch', () => { + it('switches to a tab and returns success', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Switched to tab 1' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeTabSwitch( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { tabId: 1 }, + ); + expect(result).toContain('Switched to tab 1'); + } finally { + server.stop(); + } + }); +}); diff --git a/apps/websocket/src/mcp/tools/tab-switch.ts b/apps/websocket/src/mcp/tools/tab-switch.ts new file mode 100644 index 0000000..30e35ab --- /dev/null +++ b/apps/websocket/src/mcp/tools/tab-switch.ts @@ -0,0 +1,55 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const TabSwitchInputSchema = z.object({ + tabId: z.number().int().min(0), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeTabSwitch( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'tab:switch', + params: { tabId: args.tabId }, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'tab:switch failed'); + return result.message ?? `Switched to tab ${args.tabId}`; +} + +export function registerTabSwitchTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'tab_switch', + description: 'Switch to a tab by its ID in the selected browser.', + parameters: TabSwitchInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeTabSwitch( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} From 2df7e6edf32e51a8a8c2a4f3eed416f75c4a7d59 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:20:18 +0800 Subject: [PATCH 36/39] feat(mcp): register all new CLI-aligned tools and add fullPage to screenshot --- .../mcp/__tests__/tools/screenshot.test.ts | 10 ++++++- apps/websocket/src/mcp/server.ts | 28 +++++++++++++++++++ apps/websocket/src/mcp/tools/screenshot.ts | 4 +-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts b/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts index afcd999..40a9f6b 100644 --- a/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts +++ b/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts @@ -6,6 +6,7 @@ import { executeScreenshot } from '../../tools/screenshot'; describe('executeScreenshot', () => { it('returns image content', async () => { + let lastCommandPayload: unknown; const server = Bun.serve({ port: 0, hostname: '127.0.0.1', @@ -37,6 +38,9 @@ describe('executeScreenshot', () => { status: 'ok', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', }; + if (envelope.type === 'command') { + lastCommandPayload = envelope.payload; + } ws.send( encode('response', payload, { id: envelope.id, @@ -56,11 +60,15 @@ describe('executeScreenshot', () => { sessions, websocketUrl: `ws://127.0.0.1:${server.port}/ws`, }, - {}, + { fullPage: true }, ); expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe('image'); + expect(lastCommandPayload).toEqual({ + command: 'screenshot', + params: { fullPage: true }, + }); } finally { server.stop(); } diff --git a/apps/websocket/src/mcp/server.ts b/apps/websocket/src/mcp/server.ts index 9064684..83bbacc 100644 --- a/apps/websocket/src/mcp/server.ts +++ b/apps/websocket/src/mcp/server.ts @@ -1,12 +1,26 @@ import { FastMCP } from 'fastmcp'; import { createBrowserSessionStore } from './browser-session'; import { registerClickTool } from './tools/click'; +import { registerGethtmlTool } from './tools/get-html'; +import { registerGettextTool } from './tools/get-text'; +import { registerGoBackTool } from './tools/go-back'; +import { registerGoForwardTool } from './tools/go-forward'; +import { registerHoverTool } from './tools/hover'; import { registerListBrowsersTool } from './tools/list-browsers'; import { registerNavigateTool } from './tools/navigate'; import { registerPageinfoTool } from './tools/pageinfo'; +import { registerRefreshTool } from './tools/refresh'; import { registerScreenshotTool } from './tools/screenshot'; +import { registerScrollTool } from './tools/scroll'; +import { registerSelectTool } from './tools/select'; import { registerSetBrowserTool } from './tools/set-browser'; +import { registerTabCloseTool } from './tools/tab-close'; +import { registerTabListTool } from './tools/tab-list'; +import { registerTabNewTool } from './tools/tab-new'; +import { registerTabSwitchTool } from './tools/tab-switch'; import { registerTypeTool } from './tools/type'; +import { registerWaitElementTool } from './tools/wait-element'; +import { registerWaitNavigationTool } from './tools/wait-navigation'; export interface McpServerOptions { websocketUrl: string; @@ -36,10 +50,24 @@ export async function startMcpServer( registerListBrowsersTool(server, serverContext); registerSetBrowserTool(server, serverContext); registerNavigateTool(server, serverContext); + registerGoBackTool(server, serverContext); + registerGoForwardTool(server, serverContext); + registerRefreshTool(server, serverContext); + registerTabListTool(server, serverContext); + registerTabNewTool(server, serverContext); + registerTabCloseTool(server, serverContext); + registerTabSwitchTool(server, serverContext); registerClickTool(server, serverContext); registerTypeTool(server, serverContext); + registerSelectTool(server, serverContext); + registerScrollTool(server, serverContext); + registerHoverTool(server, serverContext); + registerGettextTool(server, serverContext); + registerGethtmlTool(server, serverContext); registerScreenshotTool(server, serverContext); registerPageinfoTool(server, serverContext); + registerWaitElementTool(server, serverContext); + registerWaitNavigationTool(server, serverContext); await server.start({ transportType: 'httpStream', diff --git a/apps/websocket/src/mcp/tools/screenshot.ts b/apps/websocket/src/mcp/tools/screenshot.ts index b88b6ac..d914813 100644 --- a/apps/websocket/src/mcp/tools/screenshot.ts +++ b/apps/websocket/src/mcp/tools/screenshot.ts @@ -5,7 +5,7 @@ import { sendCommand } from '../command-client'; import type { ServerContext, ToolContext } from '../tool-context'; export const ScreenshotInputSchema = z.object({ - full_page: z.boolean().optional(), + fullPage: z.boolean().optional(), timeout_ms: z.number().int().min(100).max(120000).optional(), }); @@ -25,7 +25,7 @@ export async function executeScreenshot( serverUrl: context.websocketUrl, browserId: resolution.browserId, command: 'screenshot', - params: { fullPage: args.full_page }, + params: args.fullPage === undefined ? {} : { fullPage: args.fullPage }, timeoutMs, }); From b2f86fde9ca060fdb2ede55a385ae22a8e6e7493 Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:20:42 +0800 Subject: [PATCH 37/39] docs(mcp): document all CLI-aligned tools --- docs/mcp-setup.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md index 2713c70..b6ed503 100644 --- a/docs/mcp-setup.md +++ b/docs/mcp-setup.md @@ -50,12 +50,28 @@ Add to Cursor MCP settings: ## Available tools -- `list_browsers` — list connected browsers -- `set_browser` — choose a browser for this session -- `navigate` — open a URL -- `click` — click an element by selector -- `type` — type text into an input -- `screenshot` — capture the page -- `pageinfo` — get title, URL, tabs +| Tool | Description | Parameters | +|---|---|---| +| `list_browsers` | List connected browsers and their status | — | +| `set_browser` | Pin a browser for this MCP session | `browserId: string` | +| `navigate` | Navigate the active tab to a URL | `url: string`, `timeout_ms?: number` | +| `go_back` | Go back one page in browser history | `timeout_ms?: number` | +| `go_forward` | Go forward one page in browser history | `timeout_ms?: number` | +| `refresh` | Refresh the current page | `timeout_ms?: number` | +| `tab_list` | List all open tabs | `timeout_ms?: number` | +| `tab_new` | Open a new tab | `url?: string`, `timeout_ms?: number` | +| `tab_close` | Close a tab by ID | `tabId: number`, `timeout_ms?: number` | +| `tab_switch` | Switch to a tab by ID | `tabId: number`, `timeout_ms?: number` | +| `click` | Click an element by CSS selector | `selector: string`, `timeout_ms?: number` | +| `type` | Type text into an input | `selector: string`, `text: string`, `submit?: boolean`, `timeout_ms?: number` | +| `select` | Select a dropdown option by value | `selector: string`, `value: string`, `timeout_ms?: number` | +| `scroll` | Scroll the page or an element | `x: number`, `y: number`, `selector?: string`, `timeout_ms?: number` | +| `hover` | Hover over an element | `selector: string`, `timeout_ms?: number` | +| `get_text` | Get the text content of an element | `selector: string`, `timeout_ms?: number` | +| `get_html` | Get the inner HTML of an element | `selector: string`, `timeout_ms?: number` | +| `screenshot` | Capture a base64 PNG screenshot | `fullPage?: boolean`, `timeout_ms?: number` | +| `pageinfo` | Get title, URL, and tab list | `timeout_ms?: number` | +| `wait_element` | Wait for an element to appear | `selector: string`, `timeout_ms?: number` | +| `wait_navigation` | Wait for page navigation to complete | `timeout_ms?: number` | Each browser-control tool accepts an optional `timeout_ms` argument. From 87a4b7c76b77cd2ccffa90c014a6de0118d4b27f Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:24:16 +0800 Subject: [PATCH 38/39] test(mcp): add tool registration coverage test --- .../mcp/__tests__/tool-registration.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/websocket/src/mcp/__tests__/tool-registration.test.ts diff --git a/apps/websocket/src/mcp/__tests__/tool-registration.test.ts b/apps/websocket/src/mcp/__tests__/tool-registration.test.ts new file mode 100644 index 0000000..ac9c609 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tool-registration.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'bun:test'; +import { FastMCP } from 'fastmcp'; +import { createBrowserSessionStore } from '../browser-session'; +import { registerClickTool } from '../tools/click'; +import { registerGethtmlTool } from '../tools/get-html'; +import { registerGettextTool } from '../tools/get-text'; +import { registerGoBackTool } from '../tools/go-back'; +import { registerGoForwardTool } from '../tools/go-forward'; +import { registerHoverTool } from '../tools/hover'; +import { registerListBrowsersTool } from '../tools/list-browsers'; +import { registerNavigateTool } from '../tools/navigate'; +import { registerPageinfoTool } from '../tools/pageinfo'; +import { registerRefreshTool } from '../tools/refresh'; +import { registerScreenshotTool } from '../tools/screenshot'; +import { registerScrollTool } from '../tools/scroll'; +import { registerSelectTool } from '../tools/select'; +import { registerSetBrowserTool } from '../tools/set-browser'; +import { registerTabCloseTool } from '../tools/tab-close'; +import { registerTabListTool } from '../tools/tab-list'; +import { registerTabNewTool } from '../tools/tab-new'; +import { registerTabSwitchTool } from '../tools/tab-switch'; +import { registerTypeTool } from '../tools/type'; +import { registerWaitElementTool } from '../tools/wait-element'; +import { registerWaitNavigationTool } from '../tools/wait-navigation'; + +describe('tool registration', () => { + it('registers all CLI-aligned tools', () => { + const server = new FastMCP({ + name: 'test', + version: '0.0.0', + }); + const sessions = createBrowserSessionStore(10000); + const context = { + websocketUrl: 'ws://127.0.0.1:3001', + sessions, + }; + + registerListBrowsersTool(server, context); + registerSetBrowserTool(server, context); + registerNavigateTool(server, context); + registerGoBackTool(server, context); + registerGoForwardTool(server, context); + registerRefreshTool(server, context); + registerTabListTool(server, context); + registerTabNewTool(server, context); + registerTabCloseTool(server, context); + registerTabSwitchTool(server, context); + registerClickTool(server, context); + registerTypeTool(server, context); + registerSelectTool(server, context); + registerScrollTool(server, context); + registerHoverTool(server, context); + registerGettextTool(server, context); + registerGethtmlTool(server, context); + registerScreenshotTool(server, context); + registerPageinfoTool(server, context); + registerWaitElementTool(server, context); + registerWaitNavigationTool(server, context); + + expect(server).toBeDefined(); + }); +}); From eb38ce20f1b124a8b5c4073e8333fa56cd4dcc3a Mon Sep 17 00:00:00 2001 From: dkisser <437631891@qq.com> Date: Fri, 26 Jun 2026 16:31:28 +0800 Subject: [PATCH 39/39] docs: add plan for aligning MCP tools with CLI commands --- .../2026-06-26-align-mcp-tools-with-cli.md | 1588 +++++++++++++++++ 1 file changed, 1588 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-26-align-mcp-tools-with-cli.md diff --git a/docs/superpowers/plans/2026-06-26-align-mcp-tools-with-cli.md b/docs/superpowers/plans/2026-06-26-align-mcp-tools-with-cli.md new file mode 100644 index 0000000..eee5059 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-align-mcp-tools-with-cli.md @@ -0,0 +1,1588 @@ +# Align MCP Tools with CLI Commands + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add every Browser Bridge CLI command that is missing from the MCP server so that MCP agents can perform the same browser operations as the CLI. + +**Architecture:** Each new command maps to a single FastMCP tool in its own file under `apps/websocket/src/mcp/tools/`. Every tool reuses the existing `executeCommand` helper, the `resolveTargetBrowser` session logic, and the shared `CommandType` union. Tool registration is added to `apps/websocket/src/mcp/server.ts`. Tests mirror the pattern used by the existing `navigate`, `click`, and `pageinfo` tools. + +**Tech Stack:** Bun, TypeScript, FastMCP, Zod, `@browser-bridge/shared`, `@browser-bridge/websocket`. + +--- + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `apps/websocket/src/mcp/tools/go-back.ts` | Create | `go_back` MCP tool | +| `apps/websocket/src/mcp/tools/go-forward.ts` | Create | `go_forward` MCP tool | +| `apps/websocket/src/mcp/tools/refresh.ts` | Create | `refresh` MCP tool | +| `apps/websocket/src/mcp/tools/tab-list.ts` | Create | `tab_list` MCP tool | +| `apps/websocket/src/mcp/tools/tab-new.ts` | Create | `tab_new` MCP tool | +| `apps/websocket/src/mcp/tools/tab-close.ts` | Create | `tab_close` MCP tool | +| `apps/websocket/src/mcp/tools/tab-switch.ts` | Create | `tab_switch` MCP tool | +| `apps/websocket/src/mcp/tools/select.ts` | Create | `select` MCP tool | +| `apps/websocket/src/mcp/tools/scroll.ts` | Create | `scroll` MCP tool | +| `apps/websocket/src/mcp/tools/hover.ts` | Create | `hover` MCP tool | +| `apps/websocket/src/mcp/tools/get-text.ts` | Create | `get_text` MCP tool | +| `apps/websocket/src/mcp/tools/get-html.ts` | Create | `get_html` MCP tool | +| `apps/websocket/src/mcp/tools/wait-element.ts` | Create | `wait_element` MCP tool | +| `apps/websocket/src/mcp/tools/wait-navigation.ts` | Create | `wait_navigation` MCP tool | +| `apps/websocket/src/mcp/__tests__/tools/*.test.ts` | Create | One test file per new tool | +| `apps/websocket/src/mcp/server.ts` | Modify | Register all new tools | +| `docs/mcp-setup.md` | Modify | Add new tools to the tool reference | +| `apps/websocket/src/mcp/tools/screenshot.ts` | Modify | Add missing `fullPage` param support documented in the design spec | + +--- + +## Shared patterns used by every tool + +Every tool handler follows this shape: + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const SomeInputSchema = z.object({ + // command-specific params + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeSome( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: '', + params: { /* mapped params */ }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? ' failed'); + return result.message ?? ``; +} + +export function registerSomeTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: '', + description: '', + parameters: SomeInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeSome( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +Every test uses a real Bun WebSocket server on port `0` and follows the existing pattern in `apps/websocket/src/mcp/__tests__/tools/navigate.test.ts`. + +--- + +### Task 1: `goBack` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/go-back.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/go-back.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeGoBack } from '../../tools/go-back'; + +describe('executeGoBack', () => { + it('goes back', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', data: 'back' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeGoBack( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + + expect(result).toContain('back'); + } finally { + server.stop(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/go-back.test.ts` + +Expected: FAIL with "Module not found" or "executeGoBack is not defined" + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const GoBackInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeGoBack( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'goBack', + params: {}, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'goBack failed'); + return result.message ?? 'Navigated back'; +} + +export function registerGoBackTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'go_back', + description: 'Go back one page in browser history.', + parameters: GoBackInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeGoBack( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/go-back.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/go-back.ts apps/websocket/src/mcp/__tests__/tools/go-back.test.ts +git commit -m "feat(mcp): add go_back tool" +``` + +--- + +### Task 2: `goForward` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/go-forward.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/go-forward.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 1, but call `executeGoForward` and assert the result contains `'forward'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/go-forward.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +Mirror Task 1 implementation, use `command: 'goForward'`, tool name `go_forward`, description `"Go forward one page in browser history."`, and return `'Navigated forward'`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/go-forward.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/go-forward.ts apps/websocket/src/mcp/__tests__/tools/go-forward.test.ts +git commit -m "feat(mcp): add go_forward tool" +``` + +--- + +### Task 3: `refresh` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/refresh.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/refresh.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 1, but call `executeRefresh` and assert the result contains `'refreshed'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/refresh.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +Mirror Task 1, use `command: 'refresh'`, tool name `refresh`, description `"Refresh the current page."`, and return `'Page refreshed'`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/refresh.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/refresh.ts apps/websocket/src/mcp/__tests__/tools/refresh.test.ts +git commit -m "feat(mcp): add refresh tool" +``` + +--- + +### Task 4: `tabList` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/tab-list.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/tab-list.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeTabList } from '../../tools/tab-list'; + +describe('executeTabList', () => { + it('returns tab list as JSON', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { + status: 'ok', + data: [ + { id: 1, title: 'One', url: 'https://one.example' }, + { id: 2, title: 'Two', url: 'https://two.example' }, + ], + }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeTabList( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + {}, + ); + + expect(result).toContain('One'); + expect(result).toContain('Two'); + } finally { + server.stop(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/tab-list.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const TabListInputSchema = z.object({ + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeTabList( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'tab:list', + params: {}, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'tab:list failed'); + return JSON.stringify(result.data ?? [], null, 2); +} + +export function registerTabListTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'tab_list', + description: 'List all open tabs in the selected browser.', + parameters: TabListInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeTabList( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/tab-list.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/tab-list.ts apps/websocket/src/mcp/__tests__/tools/tab-list.test.ts +git commit -m "feat(mcp): add tab_list tool" +``` + +--- + +### Task 5: `tabNew` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/tab-new.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/tab-new.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 4 test, but call `executeTabNew({ url: 'https://new.example' })` and assert the result contains `'https://new.example'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/tab-new.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const TabNewInputSchema = z.object({ + url: z.string().url().optional(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeTabNew( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'tab:new', + params: args.url ? { url: args.url } : {}, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'tab:new failed'); + return result.message ?? `Opened new tab${args.url ? ` to ${args.url}` : ''}`; +} + +export function registerTabNewTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'tab_new', + description: 'Open a new tab, optionally navigating to a URL.', + parameters: TabNewInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeTabNew( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/tab-new.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/tab-new.ts apps/websocket/src/mcp/__tests__/tools/tab-new.test.ts +git commit -m "feat(mcp): add tab_new tool" +``` + +--- + +### Task 6: `tabClose` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/tab-close.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/tab-close.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 4, call `executeTabClose({ tabId: 2 })`, mock response message `'Closed tab 2'`, assert result contains `'Closed'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/tab-close.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const TabCloseInputSchema = z.object({ + tabId: z.number().int().min(0), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeTabClose( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'tab:close', + params: { tabId: args.tabId }, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'tab:close failed'); + return result.message ?? `Closed tab ${args.tabId}`; +} + +export function registerTabCloseTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'tab_close', + description: 'Close a tab by its ID.', + parameters: TabCloseInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeTabClose( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/tab-close.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/tab-close.ts apps/websocket/src/mcp/__tests__/tools/tab-close.test.ts +git commit -m "feat(mcp): add tab_close tool" +``` + +--- + +### Task 7: `tabSwitch` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/tab-switch.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/tab-switch.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 6, call `executeTabSwitch({ tabId: 1 })`, mock response message `'Switched to tab 1'`, assert result contains `'Switched'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/tab-switch.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +Mirror Task 6, use `command: 'tab:switch'`, tool name `tab_switch`, description `"Switch to a tab by its ID."`, and return `Switched to tab ${args.tabId}`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/tab-switch.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/tab-switch.ts apps/websocket/src/mcp/__tests__/tools/tab-switch.test.ts +git commit -m "feat(mcp): add tab_switch tool" +``` + +--- + +### Task 8: `select` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/select.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/select.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeSelect } from '../../tools/select'; + +describe('executeSelect', () => { + it('selects an option', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Selected option' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeSelect( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: '#color', value: 'blue' }, + ); + + expect(result).toContain('Selected'); + } finally { + server.stop(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/select.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const SelectInputSchema = z.object({ + selector: z.string().min(1), + value: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeSelect( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'select', + params: { selector: args.selector, value: args.value }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'select failed'); + return result.message ?? `Selected ${args.value} in ${args.selector}`; +} + +export function registerSelectTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'select', + description: 'Select an option in a dropdown by CSS selector and value.', + parameters: SelectInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeSelect( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/select.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/select.ts apps/websocket/src/mcp/__tests__/tools/select.test.ts +git commit -m "feat(mcp): add select tool" +``` + +--- + +### Task 9: `scroll` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/scroll.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/scroll.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 8, call `executeScroll({ x: 0, y: 100 })`, mock message `'Scrolled'`, assert result contains `'Scrolled'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/scroll.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const ScrollInputSchema = z.object({ + selector: z.string().min(1).optional(), + x: z.number().int(), + y: z.number().int(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeScroll( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'scroll', + params: { + selector: args.selector ?? 'page', + x: args.x, + y: args.y, + }, + timeoutMs, + }); + + if (result.status !== 'ok') throw new Error(result.error ?? 'scroll failed'); + return result.message ?? `Scrolled by (${args.x}, ${args.y})`; +} + +export function registerScrollTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'scroll', + description: + 'Scroll an element or the page by x,y pixels. Defaults to scrolling the page.', + parameters: ScrollInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeScroll( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/scroll.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/scroll.ts apps/websocket/src/mcp/__tests__/tools/scroll.test.ts +git commit -m "feat(mcp): add scroll tool" +``` + +--- + +### Task 10: `hover` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/hover.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/hover.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 8, call `executeHover({ selector: '#btn' })`, mock message `'Hovered'`, assert result contains `'Hovered'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/hover.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +Mirror Task 8 `click.ts` pattern, use `command: 'hover'`, tool name `hover`, description `"Hover over an element by CSS selector."`, params `{ selector: args.selector }`, return `Hovered ${args.selector}`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/hover.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/hover.ts apps/websocket/src/mcp/__tests__/tools/hover.test.ts +git commit -m "feat(mcp): add hover tool" +``` + +--- + +### Task 11: `getText` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/get-text.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/get-text.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeGetText } from '../../tools/get-text'; + +describe('executeGetText', () => { + it('returns element text', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', data: 'Hello world' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeGetText( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: '#msg' }, + ); + + expect(result).toContain('Hello world'); + } finally { + server.stop(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/get-text.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const GetTextInputSchema = z.object({ + selector: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeGetText( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'gettext', + params: { selector: args.selector }, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'gettext failed'); + return String(result.data ?? ''); +} + +export function registerGetTextTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'get_text', + description: 'Get the text content of an element by CSS selector.', + parameters: GetTextInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeGetText( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/get-text.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/get-text.ts apps/websocket/src/mcp/__tests__/tools/get-text.test.ts +git commit -m "feat(mcp): add get_text tool" +``` + +--- + +### Task 12: `getHtml` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/get-html.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/get-html.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 11, call `executeGetHtml({ selector: '#msg' })`, mock data `'

Hello

'`, assert result contains `'

Hello

'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/get-html.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +Mirror Task 11, use `command: 'gethtml'`, tool name `get_html`, description `"Get the inner HTML of an element by CSS selector."`, and return `String(result.data ?? '')`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/get-html.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/get-html.ts apps/websocket/src/mcp/__tests__/tools/get-html.test.ts +git commit -m "feat(mcp): add get_html tool" +``` + +--- + +### Task 13: `waitElement` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/wait-element.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/wait-element.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from 'bun:test'; +import type { Envelope } from '@browser-bridge/shared'; +import { decode, encode } from '@browser-bridge/websocket/protocol'; +import { createBrowserSessionStore } from '../../browser-session'; +import { executeWaitElement } from '../../tools/wait-element'; + +describe('executeWaitElement', () => { + it('waits for an element', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, wsServer) { + if (new URL(req.url).pathname === '/ws') { + const upgraded = wsServer.upgrade(req); + if (!upgraded) return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open() {}, + message(ws, data) { + const envelope = decode(data as string) as Envelope; + const payload = + envelope.type === 'event' + ? { + status: 'ok', + data: [ + { + browserId: 'a', + userId: 'u', + status: 'online', + lastSeen: Date.now(), + }, + ], + } + : { status: 'ok', message: 'Element appeared' }; + ws.send( + encode('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + try { + const result = await executeWaitElement( + { + sessionId: 's1', + sessions, + websocketUrl: `ws://127.0.0.1:${server.port}/ws`, + }, + { selector: '#target' }, + ); + + expect(result).toContain('appeared'); + } finally { + server.stop(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/wait-element.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { resolveTargetBrowser } from '../browser-lookup'; +import { sendCommand } from '../command-client'; +import type { ServerContext, ToolContext } from '../tool-context'; + +export const WaitElementInputSchema = z.object({ + selector: z.string().min(1), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); + +export async function executeWaitElement( + context: ToolContext, + args: z.infer, +): Promise { + const timeoutMs = + args.timeout_ms ?? + context.sessions.getSession(context.sessionId).defaultTimeoutMs; + const resolution = await resolveTargetBrowser(context, timeoutMs); + if (!resolution.success) throw new Error(resolution.message); + + const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'wait:element', + params: { selector: args.selector, timeout: timeoutMs }, + timeoutMs, + }); + + if (result.status !== 'ok') + throw new Error(result.error ?? 'wait:element failed'); + return result.message ?? `Element ${args.selector} appeared`; +} + +export function registerWaitElementTool( + server: FastMCP, + serverContext: ServerContext, +): void { + server.addTool({ + name: 'wait_element', + description: 'Wait for an element to appear in the DOM.', + parameters: WaitElementInputSchema, + execute: async (args, { sessionId }) => { + const resolvedSessionId = sessionId ?? 'anonymous'; + return executeWaitElement( + { + sessionId: resolvedSessionId, + sessions: serverContext.sessions, + websocketUrl: serverContext.websocketUrl, + }, + args, + ); + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/wait-element.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/wait-element.ts apps/websocket/src/mcp/__tests__/tools/wait-element.test.ts +git commit -m "feat(mcp): add wait_element tool" +``` + +--- + +### Task 14: `waitNavigation` tool + +**Files:** +- Create: `apps/websocket/src/mcp/tools/wait-navigation.ts` +- Create: `apps/websocket/src/mcp/__tests__/tools/wait-navigation.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirror Task 13, call `executeWaitNavigation({})`, mock message `'Navigation complete'`, assert result contains `'Navigation'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/wait-navigation.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +Mirror Task 13, use `command: 'wait:navigation'`, tool name `wait_navigation`, description `"Wait for the current page navigation to complete."`, params `{ timeout: timeoutMs }`, return `Navigation complete`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/wait-navigation.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/websocket/src/mcp/tools/wait-navigation.ts apps/websocket/src/mcp/__tests__/tools/wait-navigation.test.ts +git commit -m "feat(mcp): add wait_navigation tool" +``` + +--- + +### Task 15: Register all new tools in `server.ts` + +**Files:** +- Modify: `apps/websocket/src/mcp/server.ts` + +- [ ] **Step 1: Add imports and registrations** + +Add these imports after the existing tool imports: + +```typescript +import { registerGetHtmlTool } from './tools/get-html'; +import { registerGetTextTool } from './tools/get-text'; +import { registerGoBackTool } from './tools/go-back'; +import { registerGoForwardTool } from './tools/go-forward'; +import { registerHoverTool } from './tools/hover'; +import { registerRefreshTool } from './tools/refresh'; +import { registerScrollTool } from './tools/scroll'; +import { registerSelectTool } from './tools/select'; +import { registerTabCloseTool } from './tools/tab-close'; +import { registerTabListTool } from './tools/tab-list'; +import { registerTabNewTool } from './tools/tab-new'; +import { registerTabSwitchTool } from './tools/tab-switch'; +import { registerWaitElementTool } from './tools/wait-element'; +import { registerWaitNavigationTool } from './tools/wait-navigation'; +``` + +Add these registrations inside `startMcpServer` after the existing registrations: + +```typescript +registerGoBackTool(server, serverContext); +registerGoForwardTool(server, serverContext); +registerRefreshTool(server, serverContext); +registerTabListTool(server, serverContext); +registerTabNewTool(server, serverContext); +registerTabCloseTool(server, serverContext); +registerTabSwitchTool(server, serverContext); +registerSelectTool(server, serverContext); +registerScrollTool(server, serverContext); +registerHoverTool(server, serverContext); +registerGetTextTool(server, serverContext); +registerGetHtmlTool(server, serverContext); +registerWaitElementTool(server, serverContext); +registerWaitNavigationTool(server, serverContext); +``` + +- [ ] **Step 2: Run type check and tests** + +Run: + +```bash +bun run type-check +bun test apps/websocket/src/mcp +``` + +Expected: type-check clean; all MCP tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add apps/websocket/src/mcp/server.ts +git commit -m "feat(mcp): register all new CLI-aligned tools" +``` + +--- + +### Task 16: Add `fullPage` parameter to `screenshot` tool + +**Files:** +- Modify: `apps/websocket/src/mcp/tools/screenshot.ts` +- Modify: `apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts` + +The design spec documents a `fullPage?: boolean` parameter for `screenshot`, but the initial implementation omitted it. + +- [ ] **Step 1: Update the test to assert fullPage is forwarded** + +In `screenshot.test.ts`, update the `executeScreenshot` call to pass `{ fullPage: true }` and assert that the command params received by the mock include `fullPage: true`. The simplest way is to capture the last command params in the mock and assert after `executeScreenshot` returns. + +Example: + +```typescript +let lastCommandParams: Record | undefined; +// inside message(ws, data): +if (envelope.type === 'command') { + lastCommandParams = envelope.payload; +} +``` + +Then after the call: + +```typescript +expect(lastCommandParams).toEqual({ fullPage: true }); +``` + +Run the test; it should FAIL because the implementation does not accept `fullPage`. + +- [ ] **Step 2: Update the implementation** + +```typescript +export const ScreenshotInputSchema = z.object({ + fullPage: z.boolean().optional(), + timeout_ms: z.number().int().min(100).max(120000).optional(), +}); +``` + +Forward the param: + +```typescript +const result = await sendCommand({ + serverUrl: context.websocketUrl, + browserId: resolution.browserId, + command: 'screenshot', + params: args.fullPage === undefined ? {} : { fullPage: args.fullPage }, + timeoutMs, +}); +``` + +- [ ] **Step 3: Run test** + +Run: `bun test apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts` + +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add apps/websocket/src/mcp/tools/screenshot.ts apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts +git commit -m "feat(mcp): add fullPage option to screenshot tool" +``` + +--- + +### Task 17: Update MCP setup documentation + +**Files:** +- Modify: `docs/mcp-setup.md` + +- [ ] **Step 1: Add the new tools to the tool reference table** + +Insert new rows after the existing tool reference table so it reads: + +| Tool | Description | Parameters | +|---|---|---| +| `list_browsers` | List connected browsers | — | +| `set_browser` | Pin a browser for this session | `browserId: string` | +| `navigate` | Navigate active tab to URL | `url: string`, `timeout_ms?: number` | +| `go_back` | Go back in browser history | `timeout_ms?: number` | +| `go_forward` | Go forward in browser history | `timeout_ms?: number` | +| `refresh` | Refresh the current page | `timeout_ms?: number` | +| `tab_list` | List all open tabs | `timeout_ms?: number` | +| `tab_new` | Open a new tab | `url?: string`, `timeout_ms?: number` | +| `tab_close` | Close a tab by ID | `tabId: number`, `timeout_ms?: number` | +| `tab_switch` | Switch to a tab by ID | `tabId: number`, `timeout_ms?: number` | +| `click` | Click an element | `selector: string`, `timeout_ms?: number` | +| `type` | Type text into an input | `selector: string`, `text: string`, `submit?: boolean`, `timeout_ms?: number` | +| `select` | Select a dropdown option | `selector: string`, `value: string`, `timeout_ms?: number` | +| `scroll` | Scroll element or page | `x: number`, `y: number`, `selector?: string`, `timeout_ms?: number` | +| `hover` | Hover over an element | `selector: string`, `timeout_ms?: number` | +| `get_text` | Get element text content | `selector: string`, `timeout_ms?: number` | +| `get_html` | Get element inner HTML | `selector: string`, `timeout_ms?: number` | +| `screenshot` | Capture base64 PNG screenshot | `fullPage?: boolean`, `timeout_ms?: number` | +| `pageinfo` | Get title, URL, and tabs | `timeout_ms?: number` | +| `wait_element` | Wait for an element to appear | `selector: string`, `timeout_ms?: number` | +| `wait_navigation` | Wait for navigation to complete | `timeout_ms?: number` | + +- [ ] **Step 2: Commit** + +```bash +git add docs/mcp-setup.md +git commit -m "docs(mcp): document all CLI-aligned tools" +``` + +--- + +### Task 18: Final verification + +- [ ] **Step 1: Run full test suite** + +Run: `bun run test` + +Expected: all tests pass. + +- [ ] **Step 2: Run type check** + +Run: `bun run type-check` + +Expected: clean. + +- [ ] **Step 3: Run Biome** + +Run: `bunx @biomejs/biome check --write .` + +Expected: no diagnostics after fixes. + +- [ ] **Step 4: Check test coverage** + +Run: `bun test --coverage apps/websocket/src/mcp` + +Expected: 80%+ coverage for new MCP code. + +- [ ] **Step 5: Commit any formatting fixes** + +```bash +git add . +git commit -m "chore(mcp): apply Biome formatting fixes" || echo "No changes to commit" +``` + +--- + +## Self-Review + +**1. Spec coverage:** Every CLI command except the reserved `bridge-host` (explicitly marked "not yet implemented") now has a corresponding MCP tool. The `screenshot` tool is updated to match the design spec's `fullPage` parameter. + +**2. Placeholder scan:** No TBD/TODO placeholders. Each task contains concrete code, exact commands, and expected outcomes. + +**3. Type consistency:** All tools use `CommandType` values exactly as defined in `packages/shared/src/types.ts`. Tool names use underscores to match MCP naming conventions. Param names (`selector`, `value`, `tabId`, `x`, `y`, `url`, `timeout_ms`, `fullPage`) match the CLI's parameter names where applicable.