@opencode/browse 0.0.3-dev.2 → 0.0.6-dev.18

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.
@@ -0,0 +1,870 @@
1
+ import {
2
+ autoUpdateStatus
3
+ } from "./main-ha5bnd77.mjs";
4
+ import {
5
+ parseTargetSelection2,
6
+ getObject2,
7
+ getString2,
8
+ Service,
9
+ layerFetchMcp,
10
+ relayBuildProblem,
11
+ ensureRelay,
12
+ ensureExtensionConnected
13
+ } from "./main-jjny7bmm.mjs";
14
+ import"./main-9y22cffn.mjs";
15
+ import {
16
+ opencodeBrowserVersion2
17
+ } from "./main-ppz7sv9s.mjs";
18
+ import {
19
+ listSecrets,
20
+ readClient,
21
+ settingKeys,
22
+ settingSpecs,
23
+ securityVariables
24
+ } from "./main-fkhwazsm.mjs";
25
+ import {
26
+ updateJson2,
27
+ createUpdateWatcher2
28
+ } from "./main-w5sta7hq.mjs";
29
+
30
+ // src/mcp.ts
31
+ import { NodeStdio } from "@effect/platform-node";
32
+ import { Config, Context, Effect, Layer, Option, Predicate, Schedule } from "effect";
33
+ import { McpProtocol, McpSchema, McpServer } from "effect/ai";
34
+ import fs from "node:fs/promises";
35
+ import path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ var packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
38
+ function browsersNote(browsers) {
39
+ if (browsers.length < 2)
40
+ return "";
41
+ const list = browsers.map((browser) => `${browser.id} (${browser.name}${browser.default ? ", default" : ""})`).join(", ");
42
+ return ` Connected browsers: ${list}. Pass browser to use one other than the default.`;
43
+ }
44
+ var emptyInputSchema = objectSchema({});
45
+ var sessionSchemaProperty = {
46
+ type: "string",
47
+ description: "Optional session id. Defaults to this MCP server's current session."
48
+ };
49
+ var sessionAliasSchemaProperty = { type: "string", description: "Same as id." };
50
+ var sessionOnlyInputSchema = objectSchema({
51
+ session: sessionSchemaProperty
52
+ });
53
+ function makeToolSpecs(relay, currentSession, update = () => {
54
+ return;
55
+ }, browsers = []) {
56
+ const browserNote = browsersNote(browsers);
57
+ const browserProperty = {
58
+ type: "string",
59
+ description: `Optional browser id for a new session's tabs; omit for the default browser. A session stays in its browser.${browserNote}`
60
+ };
61
+ const resolveSessionId = (input, field = "session") => optionalStringField(input, field) ?? (field === "id" ? optionalStringField(input, "session") : undefined) ?? currentSession.id;
62
+ const establishCurrentSession = (id) => {
63
+ currentSession.id = id;
64
+ currentSession.established = true;
65
+ };
66
+ return [
67
+ {
68
+ name: "execute",
69
+ description: `Execute Playwright JavaScript against the Browse session. Code runs in a sandbox on the relay without process, require, import(), fs, child_process, http/https or Node fetch; save and read files with the files helper inside the execute file roots, and cookie access is limited to the session's pages. The result includes console logs, warnings, a bounded execution-context diagnostic when relevant, and an aftermath summary (URL movement, navigations, error counts, handoffs, and a bot challenge or block page when one appears: hand interactive ones to the user with handoff(), never solve or retry-loop them).${browserNote}`,
70
+ inputSchema: objectSchema({
71
+ code: { type: "string", description: `JavaScript code to execute. It receives browser, context, page, state, modules (path, url, util, crypto, buffer, zlib, events, stream), files (dir, path, write, read), fillInput, fillInputs, snapshot(options?) for compact semantic outlines, search, explicit diffs, or automatic deltas, persistent compatible ref(id) locators, webmcp (list/call), screenshot helpers, ariaSnapshot, ghostCursor, handoff, demonstrate, network capture, pageConsole (start/stop capturing the page's own console messages and errors, off by default because pages can detect it), secrets.type(locator, name) to type a stored site secret or a granted Deadbolt login ("deadbolt:<site>[/<username>]", { field: "username" }, or a password/code with { submit: true }; see secrets_list) only on its allowed origins, without seeing it, secrets.signIn({ site?, username? }) for a whole sign-in (same as the signin tool), and secrets.usePasskey({ site, username }) to pick the passkey account for the tab's next passkey request.` },
72
+ session: { type: "string", description: "Optional existing Browse session id. Explicit ids must already exist; omit this field to use the MCP server's current session, which is created when needed." },
73
+ targetUrl: { type: "string", description: "Optional URL substring selecting an existing attached page. This does not navigate or open a URL; use page.goto() for that." },
74
+ targetIndex: { type: "integer", minimum: 0, description: "Optional zero-based attached page index selector." },
75
+ browser: browserProperty,
76
+ timeoutMs: { type: "integer", minimum: 0, description: "Optional overall script timeout in milliseconds (paused while handoff() waits on the user); defaults to 60000, 0 disables." }
77
+ }, ["code"]),
78
+ readOnly: false,
79
+ destructive: true,
80
+ idempotent: false,
81
+ handle: (input) => Effect.gen(function* () {
82
+ const args = yield* Effect.try(() => parseExecuteArguments(input));
83
+ const sessionId = args.session ?? currentSession.id;
84
+ yield* ensureExtensionConnected({ relay, sessionId, waitForReconnect: true });
85
+ const result = yield* relay.execute({
86
+ sessionId,
87
+ code: args.code,
88
+ createIfMissing: !args.session,
89
+ ...args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs },
90
+ ...args.targetSelection ? { targetSelection: args.targetSelection } : {},
91
+ ...args.browser ? { browser: args.browser } : {}
92
+ });
93
+ const recreated = !args.session && currentSession.established && result.session.created === true;
94
+ establishCurrentSession(sessionId);
95
+ return {
96
+ ...result,
97
+ ...recreated ? { notice: `Recreated session '${sessionId}' — relay had no such session; page and state were reset.` } : {}
98
+ };
99
+ })
100
+ },
101
+ {
102
+ name: "status",
103
+ description: "Return relay, extension, target, and session status.",
104
+ inputSchema: emptyInputSchema,
105
+ readOnly: true,
106
+ destructive: false,
107
+ idempotent: false,
108
+ handle: () => Effect.gen(function* () {
109
+ const [version, status] = yield* Effect.all([relay.version, relay.extensionStatus]);
110
+ const buildProblem = relayBuildProblem(version);
111
+ const available = updateJson2(update());
112
+ return { endpoint: relay.endpoint, currentSession: currentSession.id, version, status, ...buildProblem ? { buildProblem } : {}, ...available ? { update: available } : {}, autoUpdate: autoUpdateStatus() };
113
+ })
114
+ },
115
+ {
116
+ name: "session_new",
117
+ description: `Create a Browse session and make it current for this MCP server.${browserNote}`,
118
+ inputSchema: objectSchema({
119
+ browser: browserProperty,
120
+ id: { type: "string", description: "Optional lowercase session id." },
121
+ readOnly: { type: "boolean", description: "Create a read-only session: the relay rejects input-dispatching CDP so scripts can inspect but not click or type." },
122
+ allowedOrigins: { type: "array", items: { type: "string" }, description: "Optional domain allowlist: hosts or origins this session's tabs may load (example.com, *.example.com, https://app.example.com, http://localhost:3000). Navigations elsewhere are refused or sent back to about:blank; private and loopback addresses need an exact entry." }
123
+ }),
124
+ readOnly: false,
125
+ destructive: false,
126
+ idempotent: false,
127
+ handle: (input) => Effect.gen(function* () {
128
+ const requestedId = optionalStringField(input, "id");
129
+ const readOnly = optionalBooleanField(input, "readOnly");
130
+ const allowedOrigins = optionalStringArrayField(input, "allowedOrigins");
131
+ const browser = optionalStringField(input, "browser");
132
+ const session = yield* relay.sessionNew(requestedId, {
133
+ ...readOnly ? { readOnly: true } : {},
134
+ ...allowedOrigins ? { allowedOrigins } : {},
135
+ ...browser ? { browser } : {}
136
+ });
137
+ establishCurrentSession(session.id);
138
+ return { session };
139
+ })
140
+ },
141
+ {
142
+ name: "session_allowlist",
143
+ description: "Set the domain allowlist of a session (the current one by default): the hosts or origins its tabs may load. Pass an empty list to remove it. Use it before typing secrets or working on sites with untrusted content.",
144
+ inputSchema: objectSchema({
145
+ session: { type: "string", description: "Session id; defaults to the current session." },
146
+ allowedOrigins: { type: "array", items: { type: "string" }, description: "Patterns: example.com, *.example.com, https://app.example.com, http://localhost:3000. Empty removes the allowlist." }
147
+ }, ["allowedOrigins"]),
148
+ readOnly: false,
149
+ destructive: false,
150
+ idempotent: true,
151
+ handle: (input) => Effect.gen(function* () {
152
+ const sessionId = resolveSessionId(input);
153
+ const allowedOrigins = requireObject(input).allowedOrigins;
154
+ if (!Array.isArray(allowedOrigins) || !allowedOrigins.every((item) => Predicate.isString(item))) {
155
+ return yield* Effect.fail(new Error("allowedOrigins must be an array of strings"));
156
+ }
157
+ return { session: yield* relay.sessionAllowlist(sessionId, allowedOrigins) };
158
+ })
159
+ },
160
+ {
161
+ name: "session_list",
162
+ description: "List Browse sessions.",
163
+ inputSchema: emptyInputSchema,
164
+ readOnly: true,
165
+ destructive: false,
166
+ idempotent: false,
167
+ handle: () => relay.sessions.pipe(Effect.map((sessions) => ({ sessions })))
168
+ },
169
+ {
170
+ name: "session_current",
171
+ description: "Return this MCP server's current Browse session id.",
172
+ inputSchema: emptyInputSchema,
173
+ readOnly: true,
174
+ destructive: false,
175
+ idempotent: true,
176
+ handle: () => Effect.succeed({ currentSession: currentSession.id })
177
+ },
178
+ {
179
+ name: "session_use",
180
+ description: "Set this MCP server's current Browse session id.",
181
+ inputSchema: objectSchema({
182
+ id: { type: "string", description: "Existing Browse session id." }
183
+ }, ["id"]),
184
+ readOnly: false,
185
+ destructive: false,
186
+ idempotent: true,
187
+ handle: (input) => Effect.gen(function* () {
188
+ const id = yield* Effect.try(() => requiredStringField(input, "id"));
189
+ yield* ensureSessionExists(relay, id);
190
+ establishCurrentSession(id);
191
+ return { currentSession: currentSession.id };
192
+ })
193
+ },
194
+ {
195
+ name: "session_reset",
196
+ description: "Reset a Browse session's state and page.",
197
+ inputSchema: objectSchema({
198
+ id: sessionSchemaProperty,
199
+ session: sessionAliasSchemaProperty
200
+ }),
201
+ readOnly: false,
202
+ destructive: true,
203
+ idempotent: false,
204
+ handle: (input) => Effect.gen(function* () {
205
+ const id = resolveSessionId(input, "id");
206
+ const reset = yield* relay.sessionReset(id);
207
+ establishCurrentSession(id);
208
+ return reset;
209
+ })
210
+ },
211
+ {
212
+ name: "session_delete",
213
+ description: "Delete a Browse session.",
214
+ inputSchema: objectSchema({
215
+ id: sessionSchemaProperty,
216
+ session: sessionAliasSchemaProperty
217
+ }),
218
+ readOnly: false,
219
+ destructive: true,
220
+ idempotent: true,
221
+ handle: (input) => Effect.gen(function* () {
222
+ const id = resolveSessionId(input, "id");
223
+ const result = yield* relay.sessionDelete(id);
224
+ if (currentSession.id === id) {
225
+ currentSession.id = `mcp-${crypto.randomUUID().slice(0, 8)}`;
226
+ currentSession.established = false;
227
+ }
228
+ return { ...result, currentSession: currentSession.id };
229
+ })
230
+ },
231
+ {
232
+ name: "session_adopt",
233
+ description: "Make an attached tab the Browse session's default page for subsequent bare execute calls. Omit targetUrl and targetIndex when only one user-attached tab is available.",
234
+ inputSchema: objectSchema({
235
+ session: { type: "string", description: "Optional existing Browse session id. Explicit ids must already exist; omit this field to use the MCP server's current session, which is created when needed." },
236
+ targetUrl: { type: "string", description: "Adopt an existing attached page whose URL contains this text. Omit when only one user-attached tab is available. This does not navigate or open a URL." },
237
+ targetIndex: { type: "integer", minimum: 0, description: "Adopt the attached page at this zero-based target index." },
238
+ tabId: { type: "integer", description: "Adopt the user's tab with this Chrome tab id (the tab= value in status)." }
239
+ }),
240
+ readOnly: false,
241
+ destructive: false,
242
+ idempotent: false,
243
+ handle: (input) => Effect.gen(function* () {
244
+ const args = yield* Effect.try(() => parseAdoptArguments(input));
245
+ const sessionId = args.session ?? currentSession.id;
246
+ const result = yield* relay.sessionAdopt({
247
+ sessionId,
248
+ createIfMissing: !args.session,
249
+ ...args.targetSelection ? { targetSelection: args.targetSelection } : {},
250
+ ...args.tabId === undefined ? {} : { tabId: args.tabId }
251
+ });
252
+ establishCurrentSession(sessionId);
253
+ return { ...result, confirmation: `Adopted session '${result.session.id}' default page: ${result.adoptedUrl}` };
254
+ })
255
+ },
256
+ {
257
+ name: "network_start",
258
+ description: "Start session-scoped network capture. Browse records normalized Playwright exchanges; HAR is only the optional export format. Bodies are embedded by default with per-body and total memory limits.",
259
+ inputSchema: objectSchema({
260
+ session: { type: "string", description: "Optional existing session id. Omit to use or create this MCP server's current session." },
261
+ urlFilter: { type: "string", description: "Capture only request URLs containing this text." },
262
+ resourceTypes: { type: "array", items: { type: "string" }, description: "Optional Playwright resource types such as fetch and xhr." },
263
+ content: { type: "string", enum: ["embed", "omit"], description: "Request and response body mode. Defaults to embed." },
264
+ maxBodyBytes: { type: "integer", minimum: 1, description: "Maximum bytes captured from each body. Defaults to 1000000." },
265
+ maxTotalBodyBytes: { type: "integer", minimum: 1, description: "Maximum body bytes retained for the capture. Defaults to 25000000." },
266
+ maxEntries: { type: "integer", minimum: 1, description: "Maximum captured requests. Defaults to 1000." },
267
+ maxDurationMs: { type: "integer", minimum: 1, maximum: 86400000, description: "The capture ends by itself, discarding what it captured, after this long. Defaults to 1800000 (30 minutes). It also ends when this MCP server exits." }
268
+ }),
269
+ readOnly: false,
270
+ destructive: false,
271
+ idempotent: false,
272
+ handle: (input) => Effect.gen(function* () {
273
+ const object = requireObject(input);
274
+ const explicitSession = optionalStringField(object, "session");
275
+ const sessionId = explicitSession ?? currentSession.id;
276
+ if (explicitSession) {
277
+ yield* ensureSessionExists(relay, sessionId);
278
+ } else {
279
+ yield* relay.sessionEnsure(sessionId);
280
+ currentSession.established = true;
281
+ }
282
+ yield* ensureExtensionConnected({ relay, sessionId, waitForReconnect: true });
283
+ const content = optionalStringField(object, "content");
284
+ if (content !== undefined && content !== "embed" && content !== "omit") {
285
+ return yield* Effect.fail(new Error("content must be embed or omit"));
286
+ }
287
+ const urlFilter = optionalStringField(object, "urlFilter");
288
+ const resourceTypes = optionalStringArrayField(object, "resourceTypes");
289
+ const maxBodyBytes = optionalPositiveIntegerField(object, "maxBodyBytes");
290
+ const maxTotalBodyBytes = optionalPositiveIntegerField(object, "maxTotalBodyBytes");
291
+ const maxEntries = optionalPositiveIntegerField(object, "maxEntries");
292
+ const maxDurationMs = optionalPositiveIntegerField(object, "maxDurationMs");
293
+ const result = yield* relay.networkStart({
294
+ sessionId,
295
+ ...urlFilter ? { urlFilter } : {},
296
+ ...resourceTypes && resourceTypes.length > 0 ? { resourceTypes } : {},
297
+ ...content ? { content } : {},
298
+ ...maxBodyBytes === undefined ? {} : { maxBodyBytes },
299
+ ...maxTotalBodyBytes === undefined ? {} : { maxTotalBodyBytes },
300
+ ...maxEntries === undefined ? {} : { maxEntries },
301
+ ...maxDurationMs === undefined ? {} : { maxDurationMs }
302
+ });
303
+ return { session: sessionId, ...result };
304
+ })
305
+ },
306
+ {
307
+ name: "network_status",
308
+ description: "Return bounded metadata for a session's active network capture. Captured values are never included.",
309
+ inputSchema: sessionOnlyInputSchema,
310
+ readOnly: true,
311
+ destructive: false,
312
+ idempotent: true,
313
+ handle: (input) => {
314
+ const sessionId = resolveSessionId(input);
315
+ return relay.networkStatus({ sessionId }).pipe(Effect.map((result) => ({ session: sessionId, ...result })));
316
+ }
317
+ },
318
+ {
319
+ name: "network_stop",
320
+ description: "Stop network capture. Optionally write a credential-redacted HAR and store lossless credential values in a reusable secret profile. At least one of outputPath or secrets is required.",
321
+ inputSchema: objectSchema({
322
+ session: sessionSchemaProperty,
323
+ outputPath: { type: "string", description: "Optional artifact path, resolved against the MCP process working directory. The HAR contains stable ${BC_SECRET_N} references (and redaction-only ${BC_REDACTED_N} for passwords, one-time codes and token-like URL path segments), not captured values." },
324
+ secrets: { type: "string", description: "Optional reusable profile name for captured credential values." }
325
+ }),
326
+ readOnly: false,
327
+ destructive: false,
328
+ idempotent: false,
329
+ handle: (input) => Effect.gen(function* () {
330
+ const sessionId = resolveSessionId(input);
331
+ const outputPath = optionalStringField(input, "outputPath");
332
+ const secrets = optionalStringField(input, "secrets");
333
+ if (!outputPath && !secrets) {
334
+ return yield* Effect.fail(new Error("network_stop requires outputPath, secrets, or both"));
335
+ }
336
+ return yield* relay.networkStop({
337
+ sessionId,
338
+ ...outputPath ? { outputPath: path.resolve(outputPath) } : {},
339
+ ...secrets ? { secrets } : {}
340
+ });
341
+ })
342
+ },
343
+ {
344
+ name: "network_cancel",
345
+ description: "Cancel a session's network capture and discard its in-memory exchanges.",
346
+ inputSchema: sessionOnlyInputSchema,
347
+ readOnly: false,
348
+ destructive: true,
349
+ idempotent: true,
350
+ handle: (input) => relay.networkCancel({ sessionId: resolveSessionId(input) })
351
+ },
352
+ {
353
+ name: "recording_start",
354
+ description: "Start recording the current session tab. CDP mode records video to WebM or MP4; tab-capture mode supports WebM and optional audio.",
355
+ inputSchema: objectSchema({
356
+ session: sessionSchemaProperty,
357
+ outputPath: { type: "string", description: "Recording artifact path, resolved against the MCP process working directory." },
358
+ mode: { type: "string", enum: ["auto", "tab-capture", "cdp"], description: "Recording backend. Defaults to auto." },
359
+ audio: { type: "boolean", description: "Capture tab audio in tab-capture mode." },
360
+ frameRate: { type: "integer", minimum: 1, maximum: 60, description: "Requested frame rate." },
361
+ maxDurationMs: { type: "integer", minimum: 1, description: "Maximum recording duration in milliseconds." },
362
+ cursor: { type: "boolean", description: "Draw the ghost cursor in the recording, for demo videos. Off by default: recordings show only the page, with nothing from Browse in the frame." }
363
+ }, ["outputPath"]),
364
+ readOnly: false,
365
+ destructive: false,
366
+ idempotent: false,
367
+ handle: (input) => {
368
+ const object = requireObject(input);
369
+ const mode = optionalStringField(object, "mode");
370
+ if (mode !== undefined && mode !== "auto" && mode !== "tab-capture" && mode !== "cdp") {
371
+ return Effect.fail(new Error("mode must be auto, tab-capture, or cdp"));
372
+ }
373
+ const frameRate = optionalPositiveIntegerField(object, "frameRate");
374
+ if (frameRate !== undefined && frameRate > 60)
375
+ return Effect.fail(new Error("frameRate must be at most 60"));
376
+ const maxDurationMs = optionalPositiveIntegerField(object, "maxDurationMs");
377
+ const audio = optionalBooleanField(object, "audio");
378
+ const cursor = optionalBooleanField(object, "cursor");
379
+ return relay.recordingStart({
380
+ sessionId: resolveSessionId(object),
381
+ outputPath: path.resolve(requiredStringField(object, "outputPath")),
382
+ ...mode ? { mode } : {},
383
+ ...audio === undefined ? {} : { audio },
384
+ ...frameRate === undefined ? {} : { frameRate },
385
+ ...maxDurationMs === undefined ? {} : { maxDurationMs },
386
+ ...cursor === undefined ? {} : { cursor }
387
+ });
388
+ }
389
+ },
390
+ {
391
+ name: "recording_stop",
392
+ description: "Stop the active recording for a session and finalize its artifact.",
393
+ inputSchema: sessionOnlyInputSchema,
394
+ readOnly: false,
395
+ destructive: false,
396
+ idempotent: false,
397
+ handle: (input) => relay.recordingStop({ sessionId: resolveSessionId(input) })
398
+ },
399
+ {
400
+ name: "tabs_cleanup",
401
+ description: "Close tabs agents opened that nobody has used for a while (no agent command and not visited by the user). Tabs the user shared or pinned, the active tab, tabs playing audio, recording, or waiting for a handoff stay open. Call it when you finish a task to leave the user's browser tidy; Browse also does this automatically after the user's idle setting (10 minutes unless they changed it).",
402
+ inputSchema: objectSchema({
403
+ idleMinutes: { type: "integer", minimum: 0, description: "Close tabs idle at least this long. 0 closes every eligible agent tab. Defaults to the user's setting (10 minutes; still 10 when the user set automatic cleanup to never)." }
404
+ }),
405
+ readOnly: false,
406
+ destructive: true,
407
+ idempotent: true,
408
+ handle: (input) => {
409
+ const value = typeof input === "object" && input !== null && "idleMinutes" in input ? input.idleMinutes : undefined;
410
+ return relay.tabsCleanup(typeof value === "number" && value >= 0 ? value : undefined);
411
+ }
412
+ },
413
+ {
414
+ name: "settings",
415
+ description: `Read or change the user's Browse settings, the same ones as the extension's options page: where agent tabs open (window), how they're grouped (grouping), when untouched agent tabs close (idleMinutes), the agent cursor (cursor), human-like input (humanInput) and handoff notifications (handoffNotifications). Use it only when the user asks to change how browse behaves, never on your own initiative. Without \`set\` it returns each setting's stored value, the value in effect, and the relay environment variable that overrides it, if any. With \`set\` it validates every value strictly, applies the change at once (the options page updates live) and returns what changed: tell the user what you changed. A setting an environment variable fixes is not changed, and the result says so. Security switches (${securityVariables.join(", ")}) are environment-only and can never be read or changed here.`,
416
+ inputSchema: objectSchema({
417
+ set: {
418
+ type: "object",
419
+ description: "Settings to change; omit to only read them.",
420
+ properties: Object.fromEntries(settingKeys.map((key) => [key, { enum: [...settingSpecs[key].values], description: settingSpecs[key].label }])),
421
+ additionalProperties: false
422
+ }
423
+ }),
424
+ readOnly: false,
425
+ destructive: false,
426
+ idempotent: true,
427
+ handle: (input) => {
428
+ const set = getObject2(input)?.set;
429
+ if (set === undefined)
430
+ return relay.settingsGet;
431
+ const changes = getObject2(set);
432
+ return changes ? relay.settingsSet(changes) : Effect.fail(new Error('set must be an object, for example { "grouping": "shared" }'));
433
+ }
434
+ },
435
+ {
436
+ name: "recording_status",
437
+ description: "Return bounded status and quality counters for a session recording.",
438
+ inputSchema: sessionOnlyInputSchema,
439
+ readOnly: true,
440
+ destructive: false,
441
+ idempotent: true,
442
+ handle: (input) => relay.recordingStatus({ sessionId: resolveSessionId(input) })
443
+ },
444
+ {
445
+ name: "recording_cancel",
446
+ description: "Cancel a session recording and discard its unfinished artifact.",
447
+ inputSchema: sessionOnlyInputSchema,
448
+ readOnly: false,
449
+ destructive: true,
450
+ idempotent: true,
451
+ handle: (input) => relay.recordingCancel({ sessionId: resolveSessionId(input) })
452
+ },
453
+ {
454
+ name: "flight_recorder_start",
455
+ description: "Start a rolling in-memory video buffer for the current session tab. Saving a clip does not stop buffering.",
456
+ inputSchema: objectSchema({
457
+ session: sessionSchemaProperty,
458
+ retentionMs: { type: "integer", minimum: 1000, maximum: 120000, description: "Rolling retention window. Defaults to 60000." },
459
+ frameRate: { type: "integer", minimum: 1, maximum: 60, description: "Saved clip frame rate. Defaults to 60." },
460
+ cursor: { type: "boolean", description: "Draw the ghost cursor in the buffered video. Off by default." }
461
+ }),
462
+ readOnly: false,
463
+ destructive: false,
464
+ idempotent: false,
465
+ handle: (input) => {
466
+ const object = requireObject(input);
467
+ const retentionMs = optionalPositiveIntegerField(object, "retentionMs");
468
+ const frameRate = optionalPositiveIntegerField(object, "frameRate");
469
+ const cursor = optionalBooleanField(object, "cursor");
470
+ return relay.flightRecorderStart({
471
+ sessionId: resolveSessionId(object),
472
+ ...retentionMs === undefined ? {} : { retentionMs },
473
+ ...frameRate === undefined ? {} : { frameRate },
474
+ ...cursor === undefined ? {} : { cursor }
475
+ });
476
+ }
477
+ },
478
+ {
479
+ name: "flight_recorder_status",
480
+ description: "Return bounded rolling-buffer duration, frame, byte, and drop counters.",
481
+ inputSchema: sessionOnlyInputSchema,
482
+ readOnly: true,
483
+ destructive: false,
484
+ idempotent: true,
485
+ handle: (input) => relay.flightRecorderStatus({ sessionId: resolveSessionId(input) })
486
+ },
487
+ {
488
+ name: "flight_recorder_save_last",
489
+ description: "Encode and save the most recent buffered browser video without stopping the flight recorder.",
490
+ inputSchema: objectSchema({
491
+ session: sessionSchemaProperty,
492
+ outputPath: { type: "string", description: "Fresh .webm or .mp4 path, resolved against the MCP process working directory." },
493
+ durationMs: { type: "integer", minimum: 1, description: "Recent duration to save. Defaults to 30000 and cannot exceed retention." }
494
+ }, ["outputPath"]),
495
+ readOnly: false,
496
+ destructive: false,
497
+ idempotent: false,
498
+ handle: (input) => {
499
+ const object = requireObject(input);
500
+ const durationMs = optionalPositiveIntegerField(object, "durationMs");
501
+ return relay.flightRecorderSaveLast({
502
+ sessionId: resolveSessionId(object),
503
+ outputPath: path.resolve(requiredStringField(object, "outputPath")),
504
+ ...durationMs === undefined ? {} : { durationMs }
505
+ });
506
+ }
507
+ },
508
+ {
509
+ name: "flight_recorder_cancel",
510
+ description: "Stop and discard a session's rolling video buffer.",
511
+ inputSchema: sessionOnlyInputSchema,
512
+ readOnly: false,
513
+ destructive: true,
514
+ idempotent: true,
515
+ handle: (input) => relay.flightRecorderCancel({ sessionId: resolveSessionId(input) })
516
+ },
517
+ {
518
+ name: "secrets_list",
519
+ description: 'List what agents may type without seeing: site secrets the user stored (names, kinds and allowed origins; type them with secrets.type(target, name) in execute; only the user adds them with `opencode-browser secrets add`), and Deadbolt logins the user granted to Browse for this session\'s allowed sites (title, username, which values and passkeys, never values; sign in with the signin tool or secrets.signIn(), or type one value with secrets.type(target, "deadbolt:<site>[/<username>]", { field })). Passkeys are listed by account (username, displayName). Only the user grants Deadbolt logins, in Deadbolt (`deadbolt agents allow <site>`, or `deadbolt agents allow --all`).',
520
+ inputSchema: sessionOnlyInputSchema,
521
+ readOnly: true,
522
+ destructive: false,
523
+ idempotent: true,
524
+ handle: (input) => Effect.gen(function* () {
525
+ const secrets = yield* listSecrets();
526
+ if (!readClient()) {
527
+ return { secrets, deadbolt: { connected: false, logins: [], passkeys: [], note: "Browse isn't connected to Deadbolt. The user can connect it with `opencode-browser deadbolt connect` and grant logins with `deadbolt agents allow <site>`." } };
528
+ }
529
+ const deadbolt = yield* relay.deadboltLogins({ sessionId: resolveSessionId(input) }).pipe(Effect.catch((error) => Effect.succeed({ connected: true, origins: [], logins: [], passkeys: [], error: { code: "unavailable", message: error.message } })));
530
+ return { secrets, deadbolt };
531
+ })
532
+ },
533
+ {
534
+ name: "signin",
535
+ description: 'Sign in to a site in the session\'s page with a Deadbolt login the user granted to Browse, without seeing any value. The relay does the whole sign-in in one call: username, submit, password, an authenticator (TOTP) code when asked (split boxes too), "Use password instead" toggles, and Sign in with Google when a Google login is granted. A username also picks the passkey account when the site signs in with a passkey. Returns only {status: signed_in | needs_user | needs_choice | not_granted | failed, step, reason}; needs_choice comes with choices [{username, displayName}] when several passkeys could sign in: call again with one username. needs_user means a captcha, a code sent by SMS or email, a push approval or a new-device check: hand it to the user with handoff() in execute. not_granted: tell the user what to allow (`deadbolt agents allow <site>`). Navigate to the sign-in page first, or pass site.',
536
+ inputSchema: objectSchema({
537
+ site: { type: "string", description: "The login's site: a host like github.com, or an exact origin. Defaults to the page's site; the page goes there first when it is elsewhere." },
538
+ username: { type: "string", description: "Which login (and which passkey account), when several are granted for the site." },
539
+ session: sessionSchemaProperty,
540
+ timeoutMs: { type: "integer", minimum: 1, description: "Overall limit. Defaults to 90000." }
541
+ }),
542
+ readOnly: false,
543
+ destructive: true,
544
+ idempotent: false,
545
+ handle: (input) => Effect.gen(function* () {
546
+ const object = input === undefined ? {} : requireObject(input);
547
+ const site = optionalStringField(object, "site");
548
+ const username = optionalStringField(object, "username");
549
+ const timeoutMs = optionalPositiveIntegerField(object, "timeoutMs");
550
+ const explicitSession = optionalStringField(object, "session");
551
+ const sessionId = explicitSession ?? currentSession.id;
552
+ yield* ensureExtensionConnected({ relay, sessionId, waitForReconnect: true });
553
+ const options = { ...site ? { site } : {}, ...username ? { username } : {}, ...timeoutMs === undefined ? {} : { timeoutMs } };
554
+ const outerTimeoutMs = timeoutMs === undefined ? 120000 : Math.max(60000, timeoutMs + 1e4);
555
+ const result = yield* relay.execute({ sessionId, code: `return await secrets.signIn(${JSON.stringify(options)})`, createIfMissing: !explicitSession, timeoutMs: outerTimeoutMs });
556
+ establishCurrentSession(sessionId);
557
+ if (result.isError)
558
+ return yield* Effect.fail(new Error(result.text));
559
+ return { ...result.value, ...result.aftermath?.endUrl ? { url: result.aftermath.endUrl } : {}, ...result.warnings?.length ? { warnings: result.warnings } : {} };
560
+ })
561
+ },
562
+ {
563
+ name: "secrets_status",
564
+ description: "Return secret profile references, sources, and expiration metadata without revealing credential values.",
565
+ inputSchema: objectSchema({ name: { type: "string", description: "Secret profile name." } }, ["name"]),
566
+ readOnly: true,
567
+ destructive: false,
568
+ idempotent: true,
569
+ handle: (input) => relay.authStatus({ name: requiredStringField(input, "name") })
570
+ },
571
+ {
572
+ name: "secrets_refresh",
573
+ description: "Reload a session page, observe fresh credentials, and update a secret profile while preserving stable BC_SECRET_N references.",
574
+ inputSchema: objectSchema({
575
+ name: { type: "string", description: "Existing secret profile name." },
576
+ session: sessionSchemaProperty,
577
+ urlFilter: { type: "string", description: "Observe credentials only on matching request URLs." },
578
+ timeoutMs: { type: "integer", minimum: 1, description: "Reload timeout. Defaults to 30000." }
579
+ }, ["name"]),
580
+ readOnly: false,
581
+ destructive: true,
582
+ idempotent: false,
583
+ handle: (input) => {
584
+ const object = requireObject(input);
585
+ const timeoutMs = optionalPositiveIntegerField(object, "timeoutMs");
586
+ const urlFilter = optionalStringField(object, "urlFilter");
587
+ return relay.authRefresh({
588
+ sessionId: resolveSessionId(object),
589
+ name: requiredStringField(object, "name"),
590
+ ...urlFilter ? { urlFilter } : {},
591
+ ...timeoutMs === undefined ? {} : { timeoutMs }
592
+ });
593
+ }
594
+ },
595
+ {
596
+ name: "secrets_run",
597
+ description: "Run a local command with a captured profile injected as BC_SECRET_N environment variables. Known values are replaced with their references in stdout and stderr, which keeps them out of transcripts. The command receives the real values, so redaction does not protect them from a command that deliberately sends or encodes them: run only commands you wrote or trust.",
598
+ inputSchema: objectSchema({
599
+ name: { type: "string", description: "Secret profile name." },
600
+ command: { type: "string", description: "Executable path or command name." },
601
+ args: { type: "array", items: { type: "string" }, description: "Command arguments." },
602
+ cwd: { type: "string", description: "Optional child working directory." },
603
+ timeoutMs: { type: "integer", minimum: 1, description: "Child timeout. Defaults to 120000." }
604
+ }, ["name", "command"]),
605
+ readOnly: false,
606
+ destructive: true,
607
+ idempotent: false,
608
+ handle: (input) => {
609
+ const object = requireObject(input);
610
+ const args = optionalStringArrayField(object, "args");
611
+ const cwd = optionalStringField(object, "cwd");
612
+ const timeoutMs = optionalPositiveIntegerField(object, "timeoutMs");
613
+ return relay.authRun({
614
+ name: requiredStringField(object, "name"),
615
+ command: requiredStringField(object, "command"),
616
+ ...args ? { args } : {},
617
+ cwd: path.resolve(cwd ?? process.cwd()),
618
+ ...timeoutMs === undefined ? {} : { timeoutMs }
619
+ });
620
+ }
621
+ },
622
+ {
623
+ name: "skill",
624
+ description: "Return the Browse agent skill instructions.",
625
+ inputSchema: emptyInputSchema,
626
+ readOnly: true,
627
+ destructive: false,
628
+ idempotent: true,
629
+ handle: () => Effect.tryPromise({
630
+ try: () => fs.readFile(path.join(packageRoot, "skills", "browse", "SKILL.md"), "utf8"),
631
+ catch: (cause) => new Error("read opencode-browser skill", { cause })
632
+ })
633
+ }
634
+ ];
635
+ }
636
+ var registerTools = Effect.gen(function* () {
637
+ const server = yield* McpServer.McpServer;
638
+ const relay = yield* Service;
639
+ const configuredSession = Option.getOrUndefined(yield* Config.option(Config.String("OPENCODE_BROWSER_SESSION")));
640
+ const currentSession = {
641
+ id: configuredSession || shimSession() || `mcp-${crypto.randomUUID().slice(0, 8)}`,
642
+ established: Boolean(configuredSession)
643
+ };
644
+ const updates = createUpdateWatcher2();
645
+ updates.refresh();
646
+ let updateNoted = false;
647
+ const withUpdateNote = (name, result) => {
648
+ updates.refresh();
649
+ const notice = updates.current;
650
+ if (!notice || updateNoted)
651
+ return result;
652
+ updateNoted = true;
653
+ if (name === "status")
654
+ return result;
655
+ return new McpSchema.CallToolResult({
656
+ ...result,
657
+ content: [...result.content, McpSchema.TextContent.make({ text: mcpUpdateNote(notice) })]
658
+ });
659
+ };
660
+ const register = (spec) => server.addTool({
661
+ tool: new McpSchema.Tool({
662
+ name: spec.name,
663
+ description: spec.description,
664
+ inputSchema: spec.inputSchema,
665
+ annotations: {
666
+ readOnlyHint: spec.readOnly,
667
+ destructiveHint: spec.destructive,
668
+ idempotentHint: spec.idempotent,
669
+ openWorldHint: true
670
+ }
671
+ }),
672
+ annotations: Context.empty(),
673
+ handle: (payload) => {
674
+ const operation = mcpToolRequiresRelayCompatibility(spec.name) ? ensureRelay({ relay }).pipe(Effect.flatMap((readiness) => readiness.buildProblem ? Effect.fail(new Error(readiness.buildProblem)) : spec.handle(payload))) : spec.handle(payload);
675
+ return operation.pipe(Effect.match({
676
+ onFailure: (error) => toolResult({ text: mcpErrorMessage(spec.name, error.message), isError: true }),
677
+ onSuccess: (value) => toolResultForValue(value)
678
+ }), Effect.map((result) => withUpdateNote(spec.name, result)));
679
+ }
680
+ });
681
+ const connectedBrowsers = relay.extensionStatus.pipe(Effect.map((status) => (status.browsers ?? []).map(({ id, name, default: isDefault }) => ({ id, name, default: isDefault }))), Effect.orElseSucceed(() => []));
682
+ let browsers = yield* connectedBrowsers.pipe(Effect.timeoutOption(1000), Effect.map(Option.getOrElse(() => [])));
683
+ yield* Effect.forEach(makeToolSpecs(relay, currentSession, () => updates.current, browsers), register, { discard: true });
684
+ const refreshBrowsers = Effect.gen(function* () {
685
+ const next = yield* connectedBrowsers;
686
+ if (browsersNote(next) === browsersNote(browsers))
687
+ return;
688
+ browsers = next;
689
+ const specs = makeToolSpecs(relay, currentSession, () => updates.current, browsers).filter((spec) => browserTools.has(spec.name));
690
+ yield* Effect.forEach(specs, register, { discard: true });
691
+ });
692
+ yield* refreshBrowsers.pipe(Effect.repeat(Schedule.spaced("5 seconds")), Effect.forkDetach);
693
+ });
694
+ var browserTools = new Set(["execute", "session_new"]);
695
+ function mcpUpdateNote(notice) {
696
+ return `Note: Browse has an update: ${notice.summary}. Tell the user they can run \`${notice.command}\` and then restart opencode, or ask you to run it for them.`;
697
+ }
698
+ function shimSession() {
699
+ const value = process.env.OPENCODE_BROWSER_MCP_SESSION;
700
+ return value && /^mcp-[0-9a-f]{8}$/.test(value) ? value : undefined;
701
+ }
702
+ function mcpToolRequiresRelayCompatibility(name) {
703
+ return !["status", "session_list", "session_current", "network_status", "recording_status", "flight_recorder_status", "secrets_status", "secrets_list", "skill"].includes(name);
704
+ }
705
+ var mcpServerLayer = McpServer.layerStdio({
706
+ name: "browse",
707
+ version: opencodeBrowserVersion2,
708
+ protocols: [McpProtocol.v2025_06_18, McpProtocol.v2025_11_25, McpProtocol.v2025_03_26, McpProtocol.v2024_11_05]
709
+ });
710
+ var mcpToolsLayer = Layer.effectDiscard(registerTools);
711
+ var runMcpServer = Layer.launch(mcpToolsLayer.pipe(Layer.provide(mcpServerLayer), Layer.provide(NodeStdio.layer), Layer.provide(layerFetchMcp)));
712
+ var ensureSessionExists = Effect.fnUntraced(function* (relay, id) {
713
+ const sessions = yield* relay.sessions;
714
+ const exists = sessions.some((session) => {
715
+ return session.id === id;
716
+ });
717
+ if (!exists) {
718
+ return yield* Effect.fail(new Error(`Session not found: ${id}`));
719
+ }
720
+ });
721
+ function parseExecuteArguments(input) {
722
+ const object = requireObject(input);
723
+ const code = requiredStringField(object, "code");
724
+ const session = optionalStringField(object, "session");
725
+ const timeoutMs = optionalNonNegativeIntegerField(object, "timeoutMs");
726
+ const targetSelection = parseMcpTargetSelection(object);
727
+ const browser = optionalStringField(object, "browser");
728
+ return {
729
+ code,
730
+ ...session ? { session } : {},
731
+ ...timeoutMs === undefined ? {} : { timeoutMs },
732
+ ...targetSelection ? { targetSelection } : {},
733
+ ...browser ? { browser } : {}
734
+ };
735
+ }
736
+ function parseAdoptArguments(input) {
737
+ const object = requireObject(input);
738
+ const session = optionalStringField(object, "session");
739
+ const targetSelection = parseMcpTargetSelection(object);
740
+ const tabId = typeof object.tabId === "number" && Number.isInteger(object.tabId) ? object.tabId : undefined;
741
+ if (tabId !== undefined && targetSelection)
742
+ throw new Error("Use only one of tabId, targetUrl, or targetIndex");
743
+ return {
744
+ ...session ? { session } : {},
745
+ ...targetSelection ? { targetSelection } : {},
746
+ ...tabId === undefined ? {} : { tabId }
747
+ };
748
+ }
749
+ function parseMcpTargetSelection(input) {
750
+ const urlIncludes = optionalStringField(input, "targetUrl");
751
+ const selection = parseTargetSelection2({
752
+ ...urlIncludes ? { urlIncludes } : {},
753
+ ...input.targetIndex === undefined ? {} : { index: input.targetIndex }
754
+ });
755
+ return selection?.urlIncludes || selection?.index !== undefined ? selection : undefined;
756
+ }
757
+ function requiredStringField(input, field) {
758
+ const value = optionalStringField(input, field);
759
+ if (!value) {
760
+ throw new Error(`${field} is required`);
761
+ }
762
+ return value;
763
+ }
764
+ function optionalStringField(input, field) {
765
+ const value = getString2(requireObject(input), field);
766
+ return value ? value : undefined;
767
+ }
768
+ function optionalBooleanField(input, field) {
769
+ const value = requireObject(input)[field];
770
+ return Predicate.isBoolean(value) ? value : undefined;
771
+ }
772
+ function optionalPositiveIntegerField(input, field) {
773
+ const value = requireObject(input)[field];
774
+ if (value === undefined)
775
+ return;
776
+ if (!Predicate.isNumber(value) || !Number.isInteger(value) || value <= 0) {
777
+ throw new Error(`${field} must be a positive integer`);
778
+ }
779
+ return value;
780
+ }
781
+ function optionalNonNegativeIntegerField(input, field) {
782
+ const value = requireObject(input)[field];
783
+ if (value === undefined)
784
+ return;
785
+ if (!Predicate.isNumber(value) || !Number.isInteger(value) || value < 0) {
786
+ throw new Error(`${field} must be a non-negative integer`);
787
+ }
788
+ return value;
789
+ }
790
+ function optionalStringArrayField(input, field) {
791
+ const value = requireObject(input)[field];
792
+ if (value === undefined)
793
+ return;
794
+ if (!Array.isArray(value) || !value.every((item) => Predicate.isString(item) && item.length > 0)) {
795
+ throw new Error(`${field} must be an array of non-empty strings`);
796
+ }
797
+ return value;
798
+ }
799
+ function requireObject(input) {
800
+ const object = getObject2(input);
801
+ if (!object) {
802
+ throw new Error("Expected arguments object");
803
+ }
804
+ return object;
805
+ }
806
+ function stringifyResult(value) {
807
+ if (Predicate.isString(value)) {
808
+ return value;
809
+ }
810
+ return JSON.stringify(value, null, 2);
811
+ }
812
+ function toolResultForValue(value) {
813
+ const object = getObject2(value);
814
+ const isError = object?.isError === true;
815
+ const media = Array.isArray(object?.media) ? object.media.flatMap((item) => {
816
+ const image = getObject2(item);
817
+ const mimeType = getString2(image, "mimeType");
818
+ const data = getString2(image, "data");
819
+ return image?.type === "image" && mimeType !== undefined && data !== undefined ? [{ data, mimeType }] : [];
820
+ }) : [];
821
+ const errorText = isError ? getString2(object, "text") : undefined;
822
+ if (media.length > 0) {
823
+ const { media: _media, ...structuredContent } = object ?? {};
824
+ const text = errorText ?? stringifyResult(structuredContent);
825
+ return new McpSchema.CallToolResult({
826
+ content: [
827
+ McpSchema.TextContent.make({ text }),
828
+ ...media.map((image) => McpSchema.ImageContent.make({
829
+ data: new Uint8Array(Buffer.from(image.data, "base64")),
830
+ mimeType: image.mimeType
831
+ }))
832
+ ],
833
+ structuredContent,
834
+ isError
835
+ });
836
+ }
837
+ const text = errorText ?? stringifyResult(value);
838
+ return toolResult({ text, ...object ? { structuredContent: object } : {}, isError });
839
+ }
840
+ function mcpErrorMessage(tool, message) {
841
+ if (!message.startsWith("Session not found:")) {
842
+ return message;
843
+ }
844
+ return tool === "execute" || tool === "session_adopt" ? `${message} Create it with session_new first, or omit the explicit session id to use the MCP current session.` : `${message} Create it with session_new first.`;
845
+ }
846
+ function toolResult(options) {
847
+ return new McpSchema.CallToolResult({
848
+ content: [McpSchema.TextContent.make({ text: options.text })],
849
+ structuredContent: options.structuredContent,
850
+ isError: options.isError
851
+ });
852
+ }
853
+ function objectSchema(properties, required = []) {
854
+ return {
855
+ type: "object",
856
+ properties,
857
+ required: [...required],
858
+ additionalProperties: false
859
+ };
860
+ }
861
+ export {
862
+ browsersNote,
863
+ mcpErrorMessage,
864
+ mcpServerLayer,
865
+ mcpToolRequiresRelayCompatibility,
866
+ mcpToolsLayer,
867
+ mcpUpdateNote,
868
+ runMcpServer,
869
+ toolResultForValue
870
+ };