weboperator-mcp 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge.js ADDED
@@ -0,0 +1,716 @@
1
+ #!/usr/bin/env node
2
+ // WebOperator Bridge — framed agent socket plus compatibility HTTP API over Chrome Native Messaging.
3
+ const http = require('http');
4
+ const net = require('net');
5
+ const { randomUUID } = require('crypto');
6
+ const fs = require('fs');
7
+
8
+ const HOST = process.env.WEBOPERATOR_BRIDGE_HOST || '127.0.0.1';
9
+ const PORT = Number(process.env.WEBOPERATOR_BRIDGE_PORT || 8765);
10
+ const LOG = process.env.WEBOPERATOR_BRIDGE_LOG || '/tmp/weboperator-bridge.log';
11
+ const API_TOKEN = process.env.WEBOPERATOR_API_TOKEN || '';
12
+ const ALLOW_UNAUTHENTICATED = process.env.WEBOPERATOR_ALLOW_UNAUTHENTICATED_BRIDGE !== '0';
13
+ const AGENT_SOCKET = process.env.WEBOPERATOR_AGENT_SOCKET || '/tmp/weboperator-bridge.sock';
14
+
15
+
16
+ const PID_FILE = process.env.WEBOPERATOR_BRIDGE_PID_FILE || `/tmp/weboperator-bridge-${PORT}.pid`;
17
+
18
+
19
+ function log(line) {
20
+ try { fs.appendFileSync(LOG, `${new Date().toISOString()} ${line}\n`); } catch {}
21
+ }
22
+
23
+ function ensureSingleInstance() {
24
+ try {
25
+ if (fs.existsSync(PID_FILE)) {
26
+ const oldPid = Number(fs.readFileSync(PID_FILE, 'utf8').trim());
27
+ if (oldPid && oldPid !== process.pid) {
28
+ try { process.kill(oldPid, 'SIGTERM'); } catch {}
29
+ }
30
+ }
31
+ } catch {}
32
+ try { fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
33
+ }
34
+
35
+ ensureSingleInstance();
36
+
37
+ log(`process start pid=${process.pid} ppid=${process.ppid} argv=${JSON.stringify(redactArgv(process.argv))} cwd=${process.cwd()}`);
38
+ process.on('beforeExit', (code) => log(`beforeExit code=${code}`));
39
+ process.on('exit', (code) => {
40
+ log(`exit code=${code}`);
41
+ try { if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); } catch {}
42
+ });
43
+
44
+ process.on('uncaughtException', (err) => log(`uncaughtException ${err && err.stack ? err.stack : err}`));
45
+ process.on('unhandledRejection', (err) => log(`unhandledRejection ${err && err.stack ? err.stack : err}`));
46
+ process.on('SIGTERM', () => {
47
+ log('SIGTERM received');
48
+ process.exit(0);
49
+ });
50
+ process.on('SIGINT', () => {
51
+ log('SIGINT received');
52
+ process.exit(130);
53
+ });
54
+ process.stdout.on('error', (err) => log(`stdout error: ${err.message}`));
55
+ process.stderr.on('error', (err) => log(`stderr error: ${err.message}`));
56
+
57
+ function redactArgv(argv) {
58
+ const redacted = [];
59
+ let redactNext = false;
60
+ for (const arg of argv) {
61
+ if (redactNext) {
62
+ redacted.push('[REDACTED]');
63
+ redactNext = false;
64
+ continue;
65
+ }
66
+ if (/^(--?(?:api[-_]?token|token|secret|password|key))$/i.test(arg)) {
67
+ redacted.push(arg);
68
+ redactNext = true;
69
+ continue;
70
+ }
71
+ redacted.push(arg.replace(/^(--?(?:api[-_]?token|token|secret|password|key)=).+/i, '$1[REDACTED]'));
72
+ }
73
+ return redacted;
74
+ }
75
+
76
+ let extensionOnline = false;
77
+ let inputBuffer = Buffer.alloc(0);
78
+ let nextFrameLength = null;
79
+ const pending = new Map();
80
+ const eventClients = new Map();
81
+ const agentClients = new Set();
82
+
83
+ process.stdin.on('data', (chunk) => {
84
+ inputBuffer = Buffer.concat([inputBuffer, chunk]);
85
+ readFrames();
86
+ });
87
+ process.stdin.on('end', () => {
88
+ log('stdin end');
89
+ extensionOnline = false;
90
+ rejectAll(new Error('Extension native messaging stream closed'));
91
+ });
92
+ process.stdin.on('close', () => {
93
+ log('stdin close');
94
+ });
95
+ process.stdin.on('error', (err) => {
96
+ log(`stdin error: ${err.message}`);
97
+ extensionOnline = false;
98
+ rejectAll(err);
99
+ });
100
+
101
+ function readFrames() {
102
+ while (true) {
103
+ if (nextFrameLength === null) {
104
+ if (inputBuffer.length < 4) return;
105
+ nextFrameLength = inputBuffer.readUInt32LE(0);
106
+ inputBuffer = inputBuffer.slice(4);
107
+ }
108
+ if (inputBuffer.length < nextFrameLength) return;
109
+ const payload = inputBuffer.slice(0, nextFrameLength).toString('utf8');
110
+ inputBuffer = inputBuffer.slice(nextFrameLength);
111
+ nextFrameLength = null;
112
+ try {
113
+ handleNativeMessage(JSON.parse(payload));
114
+ } catch (err) {
115
+ log(`bad native message: ${err.message}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ function sendNative(obj) {
121
+ const json = JSON.stringify(obj);
122
+ const body = Buffer.from(json, 'utf8');
123
+ const header = Buffer.alloc(4);
124
+ header.writeUInt32LE(body.length, 0);
125
+ process.stdout.write(Buffer.concat([header, body]));
126
+ }
127
+
128
+ function handleNativeMessage(msg) {
129
+ if (msg.kind === 'bridge:hello') {
130
+ extensionOnline = true;
131
+ log('extension online');
132
+ return;
133
+ }
134
+
135
+ if (msg.kind === 'bridge:event') {
136
+ publishTaskEvent(msg.event);
137
+ publishAgentEvent(msg.event);
138
+ return;
139
+ }
140
+
141
+ if (msg.kind === 'bridge:response') {
142
+ const item = pending.get(msg.id);
143
+ if (!item) return;
144
+ pending.delete(msg.id);
145
+ clearTimeout(item.timeout);
146
+ if (msg.error) item.reject(new Error(msg.error));
147
+ else item.resolve(msg.result);
148
+ }
149
+ }
150
+
151
+ function rejectAll(err) {
152
+ for (const [id, item] of pending) {
153
+ clearTimeout(item.timeout);
154
+ item.reject(err);
155
+ pending.delete(id);
156
+ }
157
+ }
158
+
159
+ function requestExtension(type, payload = {}, timeoutMs = 60_000) {
160
+ if (!extensionOnline) throw new Error('WebOperator extension is not connected to the bridge');
161
+ const id = randomUUID();
162
+ const promise = new Promise((resolve, reject) => {
163
+ const timeout = setTimeout(() => {
164
+ pending.delete(id);
165
+ reject(new Error(`Bridge request timed out: ${type}`));
166
+ }, timeoutMs);
167
+ pending.set(id, { resolve, reject, timeout });
168
+ });
169
+ sendNative({ kind: 'bridge:request', id, type, payload });
170
+ return promise;
171
+ }
172
+
173
+ const server = http.createServer(async (req, res) => {
174
+ try {
175
+ writeCors(req, res);
176
+ if (req.method === 'OPTIONS') {
177
+ res.writeHead(204);
178
+ res.end();
179
+ return;
180
+ }
181
+
182
+ const url = new URL(req.url || '/', `http://${HOST}:${PORT}`);
183
+ enforceAuth(req, url);
184
+ if (tryHandleEventStream(req, req.method || 'GET', url, res)) return;
185
+
186
+ const body = await readJson(req);
187
+ const result = await route(req.method || 'GET', url, body);
188
+ sendJson(res, 200, result);
189
+ } catch (err) {
190
+ sendJson(res, statusForError(err), { error: err instanceof Error ? err.message : String(err) });
191
+ }
192
+ });
193
+
194
+ server.on('error', (err) => {
195
+ log(`http server error: ${err.message}`);
196
+ if (err.code === 'EADDRINUSE') {
197
+ setTimeout(() => {
198
+ try { server.close(); } catch {}
199
+ server.listen(PORT, HOST, () => log(`http retry listening on http://${HOST}:${PORT}`));
200
+ }, 1000);
201
+ }
202
+ });
203
+ server.listen(PORT, HOST, () => {
204
+ log(`http listening on http://${HOST}:${PORT}`);
205
+ });
206
+
207
+
208
+ startAgentSocket();
209
+
210
+ function writeCors(_req, res) {
211
+ res.setHeader('access-control-allow-origin', 'http://127.0.0.1');
212
+ res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
213
+ res.setHeader('access-control-allow-headers', 'authorization, content-type, x-weboperator-token');
214
+ }
215
+
216
+ async function readJson(req) {
217
+ if (req.method === 'GET' || req.method === 'HEAD') return {};
218
+ const chunks = [];
219
+ for await (const chunk of req) chunks.push(chunk);
220
+ const text = Buffer.concat(chunks).toString('utf8').trim();
221
+ if (!text) return {};
222
+ try { return JSON.parse(text); } catch { throw httpError(400, 'Request body must be JSON'); }
223
+ }
224
+
225
+ function sendJson(res, status, value) {
226
+ const body = JSON.stringify(value, null, 2);
227
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
228
+ res.end(body);
229
+ }
230
+
231
+ function httpError(status, message) {
232
+ const err = new Error(message);
233
+ err.status = status;
234
+ return err;
235
+ }
236
+
237
+ function statusForError(err) {
238
+ return Number(err && err.status) || 500;
239
+ }
240
+
241
+ function enforceAuth(req, url) {
242
+ if (url.pathname.replace(/\/+$/, '') === '/health') return;
243
+ if (!API_TOKEN && ALLOW_UNAUTHENTICATED) return;
244
+ if (!API_TOKEN) throw httpError(401, 'WEBOPERATOR_API_TOKEN is required unless WEBOPERATOR_ALLOW_UNAUTHENTICATED_BRIDGE=1');
245
+
246
+ const authorization = req.headers.authorization || '';
247
+ const bearer = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length).trim() : '';
248
+ const headerToken = String(req.headers['x-weboperator-token'] || '').trim();
249
+ if (bearer === API_TOKEN || headerToken === API_TOKEN) return;
250
+ throw httpError(401, 'Missing or invalid WebOperator API token');
251
+ }
252
+
253
+ async function route(method, url, body) {
254
+ const path = url.pathname.replace(/\/+$/, '') || '/';
255
+
256
+ if (method === 'GET' && path === '/health') {
257
+ return { ok: true, bridge: 'online', extension: extensionOnline ? 'online' : 'offline', authRequired: Boolean(API_TOKEN) };
258
+ }
259
+
260
+ if (method === 'GET' && path === '/v1/tools') {
261
+ return { tools: AGENT_TOOLS };
262
+ }
263
+ if (method === 'POST' && path === '/v1/tools/call') {
264
+ const tool = body.tool || body.name || '';
265
+ const args = body.arguments || body.parameters || body.payload || {};
266
+ const timeout = Number(body.timeoutMs || 30_000);
267
+ return executeToolByName(tool, args, timeout);
268
+ }
269
+ if (method === 'POST' && path === '/mcp') {
270
+ return handleMcpHttp(body);
271
+ }
272
+
273
+ if (method === 'GET' && path === '/v1/browser/snapshot') {
274
+ return requestExtension('browser.snapshot', {}, 30_000);
275
+ }
276
+ if (method === 'GET' && path === '/v1/browser/screenshot') {
277
+ return requestExtension('browser.screenshot', {}, 30_000);
278
+ }
279
+ if (method === 'POST' && path === '/v1/browser/navigate') {
280
+ return requestExtension('browser.navigate', body, 60_000);
281
+ }
282
+ if (method === 'POST' && path === '/v1/browser/click') {
283
+ return requestExtension('browser.click', body, 30_000);
284
+ }
285
+ if (method === 'POST' && path === '/v1/browser/type') {
286
+ return requestExtension('browser.type', body, 30_000);
287
+ }
288
+ if (method === 'POST' && path === '/v1/browser/press') {
289
+ return requestExtension('browser.press', body, 30_000);
290
+ }
291
+ if (method === 'POST' && path === '/v1/browser/scroll') {
292
+ return requestExtension('browser.scroll', body, 30_000);
293
+ }
294
+ if (method === 'POST' && path === '/v1/browser/extract') {
295
+ return requestExtension('browser.extract', body, 30_000);
296
+ }
297
+
298
+ if (method === 'GET' && path === '/v1/tasks') {
299
+ return requestExtension('tasks.list', {}, 30_000);
300
+ }
301
+ if (method === 'POST' && (path === '/v1/tasks' || path === '/v1/goal')) {
302
+ return requestExtension('tasks.start', body, Number(body.timeoutMs || 60_000));
303
+ }
304
+
305
+
306
+
307
+ const taskMatch = path.match(/^\/v1\/tasks\/([^/]+)(?:\/([^/]+))?$/);
308
+ if (taskMatch) {
309
+ const id = decodeURIComponent(taskMatch[1]);
310
+ const action = taskMatch[2] || '';
311
+ if (method === 'GET' && !action) return requestExtension('tasks.get', { id }, 30_000);
312
+ if (method === 'GET' && action === 'trace') return requestExtension('tasks.get', { id }, 30_000);
313
+ if (method === 'GET' && action === 'events') throw httpError(405, 'Use the event stream handler for this endpoint');
314
+ if (method === 'POST' && action === 'stop') return requestExtension('tasks.stop', { id }, 30_000);
315
+ if (method === 'POST' && action === 'pause') return requestExtension('tasks.pause', { id }, 30_000);
316
+ if (method === 'POST' && action === 'resume') return requestExtension('tasks.resume', { id }, 30_000);
317
+ if (method === 'POST' && action === 'confirm') return requestExtension('tasks.confirm', { id, allow: body.allow }, 30_000);
318
+ if (method === 'POST' && action === 'wait') {
319
+ return requestExtension('tasks.wait', { id, timeoutMs: body.timeoutMs }, Number(body.timeoutMs || 120_000) + 5_000);
320
+ }
321
+ }
322
+
323
+ throw httpError(404, `Unknown endpoint: ${method} ${path}`);
324
+ }
325
+
326
+ function tryHandleEventStream(req, method, url, res) {
327
+ const path = url.pathname.replace(/\/+$/, '') || '/';
328
+ const match = path.match(/^\/v1\/tasks\/([^/]+)\/events$/);
329
+ if (!match) return false;
330
+ if (method !== 'GET') throw httpError(405, 'Task event streams require GET');
331
+
332
+ const taskId = decodeURIComponent(match[1]);
333
+ res.writeHead(200, {
334
+ 'content-type': 'text/event-stream; charset=utf-8',
335
+ 'cache-control': 'no-cache, no-transform',
336
+ connection: 'keep-alive',
337
+ 'x-accel-buffering': 'no',
338
+ });
339
+ res.write(': connected\n\n');
340
+
341
+ const client = { res, taskId };
342
+ let clients = eventClients.get(taskId);
343
+ if (!clients) {
344
+ clients = new Set();
345
+ eventClients.set(taskId, clients);
346
+ }
347
+ clients.add(client);
348
+
349
+ const heartbeat = setInterval(() => {
350
+ writeSse(res, 'heartbeat', { at: Date.now() });
351
+ }, 15_000);
352
+
353
+ req.on('close', () => {
354
+ clearInterval(heartbeat);
355
+ clients.delete(client);
356
+ if (clients.size === 0) eventClients.delete(taskId);
357
+ });
358
+
359
+ sendInitialTaskEvent(taskId, res).catch((err) => {
360
+ writeSse(res, 'task.error', { taskId, error: err instanceof Error ? err.message : String(err) });
361
+ });
362
+ return true;
363
+ }
364
+
365
+ async function sendInitialTaskEvent(taskId, res) {
366
+ if (!extensionOnline) {
367
+ writeSse(res, 'bridge.status', { extension: 'offline' });
368
+ return;
369
+ }
370
+ const task = await requestExtension('tasks.get', { id: taskId }, 30_000);
371
+ writeSse(res, 'task.snapshot', { taskId, task });
372
+ }
373
+
374
+ function publishTaskEvent(event) {
375
+ const taskId = taskIdForEvent(event);
376
+ if (!taskId) return;
377
+ const clients = eventClients.get(taskId);
378
+ if (!clients) return;
379
+ const name = String(event.kind || 'task.event').replace(/:/g, '.');
380
+ for (const client of clients) {
381
+ writeSse(client.res, name, event);
382
+ }
383
+ }
384
+
385
+ function taskIdForEvent(event) {
386
+ if (!event || typeof event !== 'object') return '';
387
+ if (typeof event.taskId === 'string') return event.taskId;
388
+ if (event.task && typeof event.task === 'object' && typeof event.task.id === 'string') return event.task.id;
389
+ return '';
390
+ }
391
+
392
+ function writeSse(res, event, data) {
393
+ res.write(`event: ${event}\n`);
394
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
395
+ }
396
+
397
+ function startAgentSocket() {
398
+ try {
399
+ if (fs.existsSync(AGENT_SOCKET)) fs.unlinkSync(AGENT_SOCKET);
400
+ } catch (err) {
401
+ log(`agent socket cleanup failed: ${err.message}`);
402
+ }
403
+
404
+ const socketServer = net.createServer((socket) => {
405
+ const client = { socket, buffer: Buffer.alloc(0), nextFrameLength: null };
406
+ agentClients.add(client);
407
+
408
+ socket.on('data', (chunk) => {
409
+ client.buffer = Buffer.concat([client.buffer, chunk]);
410
+ readAgentFrames(client);
411
+ });
412
+ socket.on('close', () => agentClients.delete(client));
413
+ socket.on('error', (err) => {
414
+ log(`agent socket error: ${err.message}`);
415
+ agentClients.delete(client);
416
+ });
417
+ });
418
+
419
+ socketServer.on('error', (err) => log(`agent socket server error: ${err.message}`));
420
+ socketServer.listen(AGENT_SOCKET, () => {
421
+ try { fs.chmodSync(AGENT_SOCKET, 0o600); } catch {}
422
+ log(`agent socket listening on ${AGENT_SOCKET}`);
423
+ });
424
+
425
+ process.on('exit', () => {
426
+ try { if (fs.existsSync(AGENT_SOCKET)) fs.unlinkSync(AGENT_SOCKET); } catch {}
427
+ });
428
+ }
429
+
430
+ function readAgentFrames(client) {
431
+ while (true) {
432
+ if (client.nextFrameLength === null) {
433
+ if (client.buffer.length < 4) return;
434
+ client.nextFrameLength = client.buffer.readUInt32LE(0);
435
+ client.buffer = client.buffer.slice(4);
436
+ }
437
+ if (client.buffer.length < client.nextFrameLength) return;
438
+ const payload = client.buffer.slice(0, client.nextFrameLength).toString('utf8');
439
+ client.buffer = client.buffer.slice(client.nextFrameLength);
440
+ client.nextFrameLength = null;
441
+ try {
442
+ void handleAgentMessage(client, JSON.parse(payload));
443
+ } catch (err) {
444
+ sendAgentFrame(client.socket, { id: undefined, error: err instanceof Error ? err.message : String(err) });
445
+ }
446
+ }
447
+ }
448
+
449
+ async function handleAgentMessage(client, msg) {
450
+ const id = msg.id || randomUUID();
451
+ try {
452
+ enforceAgentAuth(msg);
453
+ const type = String(msg.type || '');
454
+ const payload = msg.payload && typeof msg.payload === 'object' ? msg.payload : {};
455
+ const timeoutMs = Number(msg.timeoutMs || 60_000);
456
+ const result = type === 'bridge.health'
457
+ ? { ok: true, bridge: 'online', extension: extensionOnline ? 'online' : 'offline', authRequired: Boolean(API_TOKEN) }
458
+ : await requestExtension(type, payload, timeoutMs);
459
+ sendAgentFrame(client.socket, { id, result });
460
+ } catch (err) {
461
+ sendAgentFrame(client.socket, { id, error: err instanceof Error ? err.message : String(err) });
462
+ }
463
+ }
464
+
465
+ function enforceAgentAuth(msg) {
466
+ if (!API_TOKEN && ALLOW_UNAUTHENTICATED) return;
467
+ if (!API_TOKEN) throw new Error('WEBOPERATOR_API_TOKEN is required unless WEBOPERATOR_ALLOW_UNAUTHENTICATED_BRIDGE=1');
468
+ if (msg.token === API_TOKEN) return;
469
+ throw new Error('Missing or invalid WebOperator API token');
470
+ }
471
+
472
+ function publishAgentEvent(event) {
473
+ for (const client of agentClients) {
474
+ sendAgentFrame(client.socket, { kind: 'event', event });
475
+ }
476
+ }
477
+
478
+ function sendAgentFrame(socket, obj) {
479
+ if (socket.destroyed) return;
480
+ const body = Buffer.from(JSON.stringify(obj), 'utf8');
481
+ const header = Buffer.alloc(4);
482
+ header.writeUInt32LE(body.length, 0);
483
+ socket.write(Buffer.concat([header, body]));
484
+ }
485
+
486
+ const AGENT_TOOLS = [
487
+ {
488
+ type: 'function',
489
+ function: {
490
+ name: 'browser_snapshot',
491
+ description: 'Capture the structured accessibility tree and numbered interactive elements from the active tab.',
492
+ parameters: { type: 'object', properties: {} },
493
+ },
494
+ },
495
+ {
496
+ type: 'function',
497
+ function: {
498
+ name: 'browser_navigate',
499
+ description: 'Navigate the active browser tab to a specified URL.',
500
+ parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
501
+ },
502
+ },
503
+ {
504
+ type: 'function',
505
+ function: {
506
+ name: 'browser_click',
507
+ description: 'Click an element on the active webpage by numeric index or selector.',
508
+ parameters: { type: 'object', properties: { index: { type: 'number' }, selector: { type: 'string' } } },
509
+ },
510
+ },
511
+ {
512
+ type: 'function',
513
+ function: {
514
+ name: 'browser_type',
515
+ description: 'Type text into an input field by element index or selector.',
516
+ parameters: { type: 'object', properties: { index: { type: 'number' }, selector: { type: 'string' }, text: { type: 'string' }, clear: { type: 'boolean' } }, required: ['text'] },
517
+ },
518
+ },
519
+ {
520
+ type: 'function',
521
+ function: {
522
+ name: 'browser_press',
523
+ description: 'Press a keyboard key on the active webpage (e.g. Enter, Tab, Escape).',
524
+ parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] },
525
+ },
526
+ },
527
+ {
528
+ type: 'function',
529
+ function: {
530
+ name: 'browser_scroll',
531
+ description: 'Scroll the active webpage.',
532
+ parameters: { type: 'object', properties: { direction: { type: 'string', enum: ['down', 'up'] }, amount: { type: 'number' } } },
533
+ },
534
+ },
535
+ {
536
+ type: 'function',
537
+ function: {
538
+ name: 'browser_screenshot',
539
+ description: 'Capture a visual PNG screenshot of the current tab.',
540
+ parameters: { type: 'object', properties: {} },
541
+ },
542
+ },
543
+ {
544
+ type: 'function',
545
+ function: {
546
+ name: 'browser_extract',
547
+ description: 'Extract targeted text or structured content from the page.',
548
+ parameters: { type: 'object', properties: { instruction: { type: 'string' }, selector: { type: 'string' } } },
549
+ },
550
+ },
551
+ {
552
+ type: 'function',
553
+ function: {
554
+ name: 'browser_solve_captcha',
555
+ description: 'Attempt to detect and automatically solve or click Cloudflare Turnstile, reCAPTCHA, or hCaptcha verification challenges in the active tab.',
556
+ parameters: { type: 'object', properties: { type: { type: 'string', enum: ['cloudflare', 'recaptcha', 'hcaptcha', 'auto'] } } },
557
+ },
558
+ },
559
+ {
560
+ type: 'function',
561
+ function: {
562
+ name: 'weboperator_execute_goal',
563
+ description: 'Execute an autonomous browser goal end-to-end.',
564
+ parameters: { type: 'object', properties: { goal: { type: 'string' }, timeoutMs: { type: 'number' } }, required: ['goal'] },
565
+ },
566
+ },
567
+ ];
568
+
569
+ async function executeToolByName(name, args = {}, timeoutMs = 30_000) {
570
+ switch (name) {
571
+ case 'browser_snapshot':
572
+ return requestExtension('browser.snapshot', {}, timeoutMs);
573
+ case 'browser_navigate':
574
+ return requestExtension('browser.navigate', { url: args.url }, timeoutMs);
575
+ case 'browser_click':
576
+ return requestExtension('browser.click', { index: args.index, selector: args.selector }, timeoutMs);
577
+ case 'browser_type':
578
+ return requestExtension('browser.type', { index: args.index, selector: args.selector, text: args.text, clear: args.clear }, timeoutMs);
579
+ case 'browser_press':
580
+ return requestExtension('browser.press', { key: args.key }, timeoutMs);
581
+ case 'browser_scroll':
582
+ return requestExtension('browser.scroll', { direction: args.direction || 'down', amount: args.amount || 500 }, timeoutMs);
583
+ case 'browser_screenshot':
584
+ return requestExtension('browser.screenshot', {}, timeoutMs);
585
+ case 'browser_extract':
586
+ return requestExtension('browser.extract', { instruction: args.instruction, selector: args.selector }, timeoutMs);
587
+ case 'browser_solve_captcha':
588
+ return requestExtension('browser.solve_captcha', { type: args.type === 'auto' ? undefined : args.type }, timeoutMs);
589
+ case 'weboperator_execute_goal': {
590
+ const taskTimeout = Number(args.timeoutMs || timeoutMs || 120_000);
591
+ const startRes = await requestExtension('tasks.start', { goal: args.goal, timeoutMs: taskTimeout }, 30_000);
592
+ const taskId = startRes && startRes.id;
593
+ if (!taskId) return startRes;
594
+ const finalTask = await requestExtension('tasks.wait', { id: taskId, timeoutMs: taskTimeout }, taskTimeout + 10_000);
595
+ return formatTaskResultForAgent(finalTask || startRes);
596
+ }
597
+ default:
598
+ throw httpError(400, `Unknown tool: ${name}`);
599
+ }
600
+ }
601
+
602
+ function formatTaskResultForAgent(task) {
603
+ if (!task) return { ok: false, status: 'failed', error: 'Task not found or timed out' };
604
+
605
+ const steps = Array.isArray(task.steps) ? task.steps : [];
606
+ let answer = '';
607
+ const extractedList = [];
608
+
609
+ for (const step of steps) {
610
+ if (step.toolCall) {
611
+ const args = step.toolCall.arguments || {};
612
+ if (step.toolCall.name === 'done') {
613
+ if (args.answer || args.text || args.note || args.summary) {
614
+ answer = String(args.answer || args.text || args.note || args.summary);
615
+ }
616
+ }
617
+ if (step.toolCall.name === 'extract' && args.instruction) {
618
+ if (step.result && step.result.extracted) {
619
+ extractedList.push(step.result.extracted);
620
+ }
621
+ }
622
+ }
623
+ if (step.result && step.result.extracted !== undefined) {
624
+ extractedList.push(step.result.extracted);
625
+ }
626
+ if (!answer && step.note && step.status === 'ok') {
627
+ answer = step.note;
628
+ }
629
+ }
630
+
631
+ if (!answer && extractedList.length > 0) {
632
+ answer = typeof extractedList[extractedList.length - 1] === 'string'
633
+ ? extractedList[extractedList.length - 1]
634
+ : JSON.stringify(extractedList, null, 2);
635
+ }
636
+
637
+ if (!answer && task.plan && Array.isArray(task.plan.steps)) {
638
+ const doneSteps = task.plan.steps.filter((s) => s.status === 'done');
639
+ if (doneSteps.length > 0) {
640
+ answer = doneSteps.map((s) => `✓ ${s.description}`).join('\n');
641
+ }
642
+ }
643
+
644
+ if (!answer && task.status === 'done') {
645
+ answer = `Goal completed successfully: "${task.goal}"`;
646
+ } else if (!answer && task.status === 'failed') {
647
+ answer = `Goal failed: ${task.error || 'Unknown error'}`;
648
+ }
649
+
650
+ return {
651
+ ok: task.status === 'done',
652
+ status: task.status,
653
+ goal: task.goal,
654
+ answer,
655
+ extracted: extractedList.length > 0 ? extractedList : undefined,
656
+ stepCount: steps.length,
657
+ error: task.error,
658
+ modelUsed: task.modelUsed,
659
+ };
660
+ }
661
+
662
+
663
+ async function handleMcpHttp(msg) {
664
+ const { id, method, params } = msg || {};
665
+ if (method === 'initialize') {
666
+ return {
667
+ jsonrpc: '2.0',
668
+ id,
669
+ result: {
670
+ protocolVersion: '2024-11-05',
671
+ capabilities: { tools: { listChanged: false } },
672
+ serverInfo: { name: 'weboperator-bridge', version: '1.4.0' },
673
+ },
674
+ };
675
+ }
676
+ if (method === 'tools/list') {
677
+ return {
678
+ jsonrpc: '2.0',
679
+ id,
680
+ result: {
681
+ tools: AGENT_TOOLS.map((t) => ({
682
+ name: t.function.name,
683
+ description: t.function.description,
684
+ inputSchema: t.function.parameters,
685
+ })),
686
+ },
687
+ };
688
+ }
689
+ if (method === 'tools/call') {
690
+ const { name, arguments: toolArgs } = params || {};
691
+ try {
692
+ const res = await executeToolByName(name, toolArgs || {}, 30_000);
693
+ return {
694
+ jsonrpc: '2.0',
695
+ id,
696
+ result: {
697
+ content: [{ type: 'text', text: typeof res === 'string' ? res : JSON.stringify(res, null, 2) }],
698
+ },
699
+ };
700
+ } catch (err) {
701
+ return {
702
+ jsonrpc: '2.0',
703
+ id,
704
+ result: {
705
+ isError: true,
706
+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
707
+ },
708
+ };
709
+ }
710
+ }
711
+ if (method === 'ping') {
712
+ return { jsonrpc: '2.0', id, result: {} };
713
+ }
714
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
715
+ }
716
+