Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ca1778b
docs: add MCP Streamable HTTP implementation plan
dkisser Jun 26, 2026
d66f0b5
chore: add fastmcp and zod to websocket app
dkisser Jun 26, 2026
a7029d8
feat(mcp): add per-session browser state store
dkisser Jun 26, 2026
f11d9b6
docs: update plan to use immutable session snapshots
dkisser Jun 26, 2026
249b1e4
feat(mcp): add immutable per-session browser state store
dkisser Jun 26, 2026
caf9477
feat(mcp): add tool and server context types
dkisser Jun 26, 2026
d975243
feat(mcp): add command and event client wrappers
dkisser Jun 26, 2026
4640fdf
docs: fix encode signature in MCP plan test templates
dkisser Jun 26, 2026
9afee23
feat(mcp): add browser lookup helper
dkisser Jun 26, 2026
6c22dc8
feat(mcp): add list_browsers tool
dkisser Jun 26, 2026
ed0a1fb
feat(mcp): add set_browser tool
dkisser Jun 26, 2026
966c959
feat(mcp): add navigate tool
dkisser Jun 26, 2026
a56a31a
feat(mcp): add click tool
dkisser Jun 26, 2026
8ba7964
chore(mcp): remove unused import and format navigate tool
dkisser Jun 26, 2026
5784442
feat(mcp): add type tool
dkisser Jun 26, 2026
e058009
feat(mcp): add screenshot tool
dkisser Jun 26, 2026
3d29d86
feat(mcp): add pageinfo tool
dkisser Jun 26, 2026
c726b7f
feat(mcp): add FastMCP server bootstrap
dkisser Jun 26, 2026
4ee8278
feat(mcp): start MCP server alongside websocket server
dkisser Jun 26, 2026
f7c8fde
docs: add MCP setup guide and README section
dkisser Jun 26, 2026
fb71d52
feat(mcp): add browser resolution logic
dkisser Jun 26, 2026
0a73ca0
chore(mcp): apply Biome formatting fixes
dkisser Jun 26, 2026
6be537e
fix(mcp): use loopback websocket URL and remove startup log
dkisser Jun 26, 2026
a4e5d83
feat(mcp): add wait_element tool
dkisser Jun 26, 2026
9d15c16
feat(mcp): add tab_list tool
dkisser Jun 26, 2026
404de72
feat(mcp): add go_back tool
dkisser Jun 26, 2026
f9c7e46
feat(mcp): add go_forward tool
dkisser Jun 26, 2026
f71abe3
feat(mcp): add wait_navigation tool
dkisser Jun 26, 2026
8cbfe5e
feat(mcp): add refresh tool
dkisser Jun 26, 2026
fa0c1c0
feat(mcp): add tab_new tool
dkisser Jun 26, 2026
987a4fb
feat(mcp): add get_html tool
dkisser Jun 26, 2026
3c0af8b
feat(mcp): add hover tool
dkisser Jun 26, 2026
6df434e
feat(mcp): add scroll tool
dkisser Jun 26, 2026
144bf86
feat(mcp): add tab_close tool
dkisser Jun 26, 2026
418393a
feat(mcp): add tab_switch tool
dkisser Jun 26, 2026
2df7e6e
feat(mcp): register all new CLI-aligned tools and add fullPage to scr…
dkisser Jun 26, 2026
b2f86fd
docs(mcp): document all CLI-aligned tools
dkisser Jun 26, 2026
87a4b7c
test(mcp): add tool registration coverage test
dkisser Jun 26, 2026
eb38ce2
docs: add plan for aligning MCP tools with CLI commands
dkisser Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion apps/websocket/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 42 additions & 8 deletions apps/websocket/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
183 changes: 183 additions & 0 deletions apps/websocket/src/mcp/__tests__/browser-lookup.test.ts
Original file line number Diff line number Diff line change
@@ -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<undefined>, 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();
}
});
});
71 changes: 71 additions & 0 deletions apps/websocket/src/mcp/__tests__/browser-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
48 changes: 48 additions & 0 deletions apps/websocket/src/mcp/__tests__/browser-session.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading