|
| 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 | +}); |
0 commit comments