tuna-agent 0.1.1 → 0.1.2
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/dist/browser/actions/download.d.ts +16 -0
- package/dist/browser/actions/download.js +39 -0
- package/dist/browser/actions/emulation.d.ts +53 -0
- package/dist/browser/actions/emulation.js +103 -0
- package/dist/browser/actions/evaluate.d.ts +29 -0
- package/dist/browser/actions/evaluate.js +92 -0
- package/dist/browser/actions/interaction.d.ts +79 -0
- package/dist/browser/actions/interaction.js +210 -0
- package/dist/browser/actions/keyboard.d.ts +6 -0
- package/dist/browser/actions/keyboard.js +9 -0
- package/dist/browser/actions/navigation.d.ts +40 -0
- package/dist/browser/actions/navigation.js +92 -0
- package/dist/browser/actions/wait.d.ts +12 -0
- package/dist/browser/actions/wait.js +33 -0
- package/dist/browser/browser.d.ts +722 -0
- package/dist/browser/browser.js +1066 -0
- package/dist/browser/capture/activity.d.ts +22 -0
- package/dist/browser/capture/activity.js +39 -0
- package/dist/browser/capture/pdf.d.ts +6 -0
- package/dist/browser/capture/pdf.js +6 -0
- package/dist/browser/capture/response.d.ts +8 -0
- package/dist/browser/capture/response.js +28 -0
- package/dist/browser/capture/screenshot.d.ts +30 -0
- package/dist/browser/capture/screenshot.js +72 -0
- package/dist/browser/capture/trace.d.ts +13 -0
- package/dist/browser/capture/trace.js +19 -0
- package/dist/browser/chrome-launcher.d.ts +8 -0
- package/dist/browser/chrome-launcher.js +543 -0
- package/dist/browser/connection.d.ts +42 -0
- package/dist/browser/connection.js +359 -0
- package/dist/browser/index.d.ts +6 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/security.d.ts +51 -0
- package/dist/browser/security.js +357 -0
- package/dist/browser/snapshot/ai-snapshot.d.ts +12 -0
- package/dist/browser/snapshot/ai-snapshot.js +47 -0
- package/dist/browser/snapshot/aria-snapshot.d.ts +26 -0
- package/dist/browser/snapshot/aria-snapshot.js +121 -0
- package/dist/browser/snapshot/ref-map.d.ts +31 -0
- package/dist/browser/snapshot/ref-map.js +250 -0
- package/dist/browser/storage/index.d.ts +36 -0
- package/dist/browser/storage/index.js +65 -0
- package/dist/browser/types.d.ts +429 -0
- package/dist/browser/types.js +2 -0
- package/dist/daemon/extension-handlers.d.ts +63 -0
- package/dist/daemon/extension-handlers.js +630 -0
- package/dist/daemon/index.js +78 -19
- package/dist/daemon/ws-client.d.ts +16 -0
- package/dist/daemon/ws-client.js +45 -0
- package/dist/mcp/browser-server.d.ts +11 -0
- package/dist/mcp/browser-server.js +467 -0
- package/dist/mcp/knowledge-server.js +43 -18
- package/dist/mcp/setup.js +10 -0
- package/dist/utils/claude-cli.js +18 -9
- package/package.json +2 -1
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Browser MCP Server for Tuna Agent
|
|
4
|
+
*
|
|
5
|
+
* Stdio-based MCP server that exposes browser automation tools to Claude Code.
|
|
6
|
+
* Uses browserclaw (vendored from OpenClaw) for snapshot+ref browser control.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node browser-server.js [--headless] [--user-data-dir /path/to/chrome/profile] [--cdp-port 9222]
|
|
10
|
+
*/
|
|
11
|
+
import { BrowserClaw, setStealthEnabled } from '../browser/index.js';
|
|
12
|
+
function parseArgs() {
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
let headless = false; // Default: visible browser (not headless)
|
|
15
|
+
let userDataDir;
|
|
16
|
+
let profileDirectory;
|
|
17
|
+
let cdpPort;
|
|
18
|
+
let cdpUrl;
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
if (args[i] === '--headless')
|
|
21
|
+
headless = true;
|
|
22
|
+
else if (args[i] === '--user-data-dir' && args[i + 1])
|
|
23
|
+
userDataDir = args[++i];
|
|
24
|
+
else if (args[i] === '--profile-directory' && args[i + 1])
|
|
25
|
+
profileDirectory = args[++i];
|
|
26
|
+
else if (args[i] === '--cdp-port' && args[i + 1])
|
|
27
|
+
cdpPort = parseInt(args[++i], 10);
|
|
28
|
+
else if (args[i] === '--cdp-url' && args[i + 1])
|
|
29
|
+
cdpUrl = args[++i];
|
|
30
|
+
}
|
|
31
|
+
// Disable stealth injection for existing Chrome profiles (sites detect it and force logout)
|
|
32
|
+
if (userDataDir) {
|
|
33
|
+
setStealthEnabled(false);
|
|
34
|
+
}
|
|
35
|
+
return { headless, userDataDir, profileDirectory, cdpPort, cdpUrl };
|
|
36
|
+
}
|
|
37
|
+
// ===== Browser State =====
|
|
38
|
+
let browser = null;
|
|
39
|
+
const pages = new Map(); // targetId → CrawlPage
|
|
40
|
+
async function ensureBrowser(config) {
|
|
41
|
+
if (browser)
|
|
42
|
+
return browser;
|
|
43
|
+
if (config.cdpUrl) {
|
|
44
|
+
browser = await BrowserClaw.connect(config.cdpUrl);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
browser = await BrowserClaw.launch({
|
|
48
|
+
headless: config.headless,
|
|
49
|
+
userDataDir: config.userDataDir,
|
|
50
|
+
profileDirectory: config.profileDirectory,
|
|
51
|
+
cdpPort: config.cdpPort,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return browser;
|
|
55
|
+
}
|
|
56
|
+
function sendResponse(res) {
|
|
57
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
58
|
+
}
|
|
59
|
+
function sendResult(id, result) {
|
|
60
|
+
sendResponse({ jsonrpc: '2.0', id: id ?? null, result });
|
|
61
|
+
}
|
|
62
|
+
function sendError(id, code, message) {
|
|
63
|
+
sendResponse({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
|
|
64
|
+
}
|
|
65
|
+
// ===== Tool Definitions =====
|
|
66
|
+
const TOOLS = [
|
|
67
|
+
{
|
|
68
|
+
name: 'browser_open',
|
|
69
|
+
description: 'Open a URL in the browser and return an AI-readable snapshot of the page. Returns a text tree with numbered refs (e1, e2, ...) for interactive elements.',
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
url: { type: 'string', description: 'URL to navigate to (e.g. https://facebook.com)' },
|
|
74
|
+
},
|
|
75
|
+
required: ['url'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'browser_snapshot',
|
|
80
|
+
description: 'Take a snapshot of the current page. Returns an accessibility tree with numbered refs for all interactive elements. Use this after any navigation or page change to get fresh refs.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
page_id: { type: 'string', description: 'Target page ID (optional, uses last active page if omitted)' },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'browser_click',
|
|
90
|
+
description: 'Click an element by its ref number from a snapshot. Run browser_snapshot first to get refs.',
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {
|
|
94
|
+
ref: { type: 'string', description: 'Element ref from snapshot (e.g. "e1", "e5")' },
|
|
95
|
+
page_id: { type: 'string', description: 'Target page ID (optional)' },
|
|
96
|
+
double_click: { type: 'boolean', description: 'Double-click instead of single click' },
|
|
97
|
+
},
|
|
98
|
+
required: ['ref'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'browser_type',
|
|
103
|
+
description: 'Type text into an element by its ref number. The element is clicked first, then text is typed.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
ref: { type: 'string', description: 'Element ref from snapshot (e.g. "e3")' },
|
|
108
|
+
text: { type: 'string', description: 'Text to type' },
|
|
109
|
+
submit: { type: 'boolean', description: 'Press Enter after typing to submit' },
|
|
110
|
+
slowly: { type: 'boolean', description: 'Type slowly with keystroke delays (more human-like)' },
|
|
111
|
+
page_id: { type: 'string', description: 'Target page ID (optional)' },
|
|
112
|
+
},
|
|
113
|
+
required: ['ref', 'text'],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'browser_navigate',
|
|
118
|
+
description: 'Navigate to a different URL on the current page.',
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
url: { type: 'string', description: 'URL to navigate to' },
|
|
123
|
+
page_id: { type: 'string', description: 'Target page ID (optional)' },
|
|
124
|
+
},
|
|
125
|
+
required: ['url'],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'browser_scroll',
|
|
130
|
+
description: 'Scroll the page or a specific element into view.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
ref: { type: 'string', description: 'Element ref to scroll into view (optional, scrolls page if omitted)' },
|
|
135
|
+
direction: { type: 'string', description: 'Scroll direction: "up" or "down" (default: "down")' },
|
|
136
|
+
page_id: { type: 'string', description: 'Target page ID (optional)' },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'browser_screenshot',
|
|
142
|
+
description: 'Take a screenshot of the current page. Returns base64-encoded PNG image.',
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
properties: {
|
|
146
|
+
page_id: { type: 'string', description: 'Target page ID (optional)' },
|
|
147
|
+
full_page: { type: 'boolean', description: 'Capture the full scrollable page (default: false, viewport only)' },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'browser_tabs',
|
|
153
|
+
description: 'List all open browser tabs with their IDs, URLs, and titles.',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
properties: {},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'browser_press',
|
|
161
|
+
description: 'Press a keyboard key (e.g. Enter, Tab, Escape, ArrowDown).',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
key: { type: 'string', description: 'Key to press (e.g. "Enter", "Tab", "Escape", "ArrowDown")' },
|
|
166
|
+
page_id: { type: 'string', description: 'Target page ID (optional)' },
|
|
167
|
+
},
|
|
168
|
+
required: ['key'],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'browser_select',
|
|
173
|
+
description: 'Select an option from a dropdown/select element.',
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {
|
|
177
|
+
ref: { type: 'string', description: 'Element ref of the select/dropdown' },
|
|
178
|
+
value: { type: 'string', description: 'Value or label to select' },
|
|
179
|
+
page_id: { type: 'string', description: 'Target page ID (optional)' },
|
|
180
|
+
},
|
|
181
|
+
required: ['ref', 'value'],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'browser_hover',
|
|
186
|
+
description: 'Hover over an element by its ref number.',
|
|
187
|
+
inputSchema: {
|
|
188
|
+
type: 'object',
|
|
189
|
+
properties: {
|
|
190
|
+
ref: { type: 'string', description: 'Element ref from snapshot' },
|
|
191
|
+
page_id: { type: 'string', description: 'Target page ID (optional)' },
|
|
192
|
+
},
|
|
193
|
+
required: ['ref'],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'browser_close',
|
|
198
|
+
description: 'Close the browser and clean up resources.',
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
// ===== Request Handler =====
|
|
206
|
+
const serverConfig = parseArgs();
|
|
207
|
+
let activePageId = null;
|
|
208
|
+
async function handleRequest(req) {
|
|
209
|
+
try {
|
|
210
|
+
switch (req.method) {
|
|
211
|
+
case 'initialize':
|
|
212
|
+
sendResult(req.id, {
|
|
213
|
+
protocolVersion: '2024-11-05',
|
|
214
|
+
capabilities: { tools: {} },
|
|
215
|
+
serverInfo: { name: 'tuna-browser', version: '1.0.0' },
|
|
216
|
+
});
|
|
217
|
+
break;
|
|
218
|
+
case 'notifications/initialized':
|
|
219
|
+
break;
|
|
220
|
+
case 'tools/list':
|
|
221
|
+
sendResult(req.id, { tools: TOOLS });
|
|
222
|
+
break;
|
|
223
|
+
case 'tools/call': {
|
|
224
|
+
const toolName = req.params?.name ?? '';
|
|
225
|
+
const args = req.params?.arguments ?? {};
|
|
226
|
+
const result = await handleToolCall(toolName, args);
|
|
227
|
+
sendResult(req.id, result);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'ping':
|
|
231
|
+
sendResult(req.id, {});
|
|
232
|
+
break;
|
|
233
|
+
default:
|
|
234
|
+
if (req.id !== undefined) {
|
|
235
|
+
sendError(req.id, -32601, `Method not found: ${req.method}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
241
|
+
if (req.id !== undefined) {
|
|
242
|
+
sendError(req.id, -32603, message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function getPageId(args) {
|
|
247
|
+
return args.page_id || activePageId || undefined;
|
|
248
|
+
}
|
|
249
|
+
async function getPage(args) {
|
|
250
|
+
const pageId = getPageId(args);
|
|
251
|
+
if (pageId && pages.has(pageId)) {
|
|
252
|
+
return pages.get(pageId);
|
|
253
|
+
}
|
|
254
|
+
// Return last active page or first available
|
|
255
|
+
if (activePageId && pages.has(activePageId)) {
|
|
256
|
+
return pages.get(activePageId);
|
|
257
|
+
}
|
|
258
|
+
// No pages open
|
|
259
|
+
throw new Error('No browser page is open. Use browser_open first to navigate to a URL.');
|
|
260
|
+
}
|
|
261
|
+
async function handleToolCall(toolName, args) {
|
|
262
|
+
try {
|
|
263
|
+
switch (toolName) {
|
|
264
|
+
case 'browser_open': {
|
|
265
|
+
const url = args.url;
|
|
266
|
+
if (!url)
|
|
267
|
+
return { content: [{ type: 'text', text: 'Error: url is required' }], isError: true };
|
|
268
|
+
const b = await ensureBrowser(serverConfig);
|
|
269
|
+
const page = await b.open(url);
|
|
270
|
+
const pageId = page.id;
|
|
271
|
+
pages.set(pageId, page);
|
|
272
|
+
activePageId = pageId;
|
|
273
|
+
// Auto-snapshot after opening
|
|
274
|
+
const result = await page.snapshot();
|
|
275
|
+
return {
|
|
276
|
+
content: [{
|
|
277
|
+
type: 'text',
|
|
278
|
+
text: `Page opened: ${url}\nPage ID: ${pageId}\n\n--- Snapshot ---\n${result.snapshot}\n\n--- Stats ---\nRefs: ${Object.keys(result.refs).length} interactive elements`,
|
|
279
|
+
}],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
case 'browser_snapshot': {
|
|
283
|
+
const page = await getPage(args);
|
|
284
|
+
const result = await page.snapshot();
|
|
285
|
+
return {
|
|
286
|
+
content: [{
|
|
287
|
+
type: 'text',
|
|
288
|
+
text: `${result.snapshot}\n\n--- Stats ---\nRefs: ${Object.keys(result.refs).length} interactive elements`,
|
|
289
|
+
}],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
case 'browser_click': {
|
|
293
|
+
const ref = args.ref;
|
|
294
|
+
if (!ref)
|
|
295
|
+
return { content: [{ type: 'text', text: 'Error: ref is required' }], isError: true };
|
|
296
|
+
const page = await getPage(args);
|
|
297
|
+
await page.click(ref, {
|
|
298
|
+
doubleClick: args.double_click,
|
|
299
|
+
});
|
|
300
|
+
// Auto-snapshot after click to show result
|
|
301
|
+
const result = await page.snapshot();
|
|
302
|
+
return {
|
|
303
|
+
content: [{
|
|
304
|
+
type: 'text',
|
|
305
|
+
text: `Clicked "${ref}".\n\n--- Updated Snapshot ---\n${result.snapshot}`,
|
|
306
|
+
}],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
case 'browser_type': {
|
|
310
|
+
const ref = args.ref;
|
|
311
|
+
const text = args.text;
|
|
312
|
+
if (!ref || text === undefined)
|
|
313
|
+
return { content: [{ type: 'text', text: 'Error: ref and text are required' }], isError: true };
|
|
314
|
+
const page = await getPage(args);
|
|
315
|
+
await page.type(ref, text, {
|
|
316
|
+
submit: args.submit,
|
|
317
|
+
slowly: args.slowly,
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
content: [{
|
|
321
|
+
type: 'text',
|
|
322
|
+
text: `Typed "${text}" into "${ref}"${args.submit ? ' and submitted' : ''}.`,
|
|
323
|
+
}],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
case 'browser_navigate': {
|
|
327
|
+
const url = args.url;
|
|
328
|
+
if (!url)
|
|
329
|
+
return { content: [{ type: 'text', text: 'Error: url is required' }], isError: true };
|
|
330
|
+
const page = await getPage(args);
|
|
331
|
+
await page.goto(url);
|
|
332
|
+
const result = await page.snapshot();
|
|
333
|
+
return {
|
|
334
|
+
content: [{
|
|
335
|
+
type: 'text',
|
|
336
|
+
text: `Navigated to: ${url}\n\n--- Snapshot ---\n${result.snapshot}`,
|
|
337
|
+
}],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
case 'browser_scroll': {
|
|
341
|
+
const page = await getPage(args);
|
|
342
|
+
if (args.ref) {
|
|
343
|
+
await page.scrollIntoView(args.ref);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
// Scroll page via keyboard
|
|
347
|
+
const key = args.direction === 'up' ? 'PageUp' : 'PageDown';
|
|
348
|
+
await page.press(key);
|
|
349
|
+
}
|
|
350
|
+
const result = await page.snapshot();
|
|
351
|
+
return {
|
|
352
|
+
content: [{
|
|
353
|
+
type: 'text',
|
|
354
|
+
text: `Scrolled ${args.direction || 'down'}.\n\n--- Updated Snapshot ---\n${result.snapshot}`,
|
|
355
|
+
}],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
case 'browser_screenshot': {
|
|
359
|
+
const page = await getPage(args);
|
|
360
|
+
const buf = await page.screenshot({
|
|
361
|
+
fullPage: args.full_page,
|
|
362
|
+
});
|
|
363
|
+
return {
|
|
364
|
+
content: [{
|
|
365
|
+
type: 'image',
|
|
366
|
+
data: buf.toString('base64'),
|
|
367
|
+
mimeType: 'image/png',
|
|
368
|
+
}],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
case 'browser_tabs': {
|
|
372
|
+
const b = await ensureBrowser(serverConfig);
|
|
373
|
+
const tabs = await b.tabs();
|
|
374
|
+
if (tabs.length === 0) {
|
|
375
|
+
return { content: [{ type: 'text', text: 'No tabs open.' }] };
|
|
376
|
+
}
|
|
377
|
+
const listing = tabs.map((t) => `- ${t.title || 'Untitled'} (ID: ${t.targetId})\n URL: ${t.url}`).join('\n');
|
|
378
|
+
return { content: [{ type: 'text', text: `Open tabs:\n${listing}` }] };
|
|
379
|
+
}
|
|
380
|
+
case 'browser_press': {
|
|
381
|
+
const key = args.key;
|
|
382
|
+
if (!key)
|
|
383
|
+
return { content: [{ type: 'text', text: 'Error: key is required' }], isError: true };
|
|
384
|
+
const page = await getPage(args);
|
|
385
|
+
await page.press(key);
|
|
386
|
+
return { content: [{ type: 'text', text: `Pressed "${key}".` }] };
|
|
387
|
+
}
|
|
388
|
+
case 'browser_select': {
|
|
389
|
+
const ref = args.ref;
|
|
390
|
+
const value = args.value;
|
|
391
|
+
if (!ref || !value)
|
|
392
|
+
return { content: [{ type: 'text', text: 'Error: ref and value are required' }], isError: true };
|
|
393
|
+
const page = await getPage(args);
|
|
394
|
+
await page.select(ref, value);
|
|
395
|
+
return { content: [{ type: 'text', text: `Selected "${value}" in "${ref}".` }] };
|
|
396
|
+
}
|
|
397
|
+
case 'browser_hover': {
|
|
398
|
+
const ref = args.ref;
|
|
399
|
+
if (!ref)
|
|
400
|
+
return { content: [{ type: 'text', text: 'Error: ref is required' }], isError: true };
|
|
401
|
+
const page = await getPage(args);
|
|
402
|
+
await page.hover(ref);
|
|
403
|
+
return { content: [{ type: 'text', text: `Hovered over "${ref}".` }] };
|
|
404
|
+
}
|
|
405
|
+
case 'browser_close': {
|
|
406
|
+
if (browser) {
|
|
407
|
+
await browser.stop();
|
|
408
|
+
browser = null;
|
|
409
|
+
pages.clear();
|
|
410
|
+
activePageId = null;
|
|
411
|
+
}
|
|
412
|
+
return { content: [{ type: 'text', text: 'Browser closed.' }] };
|
|
413
|
+
}
|
|
414
|
+
default:
|
|
415
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], isError: true };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
420
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// ===== Stdio Transport =====
|
|
424
|
+
function startServer() {
|
|
425
|
+
process.stderr.write(`[browser-mcp] Starting (headless: ${serverConfig.headless})\n`);
|
|
426
|
+
let buffer = '';
|
|
427
|
+
process.stdin.setEncoding('utf-8');
|
|
428
|
+
process.stdin.on('data', (chunk) => {
|
|
429
|
+
buffer += chunk;
|
|
430
|
+
const lines = buffer.split('\n');
|
|
431
|
+
buffer = lines.pop() ?? '';
|
|
432
|
+
for (const line of lines) {
|
|
433
|
+
const trimmed = line.trim();
|
|
434
|
+
if (!trimmed)
|
|
435
|
+
continue;
|
|
436
|
+
try {
|
|
437
|
+
const req = JSON.parse(trimmed);
|
|
438
|
+
handleRequest(req).catch((err) => {
|
|
439
|
+
process.stderr.write(`[browser-mcp] Error handling ${req.method}: ${err}\n`);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
process.stderr.write(`[browser-mcp] Failed to parse JSON: ${trimmed.slice(0, 100)}\n`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
process.stdin.on('end', async () => {
|
|
448
|
+
process.stderr.write('[browser-mcp] stdin closed, cleaning up browser\n');
|
|
449
|
+
if (browser) {
|
|
450
|
+
await browser.stop().catch(() => { });
|
|
451
|
+
}
|
|
452
|
+
process.exit(0);
|
|
453
|
+
});
|
|
454
|
+
// Cleanup on process exit
|
|
455
|
+
process.on('SIGTERM', async () => {
|
|
456
|
+
if (browser)
|
|
457
|
+
await browser.stop().catch(() => { });
|
|
458
|
+
process.exit(0);
|
|
459
|
+
});
|
|
460
|
+
process.on('SIGINT', async () => {
|
|
461
|
+
if (browser)
|
|
462
|
+
await browser.stop().catch(() => { });
|
|
463
|
+
process.exit(0);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
// ===== Main =====
|
|
467
|
+
startServer();
|
|
@@ -58,10 +58,12 @@ function sendError(id, code, message) {
|
|
|
58
58
|
const TOOLS = [
|
|
59
59
|
{
|
|
60
60
|
name: 'list_knowledge',
|
|
61
|
-
description: 'List
|
|
61
|
+
description: 'List knowledge items accessible to this agent. Can list root items or items inside a specific folder.',
|
|
62
62
|
inputSchema: {
|
|
63
63
|
type: 'object',
|
|
64
|
-
properties: {
|
|
64
|
+
properties: {
|
|
65
|
+
parent_id: { type: 'string', description: 'Folder ID to list children of. Omit to list root-level items.' },
|
|
66
|
+
},
|
|
65
67
|
},
|
|
66
68
|
},
|
|
67
69
|
{
|
|
@@ -77,15 +79,17 @@ const TOOLS = [
|
|
|
77
79
|
},
|
|
78
80
|
{
|
|
79
81
|
name: 'create_knowledge',
|
|
80
|
-
description: 'Create a new knowledge document. Content should be in markdown format.',
|
|
82
|
+
description: 'Create a new knowledge document or folder. Content should be in markdown format for documents.',
|
|
81
83
|
inputSchema: {
|
|
82
84
|
type: 'object',
|
|
83
85
|
properties: {
|
|
84
|
-
name: { type: 'string', description: 'Name/title of the knowledge
|
|
85
|
-
content: { type: 'string', description: 'Markdown content
|
|
86
|
-
description: { type: 'string', description: 'Short description of what this
|
|
86
|
+
name: { type: 'string', description: 'Name/title of the knowledge item' },
|
|
87
|
+
content: { type: 'string', description: 'Markdown content (required for documents, omit for folders)' },
|
|
88
|
+
description: { type: 'string', description: 'Short description of what this item contains' },
|
|
89
|
+
kind: { type: 'string', enum: ['document', 'folder'], description: 'Type of knowledge item. Default: document' },
|
|
90
|
+
parent_id: { type: 'string', description: 'Parent folder ID to create inside. Omit for root level.' },
|
|
87
91
|
},
|
|
88
|
-
required: ['name'
|
|
92
|
+
required: ['name'],
|
|
89
93
|
},
|
|
90
94
|
},
|
|
91
95
|
{
|
|
@@ -147,13 +151,22 @@ async function handleToolCall(config, toolName, args) {
|
|
|
147
151
|
try {
|
|
148
152
|
switch (toolName) {
|
|
149
153
|
case 'list_knowledge': {
|
|
150
|
-
|
|
154
|
+
let url = `/agent-knowledge?agent_id=${config.agentId}`;
|
|
155
|
+
if (args.parent_id)
|
|
156
|
+
url += `&parent_id=${args.parent_id}`;
|
|
157
|
+
const data = await apiCall(config, 'GET', url);
|
|
151
158
|
const items = data.items || [];
|
|
152
159
|
if (items.length === 0) {
|
|
153
|
-
return { content: [{ type: 'text', text: 'No knowledge
|
|
160
|
+
return { content: [{ type: 'text', text: 'No knowledge items found.' }] };
|
|
154
161
|
}
|
|
155
|
-
const listing = items.map((k) =>
|
|
156
|
-
|
|
162
|
+
const listing = items.map((k) => {
|
|
163
|
+
const icon = k.kind === 'folder' ? '📁' : '📄';
|
|
164
|
+
const meta = k.kind === 'folder'
|
|
165
|
+
? `${k.children_count || 0} items`
|
|
166
|
+
: `${k.file_size} bytes`;
|
|
167
|
+
return `- ${icon} **${k.name}** (ID: ${k._id}) [${k.kind}]\n ${k.description || 'No description'}\n ${meta} | Updated: ${k.updated_at}`;
|
|
168
|
+
}).join('\n\n');
|
|
169
|
+
return { content: [{ type: 'text', text: `Found ${items.length} knowledge item(s):\n\n${listing}` }] };
|
|
157
170
|
}
|
|
158
171
|
case 'read_knowledge': {
|
|
159
172
|
if (!args.knowledge_id) {
|
|
@@ -168,16 +181,28 @@ async function handleToolCall(config, toolName, args) {
|
|
|
168
181
|
};
|
|
169
182
|
}
|
|
170
183
|
case 'create_knowledge': {
|
|
171
|
-
|
|
172
|
-
|
|
184
|
+
const isFolder = args.kind === 'folder';
|
|
185
|
+
if (!args.name) {
|
|
186
|
+
return { content: [{ type: 'text', text: 'Error: name is required' }], isError: true };
|
|
187
|
+
}
|
|
188
|
+
if (!isFolder && !args.content) {
|
|
189
|
+
return { content: [{ type: 'text', text: 'Error: content is required for documents' }], isError: true };
|
|
173
190
|
}
|
|
174
|
-
const
|
|
191
|
+
const body = {
|
|
175
192
|
name: args.name,
|
|
176
|
-
content: args.content,
|
|
177
|
-
description: args.description || '',
|
|
178
193
|
agent_id: config.agentId,
|
|
179
|
-
}
|
|
180
|
-
|
|
194
|
+
};
|
|
195
|
+
if (args.kind)
|
|
196
|
+
body.kind = args.kind;
|
|
197
|
+
if (args.content)
|
|
198
|
+
body.content = args.content;
|
|
199
|
+
if (args.description)
|
|
200
|
+
body.description = args.description;
|
|
201
|
+
if (args.parent_id)
|
|
202
|
+
body.parent_id = args.parent_id;
|
|
203
|
+
const data = await apiCall(config, 'POST', '/agent-knowledge', body);
|
|
204
|
+
const label = isFolder ? 'Folder' : 'Knowledge';
|
|
205
|
+
return { content: [{ type: 'text', text: `${label} "${data.name}" created (ID: ${data._id})` }] };
|
|
181
206
|
}
|
|
182
207
|
case 'update_knowledge': {
|
|
183
208
|
if (!args.knowledge_id) {
|
package/dist/mcp/setup.js
CHANGED
|
@@ -11,6 +11,7 @@ const MCP_CONFIG_PATH = path.join(MCP_CONFIG_DIR, 'mcp-config.json');
|
|
|
11
11
|
*/
|
|
12
12
|
export function setupMcpConfig(config) {
|
|
13
13
|
const knowledgeServerPath = path.join(__dirname, 'knowledge-server.js');
|
|
14
|
+
const browserServerPath = path.join(__dirname, 'browser-server.js');
|
|
14
15
|
const mcpConfig = {
|
|
15
16
|
mcpServers: {
|
|
16
17
|
'tuna-knowledge': {
|
|
@@ -22,6 +23,10 @@ export function setupMcpConfig(config) {
|
|
|
22
23
|
'--agent-id', config.agentId,
|
|
23
24
|
],
|
|
24
25
|
},
|
|
26
|
+
'tuna-browser': {
|
|
27
|
+
command: process.execPath,
|
|
28
|
+
args: [browserServerPath, '--user-data-dir', path.join(process.env.HOME || '', '.config', 'tuna-browser', 'chrome-profile')],
|
|
29
|
+
},
|
|
25
30
|
},
|
|
26
31
|
};
|
|
27
32
|
if (!fs.existsSync(MCP_CONFIG_DIR)) {
|
|
@@ -47,6 +52,7 @@ export function getMcpConfigPath() {
|
|
|
47
52
|
*/
|
|
48
53
|
export function writeAgentFolderMcpConfig(agentFolderPath, config) {
|
|
49
54
|
const knowledgeServerPath = path.join(__dirname, 'knowledge-server.js');
|
|
55
|
+
const browserServerPath = path.join(__dirname, 'browser-server.js');
|
|
50
56
|
try {
|
|
51
57
|
const mcpJsonPath = path.join(agentFolderPath, '.mcp.json');
|
|
52
58
|
// Read existing .mcp.json to preserve other servers (e.g. playwright)
|
|
@@ -66,6 +72,10 @@ export function writeAgentFolderMcpConfig(agentFolderPath, config) {
|
|
|
66
72
|
'--agent-id', config.agentId,
|
|
67
73
|
],
|
|
68
74
|
},
|
|
75
|
+
'tuna-browser': {
|
|
76
|
+
command: process.execPath,
|
|
77
|
+
args: [browserServerPath, '--user-data-dir', path.join(process.env.HOME || '', '.config', 'tuna-browser', 'chrome-profile')],
|
|
78
|
+
},
|
|
69
79
|
};
|
|
70
80
|
fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2));
|
|
71
81
|
console.log(`[MCP] Agent folder .mcp.json written to ${mcpJsonPath}`);
|
package/dist/utils/claude-cli.js
CHANGED
|
@@ -127,18 +127,27 @@ export function runClaude(options) {
|
|
|
127
127
|
`${process.env.HOME}/.local/bin`,
|
|
128
128
|
`${process.env.HOME}/.nvm/versions/node/v20.10.0/bin`,
|
|
129
129
|
].filter(Boolean).join(':');
|
|
130
|
+
// Build env: inherit process.env but strip Claude Code session markers
|
|
131
|
+
// so the spawned claude process is not blocked by nested-session detection.
|
|
132
|
+
const spawnEnv = {};
|
|
133
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
134
|
+
if (v !== undefined)
|
|
135
|
+
spawnEnv[k] = v;
|
|
136
|
+
}
|
|
137
|
+
// Remove nested-session markers
|
|
138
|
+
delete spawnEnv['CLAUDECODE'];
|
|
139
|
+
delete spawnEnv['CLAUDE_CODE_ENTRYPOINT'];
|
|
140
|
+
delete spawnEnv['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'];
|
|
141
|
+
spawnEnv['HOME'] = process.env.HOME || '';
|
|
142
|
+
spawnEnv['PATH'] = spawnPath;
|
|
143
|
+
if (options.agentTeam) {
|
|
144
|
+
spawnEnv['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] = '1';
|
|
145
|
+
spawnEnv['CLAUDE_CODE_TEAMMATE_MODE'] = 'in-process';
|
|
146
|
+
}
|
|
130
147
|
const proc = spawn(claudeBin, args, {
|
|
131
148
|
cwd: options.cwd,
|
|
132
149
|
stdio: [useInteractiveStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
133
|
-
env:
|
|
134
|
-
...process.env,
|
|
135
|
-
HOME: process.env.HOME || '',
|
|
136
|
-
PATH: spawnPath,
|
|
137
|
-
...(options.agentTeam ? {
|
|
138
|
-
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
139
|
-
CLAUDE_CODE_TEAMMATE_MODE: 'in-process',
|
|
140
|
-
} : {}),
|
|
141
|
-
},
|
|
150
|
+
env: spawnEnv,
|
|
142
151
|
});
|
|
143
152
|
// 30-minute timeout by default
|
|
144
153
|
const timeoutMs = options.timeoutMs ?? 30 * 60 * 1000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tuna-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Tuna Agent - Run AI coding tasks on your machine",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tuna-agent": "dist/cli/index.js"
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"chalk": "^5.4.0",
|
|
18
18
|
"commander": "^13.0.0",
|
|
19
|
+
"playwright-core": "^1.58.2",
|
|
19
20
|
"ws": "^8.18.0",
|
|
20
21
|
"zod": "^4.3.6"
|
|
21
22
|
},
|