Skip to content

Commit 29a3ac8

Browse files
ralyodioclaude
andauthored
feat(m3.6): local MCP server (tron mcp) (#33)
Adds `tron mcp`, a local Model Context Protocol server over stdio (PRD §14), exposing TronBrowser's browser + analyze tools to any MCP host. Dependency-free protocol layer (packages/sdk/src/mcp) — a minimal JSON-RPC 2.0 server (initialize/tools/list/tools/call/ping) so the shipped runtime stays self-contained (no @modelcontextprotocol/sdk). Tools wrap the SDK Browser/Page, so M3.1–M3.5 are reused rather than reimplemented. Tools: browser_open/snapshot/click/fill/type/press/select/scroll/wait/extract/ screenshot/tabs/close, plus the AI-assisted browser_analyze (non-mutating dry-run), browser_step, and browser_run_task. Mutating tools return a fresh snapshot; screenshot returns image content. The managed session launches lazily on first tool use and tears down on browser_close. install.sh routes `tron mcp` via tron-node.mjs with TRON_SESSION_BIN; the server ships in the existing sdk/ payload. Tests (+11): protocol (initialize/list/call/ping/unknown/notification), all tools against a fake page (mutations return snapshots, screenshot image, analyze dry-run, close), and the newline-delimited stdio transport. Verified the full server end-to-end over stdio (initialize -> tools/list -> browser_open -> browser_close) against a WS CDP stub session. Full workspace suite green. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3ff866e commit 29a3ac8

9 files changed

Lines changed: 630 additions & 0 deletions

File tree

apps/web/public/install.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Usage:
9292
tron headless <url> One-shot: --snapshot | --screenshot <p> | --extract <mode>
9393
tron run <script> Run a JS/TS script using @tronbrowser/sdk (--headless/--trace)
9494
tron analyze [goal] Analyze/fill a form or page (--data, --execute, --json)
95+
tron mcp Run a local MCP server over stdio (--headless)
9596
tron upgrade Update to the latest release
9697
tron remove Uninstall TronBrowser (keeps your profile data)
9798
tron version Print the installed version
@@ -188,6 +189,14 @@ case "${1:-}" in
188189
headless)
189190
# One-shot: launch a headless ephemeral session, navigate, act, tear down.
190191
run_automation "$@" ;;
192+
mcp)
193+
# Local Model Context Protocol server over stdio (PRD M3.6).
194+
shift
195+
_ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")"
196+
ENTRY="$_ld/sdk/mcp-bin.js"
197+
command -v node >/dev/null 2>&1 || { echo "tron mcp needs Node.js (>=22) on PATH." >&2; exit 1; }
198+
[ -f "$ENTRY" ] || { echo "This TronBrowser build lacks the MCP server. Run: tron upgrade" >&2; exit 1; }
199+
exec env TRON_SESSION_BIN="$(session_bin)" node "$_ld/tron-node.mjs" "$ENTRY" "$@" ;;
191200
run)
192201
# Execute a JS/TS automation script that imports @tronbrowser/sdk (PRD M3.4).
193202
shift

