Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Usage:
tron headless <url> One-shot: --snapshot | --screenshot <p> | --extract <mode>
tron run <script> Run a JS/TS script using @tronbrowser/sdk (--headless/--trace)
tron analyze [goal] Analyze/fill a form or page (--data, --execute, --json)
tron mcp Run a local MCP server over stdio (--headless)
tron upgrade Update to the latest release
tron remove Uninstall TronBrowser (keeps your profile data)
tron version Print the installed version
Expand Down Expand Up @@ -188,6 +189,14 @@ case "${1:-}" in
headless)
# One-shot: launch a headless ephemeral session, navigate, act, tear down.
run_automation "$@" ;;
mcp)
# Local Model Context Protocol server over stdio (PRD M3.6).
shift
_ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")"
ENTRY="$_ld/sdk/mcp-bin.js"
command -v node >/dev/null 2>&1 || { echo "tron mcp needs Node.js (>=22) on PATH." >&2; exit 1; }
[ -f "$ENTRY" ] || { echo "This TronBrowser build lacks the MCP server. Run: tron upgrade" >&2; exit 1; }
exec env TRON_SESSION_BIN="$(session_bin)" node "$_ld/tron-node.mjs" "$ENTRY" "$@" ;;
run)
# Execute a JS/TS automation script that imports @tronbrowser/sdk (PRD M3.4).
shift
Expand Down
67 changes: 67 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# MCP server (M3.6)

