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/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/apps/websocket/src/index.ts b/apps/websocket/src/index.ts index 2cf67b8..c59c2b0 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,43 @@ 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://127.0.0.1:${port ?? WEBSOCKET_PORT}`, + port: mcpPort, + hostname: mcpHostname, + defaultTimeoutMs: mcpTimeout, + version: pkg.version, + }); +} catch (error) { + console.error('Failed to start servers:', error); + process.exit(1); } 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..3ae1d56 --- /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 { 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'; + +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/__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/__tests__/browser-session.test.ts b/apps/websocket/src/mcp/__tests__/browser-session.test.ts new file mode 100644 index 0000000..ba4fb17 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/browser-session.test.ts @@ -0,0 +1,48 @@ +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.getSession('session-1'); + expect(session.browserId).toBeUndefined(); + expect(session.defaultTimeoutMs).toBe(15000); + }); + + it('sets and retrieves browserId', () => { + const store = createBrowserSessionStore(10000); + 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); + 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); + 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/__tests__/command-client.test.ts b/apps/websocket/src/mcp/__tests__/command-client.test.ts new file mode 100644 index 0000000..fe4553e --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/command-client.test.ts @@ -0,0 +1,143 @@ +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'; + +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/__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/__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(); + }); +}); 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/__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/__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/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/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/__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/__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/__tests__/tools/navigate.test.ts b/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts new file mode 100644 index 0000000..38b9032 --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/navigate.test.ts @@ -0,0 +1,65 @@ +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 { executeNavigate } from '../../tools/navigate'; + +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/__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/__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/__tests__/tools/screenshot.test.ts b/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts new file mode 100644 index 0000000..40a9f6b --- /dev/null +++ b/apps/websocket/src/mcp/__tests__/tools/screenshot.test.ts @@ -0,0 +1,76 @@ +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 () => { + let lastCommandPayload: unknown; + 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=', + }; + if (envelope.type === 'command') { + lastCommandPayload = envelope.payload; + } + 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`, + }, + { 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/__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/__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/__tests__/tools/set-browser.test.ts b/apps/websocket/src/mcp/__tests__/tools/set-browser.test.ts new file mode 100644 index 0000000..0243060 --- /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 { createBrowserSessionStore } from '../../browser-session'; +import { executeSetBrowser } from '../../tools/set-browser'; + +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/__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/__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/__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/__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/__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/__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/__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/browser-lookup.ts b/apps/websocket/src/mcp/browser-lookup.ts new file mode 100644 index 0000000..be68d3d --- /dev/null +++ b/apps/websocket/src/mcp/browser-lookup.ts @@ -0,0 +1,59 @@ +import type { BrowserConnection } from '@browser-bridge/shared'; +import { + type BrowserResolutionResult, + resolveBrowser, +} from './browser-resolver'; +import { sendEvent } from './command-client'; +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 }; + } +} 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, + }; +} diff --git a/apps/websocket/src/mcp/browser-session.ts b/apps/websocket/src/mcp/browser-session.ts new file mode 100644 index 0000000..8fe896a --- /dev/null +++ b/apps/websocket/src/mcp/browser-session.ts @@ -0,0 +1,41 @@ +export interface BrowserSession { + readonly defaultTimeoutMs: number; + readonly browserId: string | undefined; +} + +export interface BrowserSessionStore { + getSession(sessionId: string): BrowserSession; + setBrowser(sessionId: string, browserId: string): BrowserSession; + clearBrowser(sessionId: string): BrowserSession; +} + +export function createBrowserSessionStore( + defaultTimeoutMs: number, +): BrowserSessionStore { + const sessions = new Map(); + + function ensureSession(sessionId: string): BrowserSession { + let session = sessions.get(sessionId); + if (!session) { + session = { defaultTimeoutMs, browserId: undefined }; + sessions.set(sessionId, 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; + }, + }; +} diff --git a/apps/websocket/src/mcp/command-client.ts b/apps/websocket/src/mcp/command-client.ts new file mode 100644 index 0000000..7572c58 --- /dev/null +++ b/apps/websocket/src/mcp/command-client.ts @@ -0,0 +1,96 @@ +import type { + CommandPayload, + CommandType, + ResponsePayload, +} from '@browser-bridge/shared'; +import { createClient } from '@browser-bridge/websocket/client'; + +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); + }); +} 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..83bbacc --- /dev/null +++ b/apps/websocket/src/mcp/server.ts @@ -0,0 +1,82 @@ +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; + port: number; + hostname: string; + defaultTimeoutMs: number; + version: string; +} + +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: semverVersion as `${number}.${number}.${number}`, + }); + + const sessions = createBrowserSessionStore(options.defaultTimeoutMs); + const serverContext = { + websocketUrl: options.websocketUrl, + sessions, + }; + + 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', + httpStream: { + port: options.port, + host: options.hostname, + endpoint: '/mcp', + }, + }); + + return server; +} 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; +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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/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/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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} diff --git a/apps/websocket/src/mcp/tools/navigate.ts b/apps/websocket/src/mcp/tools/navigate.ts new file mode 100644 index 0000000..3b3bafe --- /dev/null +++ b/apps/websocket/src/mcp/tools/navigate.ts @@ -0,0 +1,60 @@ +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 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} diff --git a/apps/websocket/src/mcp/tools/screenshot.ts b/apps/websocket/src/mcp/tools/screenshot.ts new file mode 100644 index 0000000..d914813 --- /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({ + fullPage: 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: args.fullPage === undefined ? {} : { fullPage: args.fullPage }, + 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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..20e75d9 --- /dev/null +++ b/apps/websocket/src/mcp/tools/set-browser.ts @@ -0,0 +1,38 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import type { ServerContext, ToolContext } 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} 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, + ); + }, + }); +} diff --git a/bun.lockb b/bun.lockb index cea255c..64ad869 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md new file mode 100644 index 0000000..b6ed503 --- /dev/null +++ b/docs/mcp-setup.md @@ -0,0 +1,77 @@ +# 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 + +| 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. 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. 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..86005ba --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-mcp-streamable-http.md @@ -0,0 +1,2315 @@ +# 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: 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` | +| `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. Session snapshots are immutable; the store owns mutable state. + +- [ ] **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.getSession('session-1'); + expect(session.browserId).toBeUndefined(); + expect(session.defaultTimeoutMs).toBe(15000); + }); + + it('sets and retrieves browserId', () => { + const store = createBrowserSessionStore(10000); + 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); + 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); + 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'); + }); +}); +``` + +- [ ] **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; + readonly browserId: string | undefined; +} + +export interface BrowserSessionStore { + getSession(sessionId: string): BrowserSession; + setBrowser(sessionId: string, browserId: string): BrowserSession; + clearBrowser(sessionId: string): BrowserSession; +} + +export function createBrowserSessionStore(defaultTimeoutMs: number): BrowserSessionStore { + const sessions = new Map(); + + function ensureSession(sessionId: string): BrowserSession { + let session = sessions.get(sessionId); + if (!session) { + session = { defaultTimeoutMs, browserId: undefined }; + sessions.set(sessionId, 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; + }, + }; +} +``` + +- [ ] **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 immutable 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 { BrowserSessionStore } from './browser-session'; + +export interface ToolContext { + sessionId: string; + sessions: BrowserSessionStore; + 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', 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: 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', 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); + }); +}); +``` + +- [ ] **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', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); + }, + close() {}, + }, + }); +} + +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 context = makeContext(server, 's1'); + const list = await fetchBrowserList(context, 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 context = makeContext(server, 's1'); + context.sessions.setBrowser('s1', 'a'); + + const result = await resolveTargetBrowser(context, 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 context = makeContext(server, 's1'); + + const result = await resolveTargetBrowser(context, 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.sessions.getSession(context.sessionId).browserId; + 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', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const result = await executeListBrowsers( + { sessionId: 's1', sessions, 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.sessions.getSession(context.sessionId).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 resolvedSessionId = sessionId ?? 'anonymous'; + return executeListBrowsers( + { sessionId: resolvedSessionId, sessions: serverContext.sessions, websocketUrl: serverContext.websocketUrl }, + 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 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'); + }); +}); +``` + +- [ ] **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.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, + ); + }, + }); +} +``` + +- [ ] **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('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + + const result = await executeNavigate( + { sessionId: 's1', sessions, 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.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, + ); + }, + }); +} +``` + +- [ ] **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('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const result = await executeClick( + { sessionId: 's1', sessions, 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.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, + ); + }, + }); +} +``` + +- [ ] **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('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + const result = await executeType( + { sessionId: 's1', sessions, 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.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, + ); + }, + }); +} +``` + +- [ ] **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('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + 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'); + 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.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, + ); + }, + }); +} +``` + +- [ ] **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('response', payload, { + id: envelope.id, + browserId: envelope.browserId, + }), + ); + }, + close() {}, + }, + }); + + const sessions = createBrowserSessionStore(10000); + 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'); + 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.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, + ); + }, + }); +} +``` + +- [ ] **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', response.payload, { + id: response.id, + browserId: response.browserId, + }), + ); + }, + 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 |