docs/mcp.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# MCP server (M3.6)
2+
3+
`tron mcp` runs a local [Model Context Protocol](https://modelcontextprotocol.io)
4+
server over **stdio**, exposing TronBrowser's browser + analyze tools to any MCP
5+
host (Claude Desktop, IDE agents, etc.).
6+
7+
```sh
8+
tron mcp # headed managed session
9+
tron mcp --headless # headless
10+
tron mcp --profile work
11+
```
12+
13+
It speaks newline-delimited JSON-RPC 2.0 on stdin/stdout — **local only**, never
14+
a network listener. The managed session launches lazily on the first tool call
15+
and is torn down on `browser_close` (or when the host disconnects).
16+
17+
## Example host config
18+
19+
```json
20+
{
21+
"mcpServers": {
22+
"tronbrowser": { "command": "tron", "args": ["mcp", "--headless"] }
23+
}
24+
}
25+
```
26+
27+
## Tools
28+
29+
Primitive browser tools:
30+
31+
```
32+
browser_open browser_snapshot browser_click browser_fill
33+
browser_type browser_press browser_select browser_scroll
34+
browser_wait browser_extract browser_screenshot browser_tabs
35+
browser_close
36+
```
37+
38+
AI-assisted unknown-interface tools:
39+
40+
```
41+
browser_analyze # non-mutating: analyze the page / map a form to data (dry-run)
42+
browser_step # one validated action toward a goal
43+
browser_run_task # bounded unknown-interface task
44+
```
45+
46+
- **Mutating tools return a fresh snapshot** (open/click/fill/type/press/select/
47+
scroll), so the host always sees current, ref-tagged page state.
48+
- `browser_screenshot` returns image content (PNG); `browser_extract`,
49+
`browser_analyze`, `browser_step`, `browser_run_task`, and `browser_tabs`
50+
return JSON text.
51+
- `browser_analyze`/`step`/`run_task` are backed by the deterministic analyze
52+
engine (M3.5), so form-fill works without an AI provider; open-ended goals
53+
report `AI_PROVIDER_NOT_CONFIGURED`.
54+
55+
## How it works
56+
57+
- The shell `tron` dispatcher runs `sdk/mcp-bin.js` via `tron-node.mjs` (which
58+
resolves the `@tronbrowser/*` imports) with `TRON_SESSION_BIN` set so the
59+
server can launch/close its managed session.
60+
- The MCP protocol layer is a small, dependency-free JSON-RPC 2.0 server
61+
(`packages/sdk/src/mcp`) — no `@modelcontextprotocol/sdk` dependency, keeping
62+
the shipped runtime self-contained. Tools wrap the SDK `Browser`/`Page`.
63+
64+
## Scope
65+
66+
- Requires Node ≥22. Runs over stdio; remote transports are out of scope.
67+
- Cookies/local storage are not exposed as tools.

packages/sdk/src/mcp-bin.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* `tron mcp` — local MCP server over stdio (PRD M3.6). Built into the launcher
3+
* payload (sdk/mcp-bin.js) and run via tron-node.mjs so @tronbrowser/* resolve.
4+
*/
5+
import { createMcpServer } from './mcp/server.js';
6+
import { serveStdio } from './mcp/server.js';
7+
import { McpBrowserSession } from './mcp/session.js';
8+
9+
const argv = process.argv.slice(2);
10+
const headless = argv.includes('--headless');
11+
const profileIdx = argv.indexOf('--profile');
12+
const profile = profileIdx >= 0 ? argv[profileIdx + 1] : undefined;
13+
14+
const session = McpBrowserSession.fromSdk({ headless, ...(profile ? { profile } : {}) });
15+
const server = createMcpServer(session);
16+
17+
process.stderr.write('tron mcp: TronBrowser MCP server on stdio\n');
18+
19+
serveStdio(server, {
20+
input: process.stdin,
21+
write: (line) => process.stdout.write(line),
22+
}).catch((err: unknown) => {
23+
process.stderr.write(`tron mcp: ${err instanceof Error ? err.message : String(err)}\n`);
24+
process.exit(1);
25+
});

packages/sdk/src/mcp/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @tronbrowser/sdk — MCP server (PRD M3.6). Local stdio Model Context Protocol
3+
* server exposing TronBrowser's browser + analyze tools.
4+
*/
5+
export * from './protocol.js';
6+
export * from './session.js';
7+
export * from './tools.js';
8+
export * from './server.js';

packages/sdk/src/mcp/mcp.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Readable } from 'node:stream';
2+
import { describe, expect, it } from 'vitest';
3+
import type { AgentSnapshot } from '@tronbrowser/browser-core';
4+
import { McpServer } from './protocol.js';
5+
import { McpBrowserSession, type McpPage } from './session.js';
6+
import { createMcpServer, serveStdio } from './server.js';
7+
8+
const SNAP: AgentSnapshot = {
9+
url: 'https://x/', title: 'X', timestamp: 't',
10+
elements: [{ ref: '@e1', role: 'link', name: 'More', tag: 'a', interactive: true, visible: true }],
11+
};
12+
13+
function fakePage() {
14+
const calls: string[] = [];
15+
const page: McpPage = {
16+
id: 'p1',
17+
goto: async (u) => { calls.push('goto:' + u); },
18+
snapshot: async () => SNAP,
19+
click: async (r) => { calls.push('click:' + r); },
20+
fill: async (r, v) => { calls.push('fill:' + r + '=' + v); },
21+
extract: async (m) => ({ mode: m }),
22+
screenshot: async () => Buffer.from('PNG'),
23+
eval: async () => { calls.push('eval'); return null as never; },
24+
url: async () => 'https://x/',
25+
title: async () => 'X',
26+
analyze: async (g) => ({ ok: true, mode: 'dry-run', status: 'planned', page: { url: 'x', title: 'X' }, ...(g ? { goal: g } : {}) }),
27+
step: async () => ({ ok: true, mode: 'execute', status: 'acted', page: { url: 'x', title: 'X' } }),
28+
runTask: async () => ({ ok: true, mode: 'execute', status: 'complete', page: { url: 'x', title: 'X' } }),
29+
};
30+
return { page, calls };
31+
}
32+
33+
function harness() {
34+
const { page, calls } = fakePage();
35+
let closed = false;
36+
const session = new McpBrowserSession(async () => ({ page, close: async () => { closed = true; } }));
37+
const server = createMcpServer(session);
38+
const call = async (name: string, args: Record<string, unknown> = {}) =>
39+
server.handle({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
40+
return { server, call, calls, isClosed: () => closed };
41+
}
42+
43+
describe('MCP protocol', () => {
44+
const { server } = harness();
45+
it('handles initialize with capabilities + serverInfo', async () => {
46+
const r = await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } });
47+
expect((r!.result as { serverInfo: { name: string } }).serverInfo.name).toBe('tronbrowser');
48+
expect((r!.result as { protocolVersion: string }).protocolVersion).toBe('2025-06-18');
49+
expect((r!.result as { capabilities: { tools: unknown } }).capabilities.tools).toBeDefined();
50+
});
51+
it('lists all browser tools', async () => {
52+
const r = await server.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
53+
const names = (r!.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
54+
expect(names).toEqual(expect.arrayContaining([
55+
'browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_extract',
56+
'browser_screenshot', 'browser_tabs', 'browser_close', 'browser_analyze', 'browser_step', 'browser_run_task',
57+
]));
58+
});
59+
it('returns null for a notification (no id)', async () => {
60+
expect(await server.handle({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBeNull();
61+
});
62+
it('answers ping and errors on unknown method', async () => {
63+
expect((await server.handle({ jsonrpc: '2.0', id: 3, method: 'ping' }))!.result).toEqual({});
64+
const e = await server.handle({ jsonrpc: '2.0', id: 4, method: 'nope' });
65+
expect(e!.error!.code).toBe(-32601);
66+
});
67+
});
68+
69+
describe('MCP tools', () => {
70+
it('browser_open navigates and returns a fresh snapshot', async () => {
71+
const { call, calls } = harness();
72+
const r = await call('browser_open', { url: 'https://x/' });
73+
expect(calls).toContain('goto:https://x/');
74+
expect((r!.result as { content: Array<{ text: string }> }).content[0].text).toContain('@e1 link "More"');
75+
});
76+
it('browser_click / browser_fill act then return a snapshot', async () => {
77+
const { call, calls } = harness();
78+
await call('browser_click', { ref: '@e1' });
79+
await call('browser_fill', { ref: '@e2', value: 'hi' });
80+
expect(calls).toContain('click:@e1');
81+
expect(calls).toContain('fill:@e2=hi');
82+
});
83+
it('browser_screenshot returns image content', async () => {
84+
const { call } = harness();
85+
const r = await call('browser_screenshot');
86+
const content = (r!.result as { content: Array<{ type: string; data: string; mimeType: string }> }).content[0];
87+
expect(content.type).toBe('image');
88+
expect(content.mimeType).toBe('image/png');
89+
expect(Buffer.from(content.data, 'base64').toString()).toBe('PNG');
90+
});
91+
it('browser_analyze returns a dry-run result', async () => {
92+
const { call } = harness();
93+
const r = await call('browser_analyze', { goal: 'Fill form' });
94+
const parsed = JSON.parse((r!.result as { content: Array<{ text: string }> }).content[0].text);
95+
expect(parsed.status).toBe('planned');
96+
expect(parsed.goal).toBe('Fill form');
97+
});
98+
it('browser_close closes an opened session', async () => {
99+
const { call, isClosed } = harness();
100+
await call('browser_snapshot'); // opens the session
101+
await call('browser_close');
102+
expect(isClosed()).toBe(true);
103+
});
104+
it('reports isError for an unknown tool', async () => {
105+
const { call } = harness();
106+
const r = await call('browser_bogus');
107+
expect((r!.result as { isError: boolean }).isError).toBe(true);
108+
});
109+
});
110+
111+
describe('MCP stdio transport', () => {
112+
it('reads newline-delimited requests and writes responses', async () => {
113+
const server = new McpServer({ name: 'tronbrowser', version: '3.7' });
114+
const out: string[] = [];
115+
await serveStdio(server, {
116+
input: Readable.from([
117+
'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n',
118+
'{"jsonrpc":"2.0","method":"notifications/initialized"}\n',
119+
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n',
120+
]),
121+
write: (line) => out.push(line.trim()),
122+
});
123+
// one response for initialize, none for the notification, one for tools/list
124+
expect(out).toHaveLength(2);
125+
expect(JSON.parse(out[0]).id).toBe(1);
126+
expect(JSON.parse(out[1]).id).toBe(2);
127+
});
128+
});

packages/sdk/src/mcp/protocol.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Minimal Model Context Protocol server core (PRD M3.6). Dependency-free:
3+
* implements the JSON-RPC 2.0 methods an MCP host needs (initialize, tools/list,
4+
* tools/call, ping) so the shipped runtime stays self-contained. Transport-
5+
* agnostic — `handle()` maps a parsed message to a response (or null for
6+
* notifications); `mcp-bin` wires it to newline-delimited stdio.
7+
*/
8+
9+
export interface JsonRpcMessage {
10+
jsonrpc: '2.0';
11+
id?: number | string;
12+
method?: string;
13+
params?: Record<string, unknown>;
14+
result?: unknown;
15+
error?: { code: number; message: string };
16+
}
17+
18+
export type McpContent =
19+
| { type: 'text'; text: string }
20+
| { type: 'image'; data: string; mimeType: string };
21+
22+
export interface McpTool {
23+
name: string;
24+
description: string;
25+
inputSchema: Record<string, unknown>;
26+
handler(args: Record<string, unknown>): Promise<McpContent[]>;
27+
}
28+
29+
export interface McpServerInfo {
30+
name: string;
31+
version: string;
32+
}
33+
34+
const DEFAULT_PROTOCOL_VERSION = '2024-11-05';
35+
36+
function ok(id: number | string, result: unknown): JsonRpcMessage {
37+
return { jsonrpc: '2.0', id, result };
38+
}
39+
function fail(id: number | string, code: number, message: string): JsonRpcMessage {
40+
return { jsonrpc: '2.0', id, error: { code, message } };
41+
}
42+
43+
export class McpServer {
44+
readonly #info: McpServerInfo;
45+
readonly #tools = new Map<string, McpTool>();
46+
47+
constructor(info: McpServerInfo) {
48+
this.#info = info;
49+
}
50+
51+
register(tool: McpTool): void {
52+
this.#tools.set(tool.name, tool);
53+
}
54+
55+
tools(): McpTool[] {
56+
return [...this.#tools.values()];
57+
}
58+
59+
/** Handle one JSON-RPC message; returns a response, or null for notifications. */
60+
async handle(msg: JsonRpcMessage): Promise<JsonRpcMessage | null> {
61+
// Notifications have no id and expect no response.
62+
if (msg.id === undefined) return null;
63+
const id = msg.id;
64+
65+
switch (msg.method) {
66+
case 'initialize': {
67+
const requested = (msg.params?.protocolVersion as string | undefined) ?? DEFAULT_PROTOCOL_VERSION;
68+
return ok(id, {
69+
protocolVersion: requested,
70+
capabilities: { tools: { listChanged: false } },
71+
serverInfo: this.#info,
72+
});
73+
}
74+
case 'ping':
75+
return ok(id, {});
76+
case 'tools/list':
77+
return ok(id, {
78+
tools: this.tools().map((t) => ({
79+
name: t.name,
80+
description: t.description,
81+
inputSchema: t.inputSchema,
82+
})),
83+
});
84+
case 'tools/call': {
85+
const name = msg.params?.name as string | undefined;
86+
const args = (msg.params?.arguments as Record<string, unknown> | undefined) ?? {};
87+
const tool = name ? this.#tools.get(name) : undefined;
88+
if (!tool) {
89+
return ok(id, { content: [{ type: 'text', text: `Unknown tool: ${name ?? '(none)'}` }], isError: true });
90+
}
91+
try {
92+
return ok(id, { content: await tool.handler(args) });
93+
} catch (err) {
94+
return ok(id, {
95+
content: [{ type: 'text', text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
96+
isError: true,
97+
});
98+
}
99+
}
100+
default:
101+
return fail(id, -32601, `Method not found: ${msg.method ?? '(none)'}`);
102+
}
103+
}
104+
}

0 commit comments

Comments
 (0)