`tron mcp` runs a local [Model Context Protocol](https://modelcontextprotocol.io)
server over **stdio**, exposing TronBrowser's browser + analyze tools to any MCP
host (Claude Desktop, IDE agents, etc.).

```sh
tron mcp # headed managed session
tron mcp --headless # headless
tron mcp --profile work
```

It speaks newline-delimited JSON-RPC 2.0 on stdin/stdout — **local only**, never
a network listener. The managed session launches lazily on the first tool call
and is torn down on `browser_close` (or when the host disconnects).

## Example host config

```json
{
"mcpServers": {
"tronbrowser": { "command": "tron", "args": ["mcp", "--headless"] }
}
}
```

## Tools

Primitive browser tools:

```
browser_open browser_snapshot browser_click browser_fill
browser_type browser_press browser_select browser_scroll
browser_wait browser_extract browser_screenshot browser_tabs
browser_close
```

AI-assisted unknown-interface tools:

```
browser_analyze # non-mutating: analyze the page / map a form to data (dry-run)
browser_step # one validated action toward a goal
browser_run_task # bounded unknown-interface task
```

- **Mutating tools return a fresh snapshot** (open/click/fill/type/press/select/
scroll), so the host always sees current, ref-tagged page state.
- `browser_screenshot` returns image content (PNG); `browser_extract`,
`browser_analyze`, `browser_step`, `browser_run_task`, and `browser_tabs`
return JSON text.
- `browser_analyze`/`step`/`run_task` are backed by the deterministic analyze
engine (M3.5), so form-fill works without an AI provider; open-ended goals
report `AI_PROVIDER_NOT_CONFIGURED`.

## How it works

- The shell `tron` dispatcher runs `sdk/mcp-bin.js` via `tron-node.mjs` (which
resolves the `@tronbrowser/*` imports) with `TRON_SESSION_BIN` set so the
server can launch/close its managed session.
- The MCP protocol layer is a small, dependency-free JSON-RPC 2.0 server
(`packages/sdk/src/mcp`) — no `@modelcontextprotocol/sdk` dependency, keeping
the shipped runtime self-contained. Tools wrap the SDK `Browser`/`Page`.

## Scope

- Requires Node ≥22. Runs over stdio; remote transports are out of scope.
- Cookies/local storage are not exposed as tools.
25 changes: 25 additions & 0 deletions packages/sdk/src/mcp-bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* `tron mcp` — local MCP server over stdio (PRD M3.6). Built into the launcher
* payload (sdk/mcp-bin.js) and run via tron-node.mjs so @tronbrowser/* resolve.
*/
import { createMcpServer } from './mcp/server.js';
import { serveStdio } from './mcp/server.js';
import { McpBrowserSession } from './mcp/session.js';

const argv = process.argv.slice(2);
const headless = argv.includes('--headless');
const profileIdx = argv.indexOf('--profile');
const profile = profileIdx >= 0 ? argv[profileIdx + 1] : undefined;

const session = McpBrowserSession.fromSdk({ headless, ...(profile ? { profile } : {}) });
const server = createMcpServer(session);

process.stderr.write('tron mcp: TronBrowser MCP server on stdio\n');

serveStdio(server, {
input: process.stdin,
write: (line) => process.stdout.write(line),
}).catch((err: unknown) => {
process.stderr.write(`tron mcp: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});
8 changes: 8 additions & 0 deletions packages/sdk/src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @tronbrowser/sdk — MCP server (PRD M3.6). Local stdio Model Context Protocol
* server exposing TronBrowser's browser + analyze tools.
*/
export * from './protocol.js';
export * from './session.js';
export * from './tools.js';
export * from './server.js';
128 changes: 128 additions & 0 deletions packages/sdk/src/mcp/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Readable } from 'node:stream';
import { describe, expect, it } from 'vitest';
import type { AgentSnapshot } from '@tronbrowser/browser-core';
import { McpServer } from './protocol.js';
import { McpBrowserSession, type McpPage } from './session.js';
import { createMcpServer, serveStdio } from './server.js';

const SNAP: AgentSnapshot = {
url: 'https://x/', title: 'X', timestamp: 't',
elements: [{ ref: '@e1', role: 'link', name: 'More', tag: 'a', interactive: true, visible: true }],
};

function fakePage() {
const calls: string[] = [];
const page: McpPage = {
id: 'p1',
goto: async (u) => { calls.push('goto:' + u); },
snapshot: async () => SNAP,
click: async (r) => { calls.push('click:' + r); },
fill: async (r, v) => { calls.push('fill:' + r + '=' + v); },
extract: async (m) => ({ mode: m }),
screenshot: async () => Buffer.from('PNG'),
eval: async () => { calls.push('eval'); return null as never; },
url: async () => 'https://x/',
title: async () => 'X',
analyze: async (g) => ({ ok: true, mode: 'dry-run', status: 'planned', page: { url: 'x', title: 'X' }, ...(g ? { goal: g } : {}) }),
step: async () => ({ ok: true, mode: 'execute', status: 'acted', page: { url: 'x', title: 'X' } }),
runTask: async () => ({ ok: true, mode: 'execute', status: 'complete', page: { url: 'x', title: 'X' } }),
};
return { page, calls };
}

function harness() {
const { page, calls } = fakePage();
let closed = false;
const session = new McpBrowserSession(async () => ({ page, close: async () => { closed = true; } }));
const server = createMcpServer(session);
const call = async (name: string, args: Record<string, unknown> = {}) =>
server.handle({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
return { server, call, calls, isClosed: () => closed };
}

describe('MCP protocol', () => {
const { server } = harness();
it('handles initialize with capabilities + serverInfo', async () => {
const r = await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } });
expect((r!.result as { serverInfo: { name: string } }).serverInfo.name).toBe('tronbrowser');
expect((r!.result as { protocolVersion: string }).protocolVersion).toBe('2025-06-18');
expect((r!.result as { capabilities: { tools: unknown } }).capabilities.tools).toBeDefined();
});
it('lists all browser tools', async () => {
const r = await server.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
const names = (r!.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toEqual(expect.arrayContaining([
'browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_extract',
'browser_screenshot', 'browser_tabs', 'browser_close', 'browser_analyze', 'browser_step', 'browser_run_task',
]));
});
it('returns null for a notification (no id)', async () => {
expect(await server.handle({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBeNull();
});
it('answers ping and errors on unknown method', async () => {
expect((await server.handle({ jsonrpc: '2.0', id: 3, method: 'ping' }))!.result).toEqual({});
const e = await server.handle({ jsonrpc: '2.0', id: 4, method: 'nope' });
expect(e!.error!.code).toBe(-32601);
});
});

describe('MCP tools', () => {
it('browser_open navigates and returns a fresh snapshot', async () => {
const { call, calls } = harness();
const r = await call('browser_open', { url: 'https://x/' });
expect(calls).toContain('goto:https://x/');
expect((r!.result as { content: Array<{ text: string }> }).content[0].text).toContain('@e1 link "More"');
});
it('browser_click / browser_fill act then return a snapshot', async () => {
const { call, calls } = harness();
await call('browser_click', { ref: '@e1' });
await call('browser_fill', { ref: '@e2', value: 'hi' });
expect(calls).toContain('click:@e1');
expect(calls).toContain('fill:@e2=hi');
});
it('browser_screenshot returns image content', async () => {
const { call } = harness();
const r = await call('browser_screenshot');
const content = (r!.result as { content: Array<{ type: string; data: string; mimeType: string }> }).content[0];
expect(content.type).toBe('image');
expect(content.mimeType).toBe('image/png');
expect(Buffer.from(content.data, 'base64').toString()).toBe('PNG');
});
it('browser_analyze returns a dry-run result', async () => {
const { call } = harness();
const r = await call('browser_analyze', { goal: 'Fill form' });
const parsed = JSON.parse((r!.result as { content: Array<{ text: string }> }).content[0].text);
expect(parsed.status).toBe('planned');
expect(parsed.goal).toBe('Fill form');
});
it('browser_close closes an opened session', async () => {
const { call, isClosed } = harness();
await call('browser_snapshot'); // opens the session
await call('browser_close');
expect(isClosed()).toBe(true);
});
it('reports isError for an unknown tool', async () => {
const { call } = harness();
const r = await call('browser_bogus');
expect((r!.result as { isError: boolean }).isError).toBe(true);
});
});

describe('MCP stdio transport', () => {
it('reads newline-delimited requests and writes responses', async () => {
const server = new McpServer({ name: 'tronbrowser', version: '3.7' });
const out: string[] = [];
await serveStdio(server, {
input: Readable.from([
'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n',
'{"jsonrpc":"2.0","method":"notifications/initialized"}\n',
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n',
]),
write: (line) => out.push(line.trim()),
});
// one response for initialize, none for the notification, one for tools/list
expect(out).toHaveLength(2);
expect(JSON.parse(out[0]).id).toBe(1);
expect(JSON.parse(out[1]).id).toBe(2);
});
});
104 changes: 104 additions & 0 deletions packages/sdk/src/mcp/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Minimal Model Context Protocol server core (PRD M3.6). Dependency-free:
* implements the JSON-RPC 2.0 methods an MCP host needs (initialize, tools/list,
* tools/call, ping) so the shipped runtime stays self-contained. Transport-
* agnostic — `handle()` maps a parsed message to a response (or null for
* notifications); `mcp-bin` wires it to newline-delimited stdio.
*/

export interface JsonRpcMessage {
jsonrpc: '2.0';
id?: number | string;
method?: string;
params?: Record<string, unknown>;
result?: unknown;
error?: { code: number; message: string };
}

export type McpContent =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string };

export interface McpTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
handler(args: Record<string, unknown>): Promise<McpContent[]>;
}

export interface McpServerInfo {
name: string;
version: string;
}

const DEFAULT_PROTOCOL_VERSION = '2024-11-05';

function ok(id: number | string, result: unknown): JsonRpcMessage {
return { jsonrpc: '2.0', id, result };
}
function fail(id: number | string, code: number, message: string): JsonRpcMessage {
return { jsonrpc: '2.0', id, error: { code, message } };
}

export class McpServer {
readonly #info: McpServerInfo;
readonly #tools = new Map<string, McpTool>();

constructor(info: McpServerInfo) {
this.#info = info;
}

register(tool: McpTool): void {
this.#tools.set(tool.name, tool);
}

tools(): McpTool[] {
return [...this.#tools.values()];
}

/** Handle one JSON-RPC message; returns a response, or null for notifications. */
async handle(msg: JsonRpcMessage): Promise<JsonRpcMessage | null> {
// Notifications have no id and expect no response.
if (msg.id === undefined) return null;
const id = msg.id;

switch (msg.method) {
case 'initialize': {
const requested = (msg.params?.protocolVersion as string | undefined) ?? DEFAULT_PROTOCOL_VERSION;
return ok(id, {
protocolVersion: requested,
capabilities: { tools: { listChanged: false } },
serverInfo: this.#info,
});
}
case 'ping':
return ok(id, {});
case 'tools/list':
return ok(id, {
tools: this.tools().map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
});
case 'tools/call': {
const name = msg.params?.name as string | undefined;
const args = (msg.params?.arguments as Record<string, unknown> | undefined) ?? {};
const tool = name ? this.#tools.get(name) : undefined;
if (!tool) {
return ok(id, { content: [{ type: 'text', text: `Unknown tool: ${name ?? '(none)'}` }], isError: true });
}
try {
return ok(id, { content: await tool.handler(args) });
} catch (err) {
return ok(id, {
content: [{ type: 'text', text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
});
}
}
default:
return fail(id, -32601, `Method not found: ${msg.method ?? '(none)'}`);
}
}
}
Loading
Loading