@opencode/browse 0.0.6-dev.17 → 0.0.6-dev.19

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.
@@ -1,288 +1,316 @@
1
1
  import {
2
2
  autoUpdateStatus
3
- } from "./main-g5mke6rn.mjs";
3
+ } from "./main-yd50d95g.mjs";
4
4
  import {
5
- parseTargetSelection2,
6
5
  getObject2,
7
- getString2,
8
6
  Service,
9
7
  layerFetchMcp,
10
8
  relayBuildProblem,
11
9
  ensureRelay,
12
10
  ensureExtensionConnected
13
- } from "./main-3qpkwaa7.mjs";
11
+ } from "./main-8a245rm1.mjs";
12
+ import"./main-75rdz6bc.mjs";
14
13
  import {
15
14
  opencodeBrowserVersion2
16
- } from "./main-y7c1atbf.mjs";
17
- import {
18
- listSecrets,
19
- readClient,
20
- settingKeys,
21
- settingSpecs,
22
- securityVariables
23
- } from "./main-e4stqtf2.mjs";
15
+ } from "./main-ts9jjqs8.mjs";
24
16
  import {
25
17
  updateJson2,
26
18
  createUpdateWatcher2
27
- } from "./main-4p2p9eey.mjs";
28
- import"./main-4bht27ry.mjs";
19
+ } from "./main-2wrztakb.mjs";
20
+ import {
21
+ settingKeys,
22
+ settingSpecs,
23
+ securityVariables,
24
+ browsersNote2,
25
+ mcpUpdateNote2,
26
+ objectSchema,
27
+ emptyInputSchema,
28
+ sessionSchemaProperty,
29
+ sessionAliasSchemaProperty,
30
+ sessionOnlyInputSchema,
31
+ ensureSessionExists,
32
+ parseExecuteArguments,
33
+ parseAdoptArguments,
34
+ requiredStringField,
35
+ optionalStringField,
36
+ optionalBooleanField,
37
+ optionalPositiveIntegerField,
38
+ optionalStringArrayField,
39
+ requireObject,
40
+ toolResultForValue2,
41
+ mcpErrorMessage2,
42
+ toolResult,
43
+ readClient,
44
+ listSecrets
45
+ } from "./main-90enpn5q.mjs";
29
46
 
30
47
  // src/mcp.ts
31
48
  import { NodeStdio } from "@effect/platform-node";
32
- import { Config, Context, Effect, Layer, Option, Predicate, Schedule } from "effect";
49
+ import { Config, Context, Effect as Effect7, Layer, Option, Schedule } from "effect";
33
50
  import { McpProtocol, McpSchema, McpServer } from "effect/ai";
51
+ import path5 from "node:path";
52
+ import { fileURLToPath } from "node:url";
53
+
54
+ // src/mcp/browser.ts
55
+ import { Effect } from "effect";
34
56
  import fs from "node:fs/promises";
35
57
  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.`;
58
+
59
+ // src/mcp/registry.ts
60
+ var groups = [];
61
+ function registerMcpTools(options) {
62
+ groups.push({
63
+ order: options.order,
64
+ build: options.build
65
+ });
43
66
  }
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);
67
+ function createMcpToolContext(options) {
68
+ const browsers = options.browsers ?? [];
69
+ const browserNote = browsersNote2(browsers);
57
70
  const browserProperty = {
58
71
  type: "string",
59
72
  description: `Optional browser id for a new session's tabs; omit for the default browser. A session stays in its browser.${browserNote}`
60
73
  };
61
- const resolveSessionId = (input, field = "session") => optionalStringField(input, field) ?? (field === "id" ? optionalStringField(input, "session") : undefined) ?? currentSession.id;
74
+ const resolveSessionId = (input, field = "session") => optionalStringField(input, field) ?? (field === "id" ? optionalStringField(input, "session") : undefined) ?? options.currentSession.id;
62
75
  const establishCurrentSession = (id) => {
63
- currentSession.id = id;
64
- currentSession.established = true;
76
+ options.currentSession.id = id;
77
+ options.currentSession.established = true;
65
78
  };
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
- },
79
+ return {
80
+ relay: options.relay,
81
+ currentSession: options.currentSession,
82
+ packageRoot: options.packageRoot,
83
+ update: options.update ?? (() => {
84
+ return;
85
+ }),
86
+ browsers,
87
+ browserNote,
88
+ browserProperty,
89
+ resolveSessionId,
90
+ establishCurrentSession
91
+ };
92
+ }
93
+ function buildRegisteredMcpTools(ctx) {
94
+ return [...groups].sort((left, right) => left.order - right.order).flatMap((group) => group.build(ctx));
95
+ }
96
+
97
+ // src/mcp/browser.ts
98
+ registerMcpTools({
99
+ order: 110,
100
+ build: (ctx) => [
101
101
  {
102
102
  name: "status",
103
+ requiresRelayCompatibility: false,
103
104
  description: "Return relay, extension, target, and session status.",
104
105
  inputSchema: emptyInputSchema,
105
106
  readOnly: true,
106
107
  destructive: false,
107
108
  idempotent: false,
108
109
  handle: () => Effect.gen(function* () {
109
- const [version, status] = yield* Effect.all([relay.version, relay.extensionStatus]);
110
+ const [version, status] = yield* Effect.all([ctx.relay.version, ctx.relay.extensionStatus]);
110
111
  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() };
112
+ const available = updateJson2(ctx.update());
113
+ return {
114
+ endpoint: ctx.relay.endpoint,
115
+ currentSession: ctx.currentSession.id,
116
+ version,
117
+ status,
118
+ ...buildProblem ? { buildProblem } : {},
119
+ ...available ? { update: available } : {},
120
+ autoUpdate: autoUpdateStatus()
121
+ };
113
122
  })
114
- },
123
+ }
124
+ ]
125
+ });
126
+ registerMcpTools({
127
+ order: 420,
128
+ build: (ctx) => [
115
129
  {
116
- name: "session_new",
117
- description: `Create a Browse session and make it current for this MCP server.${browserNote}`,
130
+ name: "tabs_cleanup",
131
+ 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).",
118
132
  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." }
133
+ idleMinutes: {
134
+ type: "integer",
135
+ minimum: 0,
136
+ 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)."
137
+ }
123
138
  }),
124
139
  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
+ destructive: true,
141
+ idempotent: true,
142
+ handle: (input) => {
143
+ const value = typeof input === "object" && input !== null && "idleMinutes" in input ? input.idleMinutes : undefined;
144
+ return ctx.relay.tabsCleanup(typeof value === "number" && value >= 0 ? value : undefined);
145
+ }
140
146
  },
141
147
  {
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.",
148
+ name: "settings",
149
+ 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.`,
144
150
  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"]),
151
+ set: {
152
+ type: "object",
153
+ description: "Settings to change; omit to only read them.",
154
+ properties: Object.fromEntries(settingKeys.map((key) => [key, { enum: [...settingSpecs[key].values], description: settingSpecs[key].label }])),
155
+ additionalProperties: false
156
+ }
157
+ }),
148
158
  readOnly: false,
149
159
  destructive: false,
150
160
  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
- },
161
+ handle: (input) => {
162
+ const set = getObject2(input)?.set;
163
+ if (set === undefined)
164
+ return ctx.relay.settingsGet;
165
+ const changes = getObject2(set);
166
+ return changes ? ctx.relay.settingsSet(changes) : Effect.fail(new Error('set must be an object, for example { "grouping": "shared" }'));
167
+ }
168
+ }
169
+ ]
170
+ });
171
+ registerMcpTools({
172
+ order: 700,
173
+ build: (ctx) => [
169
174
  {
170
- name: "session_current",
171
- description: "Return this MCP server's current Browse session id.",
175
+ name: "skill",
176
+ requiresRelayCompatibility: false,
177
+ description: "Return the Browse agent skill instructions.",
172
178
  inputSchema: emptyInputSchema,
173
179
  readOnly: true,
174
180
  destructive: false,
175
181
  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;
182
+ handle: () => Effect.tryPromise({
183
+ try: () => fs.readFile(path.join(ctx.packageRoot, "skills", "browse", "SKILL.md"), "utf8"),
184
+ catch: (cause) => new Error("read opencode-browser skill", { cause })
209
185
  })
210
- },
186
+ }
187
+ ]
188
+ });
189
+
190
+ // src/mcp/execute.ts
191
+ import { Effect as Effect2 } from "effect";
192
+ registerMcpTools({
193
+ order: 100,
194
+ build: (ctx) => [
211
195
  {
212
- name: "session_delete",
213
- description: "Delete a Browse session.",
196
+ name: "execute",
197
+ listsBrowsers: true,
198
+ 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).${ctx.browserNote}`,
214
199
  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;
200
+ code: {
201
+ type: "string",
202
+ 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.`
203
+ },
204
+ session: {
205
+ type: "string",
206
+ 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."
207
+ },
208
+ targetUrl: {
209
+ type: "string",
210
+ description: "Optional URL substring selecting an existing attached page. This does not navigate or open a URL; use page.goto() for that."
211
+ },
212
+ targetIndex: {
213
+ type: "integer",
214
+ minimum: 0,
215
+ description: "Optional zero-based attached page index selector."
216
+ },
217
+ browser: ctx.browserProperty,
218
+ timeoutMs: {
219
+ type: "integer",
220
+ minimum: 0,
221
+ description: "Optional overall script timeout in milliseconds (paused while handoff() waits on the user); defaults to 60000, 0 disables."
227
222
  }
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
- }),
223
+ }, ["code"]),
240
224
  readOnly: false,
241
- destructive: false,
225
+ destructive: true,
242
226
  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({
227
+ handle: (input) => Effect2.gen(function* () {
228
+ const args = yield* Effect2.try(() => parseExecuteArguments(input));
229
+ const sessionId = args.session ?? ctx.currentSession.id;
230
+ yield* ensureExtensionConnected({ relay: ctx.relay, sessionId, waitForReconnect: true });
231
+ const result = yield* ctx.relay.execute({
247
232
  sessionId,
233
+ code: args.code,
248
234
  createIfMissing: !args.session,
235
+ ...args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs },
249
236
  ...args.targetSelection ? { targetSelection: args.targetSelection } : {},
250
- ...args.tabId === undefined ? {} : { tabId: args.tabId }
237
+ ...args.browser ? { browser: args.browser } : {}
251
238
  });
252
- establishCurrentSession(sessionId);
253
- return { ...result, confirmation: `Adopted session '${result.session.id}' default page: ${result.adoptedUrl}` };
239
+ const recreated = !args.session && ctx.currentSession.established && result.session.created === true;
240
+ ctx.establishCurrentSession(sessionId);
241
+ return {
242
+ ...result,
243
+ ...recreated ? { notice: `Recreated session '${sessionId}' — relay had no such session; page and state were reset.` } : {}
244
+ };
254
245
  })
255
- },
246
+ }
247
+ ]
248
+ });
249
+
250
+ // src/mcp/network.ts
251
+ import { Effect as Effect3 } from "effect";
252
+ import path2 from "node:path";
253
+ registerMcpTools({
254
+ order: 300,
255
+ build: (ctx) => [
256
256
  {
257
257
  name: "network_start",
258
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
259
  inputSchema: objectSchema({
260
- session: { type: "string", description: "Optional existing session id. Omit to use or create this MCP server's current session." },
260
+ session: {
261
+ type: "string",
262
+ description: "Optional existing session id. Omit to use or create this MCP server's current session."
263
+ },
261
264
  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." }
265
+ resourceTypes: {
266
+ type: "array",
267
+ items: { type: "string" },
268
+ description: "Optional Playwright resource types such as fetch and xhr."
269
+ },
270
+ content: {
271
+ type: "string",
272
+ enum: ["embed", "omit"],
273
+ description: "Request and response body mode. Defaults to embed."
274
+ },
275
+ maxBodyBytes: {
276
+ type: "integer",
277
+ minimum: 1,
278
+ description: "Maximum bytes captured from each body. Defaults to 1000000."
279
+ },
280
+ maxTotalBodyBytes: {
281
+ type: "integer",
282
+ minimum: 1,
283
+ description: "Maximum body bytes retained for the capture. Defaults to 25000000."
284
+ },
285
+ maxEntries: {
286
+ type: "integer",
287
+ minimum: 1,
288
+ description: "Maximum captured requests. Defaults to 1000."
289
+ },
290
+ maxDurationMs: {
291
+ type: "integer",
292
+ minimum: 1,
293
+ maximum: 86400000,
294
+ 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."
295
+ }
268
296
  }),
269
297
  readOnly: false,
270
298
  destructive: false,
271
299
  idempotent: false,
272
- handle: (input) => Effect.gen(function* () {
300
+ handle: (input) => Effect3.gen(function* () {
273
301
  const object = requireObject(input);
274
302
  const explicitSession = optionalStringField(object, "session");
275
- const sessionId = explicitSession ?? currentSession.id;
303
+ const sessionId = explicitSession ?? ctx.currentSession.id;
276
304
  if (explicitSession) {
277
- yield* ensureSessionExists(relay, sessionId);
305
+ yield* ensureSessionExists(ctx.relay, sessionId);
278
306
  } else {
279
- yield* relay.sessionEnsure(sessionId);
280
- currentSession.established = true;
307
+ yield* ctx.relay.sessionEnsure(sessionId);
308
+ ctx.currentSession.established = true;
281
309
  }
282
- yield* ensureExtensionConnected({ relay, sessionId, waitForReconnect: true });
310
+ yield* ensureExtensionConnected({ relay: ctx.relay, sessionId, waitForReconnect: true });
283
311
  const content = optionalStringField(object, "content");
284
312
  if (content !== undefined && content !== "embed" && content !== "omit") {
285
- return yield* Effect.fail(new Error("content must be embed or omit"));
313
+ return yield* Effect3.fail(new Error("content must be embed or omit"));
286
314
  }
287
315
  const urlFilter = optionalStringField(object, "urlFilter");
288
316
  const resourceTypes = optionalStringArrayField(object, "resourceTypes");
@@ -290,7 +318,7 @@ function makeToolSpecs(relay, currentSession, update = () => {
290
318
  const maxTotalBodyBytes = optionalPositiveIntegerField(object, "maxTotalBodyBytes");
291
319
  const maxEntries = optionalPositiveIntegerField(object, "maxEntries");
292
320
  const maxDurationMs = optionalPositiveIntegerField(object, "maxDurationMs");
293
- const result = yield* relay.networkStart({
321
+ const result = yield* ctx.relay.networkStart({
294
322
  sessionId,
295
323
  ...urlFilter ? { urlFilter } : {},
296
324
  ...resourceTypes && resourceTypes.length > 0 ? { resourceTypes } : {},
@@ -305,14 +333,15 @@ function makeToolSpecs(relay, currentSession, update = () => {
305
333
  },
306
334
  {
307
335
  name: "network_status",
336
+ requiresRelayCompatibility: false,
308
337
  description: "Return bounded metadata for a session's active network capture. Captured values are never included.",
309
338
  inputSchema: sessionOnlyInputSchema,
310
339
  readOnly: true,
311
340
  destructive: false,
312
341
  idempotent: true,
313
342
  handle: (input) => {
314
- const sessionId = resolveSessionId(input);
315
- return relay.networkStatus({ sessionId }).pipe(Effect.map((result) => ({ session: sessionId, ...result })));
343
+ const sessionId = ctx.resolveSessionId(input);
344
+ return ctx.relay.networkStatus({ sessionId }).pipe(Effect3.map((result) => ({ session: sessionId, ...result })));
316
345
  }
317
346
  },
318
347
  {
@@ -320,22 +349,25 @@ function makeToolSpecs(relay, currentSession, update = () => {
320
349
  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
350
  inputSchema: objectSchema({
322
351
  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." },
352
+ outputPath: {
353
+ type: "string",
354
+ 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."
355
+ },
324
356
  secrets: { type: "string", description: "Optional reusable profile name for captured credential values." }
325
357
  }),
326
358
  readOnly: false,
327
359
  destructive: false,
328
360
  idempotent: false,
329
- handle: (input) => Effect.gen(function* () {
330
- const sessionId = resolveSessionId(input);
361
+ handle: (input) => Effect3.gen(function* () {
362
+ const sessionId = ctx.resolveSessionId(input);
331
363
  const outputPath = optionalStringField(input, "outputPath");
332
364
  const secrets = optionalStringField(input, "secrets");
333
365
  if (!outputPath && !secrets) {
334
- return yield* Effect.fail(new Error("network_stop requires outputPath, secrets, or both"));
366
+ return yield* Effect3.fail(new Error("network_stop requires outputPath, secrets, or both"));
335
367
  }
336
- return yield* relay.networkStop({
368
+ return yield* ctx.relay.networkStop({
337
369
  sessionId,
338
- ...outputPath ? { outputPath: path.resolve(outputPath) } : {},
370
+ ...outputPath ? { outputPath: path2.resolve(outputPath) } : {},
339
371
  ...secrets ? { secrets } : {}
340
372
  });
341
373
  })
@@ -347,19 +379,38 @@ function makeToolSpecs(relay, currentSession, update = () => {
347
379
  readOnly: false,
348
380
  destructive: true,
349
381
  idempotent: true,
350
- handle: (input) => relay.networkCancel({ sessionId: resolveSessionId(input) })
351
- },
382
+ handle: (input) => ctx.relay.networkCancel({ sessionId: ctx.resolveSessionId(input) })
383
+ }
384
+ ]
385
+ });
386
+
387
+ // src/mcp/recording.ts
388
+ import { Effect as Effect4 } from "effect";
389
+ import path3 from "node:path";
390
+ registerMcpTools({
391
+ order: 400,
392
+ build: (ctx) => [
352
393
  {
353
394
  name: "recording_start",
354
395
  description: "Start recording the current session tab. CDP mode records video to WebM or MP4; tab-capture mode supports WebM and optional audio.",
355
396
  inputSchema: objectSchema({
356
397
  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." },
398
+ outputPath: {
399
+ type: "string",
400
+ description: "Recording artifact path, resolved against the MCP process working directory."
401
+ },
402
+ mode: {
403
+ type: "string",
404
+ enum: ["auto", "tab-capture", "cdp"],
405
+ description: "Recording backend. Defaults to auto."
406
+ },
359
407
  audio: { type: "boolean", description: "Capture tab audio in tab-capture mode." },
360
408
  frameRate: { type: "integer", minimum: 1, maximum: 60, description: "Requested frame rate." },
361
409
  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." }
410
+ cursor: {
411
+ type: "boolean",
412
+ 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."
413
+ }
363
414
  }, ["outputPath"]),
364
415
  readOnly: false,
365
416
  destructive: false,
@@ -368,17 +419,17 @@ function makeToolSpecs(relay, currentSession, update = () => {
368
419
  const object = requireObject(input);
369
420
  const mode = optionalStringField(object, "mode");
370
421
  if (mode !== undefined && mode !== "auto" && mode !== "tab-capture" && mode !== "cdp") {
371
- return Effect.fail(new Error("mode must be auto, tab-capture, or cdp"));
422
+ return Effect4.fail(new Error("mode must be auto, tab-capture, or cdp"));
372
423
  }
373
424
  const frameRate = optionalPositiveIntegerField(object, "frameRate");
374
425
  if (frameRate !== undefined && frameRate > 60)
375
- return Effect.fail(new Error("frameRate must be at most 60"));
426
+ return Effect4.fail(new Error("frameRate must be at most 60"));
376
427
  const maxDurationMs = optionalPositiveIntegerField(object, "maxDurationMs");
377
428
  const audio = optionalBooleanField(object, "audio");
378
429
  const cursor = optionalBooleanField(object, "cursor");
379
- return relay.recordingStart({
380
- sessionId: resolveSessionId(object),
381
- outputPath: path.resolve(requiredStringField(object, "outputPath")),
430
+ return ctx.relay.recordingStart({
431
+ sessionId: ctx.resolveSessionId(object),
432
+ outputPath: path3.resolve(requiredStringField(object, "outputPath")),
382
433
  ...mode ? { mode } : {},
383
434
  ...audio === undefined ? {} : { audio },
384
435
  ...frameRate === undefined ? {} : { frameRate },
@@ -394,52 +445,22 @@ function makeToolSpecs(relay, currentSession, update = () => {
394
445
  readOnly: false,
395
446
  destructive: false,
396
447
  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
- },
448
+ handle: (input) => ctx.relay.recordingStop({ sessionId: ctx.resolveSessionId(input) })
449
+ }
450
+ ]
451
+ });
452
+ registerMcpTools({
453
+ order: 500,
454
+ build: (ctx) => [
435
455
  {
436
456
  name: "recording_status",
457
+ requiresRelayCompatibility: false,
437
458
  description: "Return bounded status and quality counters for a session recording.",
438
459
  inputSchema: sessionOnlyInputSchema,
439
460
  readOnly: true,
440
461
  destructive: false,
441
462
  idempotent: true,
442
- handle: (input) => relay.recordingStatus({ sessionId: resolveSessionId(input) })
463
+ handle: (input) => ctx.relay.recordingStatus({ sessionId: ctx.resolveSessionId(input) })
443
464
  },
444
465
  {
445
466
  name: "recording_cancel",
@@ -448,15 +469,25 @@ function makeToolSpecs(relay, currentSession, update = () => {
448
469
  readOnly: false,
449
470
  destructive: true,
450
471
  idempotent: true,
451
- handle: (input) => relay.recordingCancel({ sessionId: resolveSessionId(input) })
472
+ handle: (input) => ctx.relay.recordingCancel({ sessionId: ctx.resolveSessionId(input) })
452
473
  },
453
474
  {
454
475
  name: "flight_recorder_start",
455
476
  description: "Start a rolling in-memory video buffer for the current session tab. Saving a clip does not stop buffering.",
456
477
  inputSchema: objectSchema({
457
478
  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." },
479
+ retentionMs: {
480
+ type: "integer",
481
+ minimum: 1000,
482
+ maximum: 120000,
483
+ description: "Rolling retention window. Defaults to 60000."
484
+ },
485
+ frameRate: {
486
+ type: "integer",
487
+ minimum: 1,
488
+ maximum: 60,
489
+ description: "Saved clip frame rate. Defaults to 60."
490
+ },
460
491
  cursor: { type: "boolean", description: "Draw the ghost cursor in the buffered video. Off by default." }
461
492
  }),
462
493
  readOnly: false,
@@ -467,8 +498,8 @@ function makeToolSpecs(relay, currentSession, update = () => {
467
498
  const retentionMs = optionalPositiveIntegerField(object, "retentionMs");
468
499
  const frameRate = optionalPositiveIntegerField(object, "frameRate");
469
500
  const cursor = optionalBooleanField(object, "cursor");
470
- return relay.flightRecorderStart({
471
- sessionId: resolveSessionId(object),
501
+ return ctx.relay.flightRecorderStart({
502
+ sessionId: ctx.resolveSessionId(object),
472
503
  ...retentionMs === undefined ? {} : { retentionMs },
473
504
  ...frameRate === undefined ? {} : { frameRate },
474
505
  ...cursor === undefined ? {} : { cursor }
@@ -477,20 +508,28 @@ function makeToolSpecs(relay, currentSession, update = () => {
477
508
  },
478
509
  {
479
510
  name: "flight_recorder_status",
511
+ requiresRelayCompatibility: false,
480
512
  description: "Return bounded rolling-buffer duration, frame, byte, and drop counters.",
481
513
  inputSchema: sessionOnlyInputSchema,
482
514
  readOnly: true,
483
515
  destructive: false,
484
516
  idempotent: true,
485
- handle: (input) => relay.flightRecorderStatus({ sessionId: resolveSessionId(input) })
517
+ handle: (input) => ctx.relay.flightRecorderStatus({ sessionId: ctx.resolveSessionId(input) })
486
518
  },
487
519
  {
488
520
  name: "flight_recorder_save_last",
489
521
  description: "Encode and save the most recent buffered browser video without stopping the flight recorder.",
490
522
  inputSchema: objectSchema({
491
523
  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." }
524
+ outputPath: {
525
+ type: "string",
526
+ description: "Fresh .webm or .mp4 path, resolved against the MCP process working directory."
527
+ },
528
+ durationMs: {
529
+ type: "integer",
530
+ minimum: 1,
531
+ description: "Recent duration to save. Defaults to 30000 and cannot exceed retention."
532
+ }
494
533
  }, ["outputPath"]),
495
534
  readOnly: false,
496
535
  destructive: false,
@@ -498,9 +537,9 @@ function makeToolSpecs(relay, currentSession, update = () => {
498
537
  handle: (input) => {
499
538
  const object = requireObject(input);
500
539
  const durationMs = optionalPositiveIntegerField(object, "durationMs");
501
- return relay.flightRecorderSaveLast({
502
- sessionId: resolveSessionId(object),
503
- outputPath: path.resolve(requiredStringField(object, "outputPath")),
540
+ return ctx.relay.flightRecorderSaveLast({
541
+ sessionId: ctx.resolveSessionId(object),
542
+ outputPath: path3.resolve(requiredStringField(object, "outputPath")),
504
543
  ...durationMs === undefined ? {} : { durationMs }
505
544
  });
506
545
  }
@@ -512,21 +551,45 @@ function makeToolSpecs(relay, currentSession, update = () => {
512
551
  readOnly: false,
513
552
  destructive: true,
514
553
  idempotent: true,
515
- handle: (input) => relay.flightRecorderCancel({ sessionId: resolveSessionId(input) })
516
- },
554
+ handle: (input) => ctx.relay.flightRecorderCancel({ sessionId: ctx.resolveSessionId(input) })
555
+ }
556
+ ]
557
+ });
558
+
559
+ // src/mcp/secrets.ts
560
+ import { Effect as Effect5 } from "effect";
561
+ import path4 from "node:path";
562
+ registerMcpTools({
563
+ order: 600,
564
+ build: (ctx) => [
517
565
  {
518
566
  name: "secrets_list",
567
+ requiresRelayCompatibility: false,
519
568
  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
569
  inputSchema: sessionOnlyInputSchema,
521
570
  readOnly: true,
522
571
  destructive: false,
523
572
  idempotent: true,
524
- handle: (input) => Effect.gen(function* () {
573
+ handle: (input) => Effect5.gen(function* () {
525
574
  const secrets = yield* listSecrets();
526
575
  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>`." } };
576
+ return {
577
+ secrets,
578
+ deadbolt: {
579
+ connected: false,
580
+ logins: [],
581
+ passkeys: [],
582
+ 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>`."
583
+ }
584
+ };
528
585
  }
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 } })));
586
+ const deadbolt = yield* ctx.relay.deadboltLogins({ sessionId: ctx.resolveSessionId(input) }).pipe(Effect5.catch((error) => Effect5.succeed({
587
+ connected: true,
588
+ origins: [],
589
+ logins: [],
590
+ passkeys: [],
591
+ error: { code: "unavailable", message: error.message }
592
+ })));
530
593
  return { secrets, deadbolt };
531
594
  })
532
595
  },
@@ -534,39 +597,59 @@ function makeToolSpecs(relay, currentSession, update = () => {
534
597
  name: "signin",
535
598
  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
599
  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." },
600
+ site: {
601
+ type: "string",
602
+ 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."
603
+ },
604
+ username: {
605
+ type: "string",
606
+ description: "Which login (and which passkey account), when several are granted for the site."
607
+ },
539
608
  session: sessionSchemaProperty,
540
609
  timeoutMs: { type: "integer", minimum: 1, description: "Overall limit. Defaults to 90000." }
541
610
  }),
542
611
  readOnly: false,
543
612
  destructive: true,
544
613
  idempotent: false,
545
- handle: (input) => Effect.gen(function* () {
614
+ handle: (input) => Effect5.gen(function* () {
546
615
  const object = input === undefined ? {} : requireObject(input);
547
616
  const site = optionalStringField(object, "site");
548
617
  const username = optionalStringField(object, "username");
549
618
  const timeoutMs = optionalPositiveIntegerField(object, "timeoutMs");
550
619
  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 } };
620
+ const sessionId = explicitSession ?? ctx.currentSession.id;
621
+ yield* ensureExtensionConnected({ relay: ctx.relay, sessionId, waitForReconnect: true });
622
+ const options = {
623
+ ...site ? { site } : {},
624
+ ...username ? { username } : {},
625
+ ...timeoutMs === undefined ? {} : { timeoutMs }
626
+ };
554
627
  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);
628
+ const result = yield* ctx.relay.execute({
629
+ sessionId,
630
+ code: `return await secrets.signIn(${JSON.stringify(options)})`,
631
+ createIfMissing: !explicitSession,
632
+ timeoutMs: outerTimeoutMs
633
+ });
634
+ ctx.establishCurrentSession(sessionId);
557
635
  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 } : {} };
636
+ return yield* Effect5.fail(new Error(result.text));
637
+ return {
638
+ ...result.value,
639
+ ...result.aftermath?.endUrl ? { url: result.aftermath.endUrl } : {},
640
+ ...result.warnings?.length ? { warnings: result.warnings } : {}
641
+ };
560
642
  })
561
643
  },
562
644
  {
563
645
  name: "secrets_status",
646
+ requiresRelayCompatibility: false,
564
647
  description: "Return secret profile references, sources, and expiration metadata without revealing credential values.",
565
648
  inputSchema: objectSchema({ name: { type: "string", description: "Secret profile name." } }, ["name"]),
566
649
  readOnly: true,
567
650
  destructive: false,
568
651
  idempotent: true,
569
- handle: (input) => relay.authStatus({ name: requiredStringField(input, "name") })
652
+ handle: (input) => ctx.relay.authStatus({ name: requiredStringField(input, "name") })
570
653
  },
571
654
  {
572
655
  name: "secrets_refresh",
@@ -584,8 +667,8 @@ function makeToolSpecs(relay, currentSession, update = () => {
584
667
  const object = requireObject(input);
585
668
  const timeoutMs = optionalPositiveIntegerField(object, "timeoutMs");
586
669
  const urlFilter = optionalStringField(object, "urlFilter");
587
- return relay.authRefresh({
588
- sessionId: resolveSessionId(object),
670
+ return ctx.relay.authRefresh({
671
+ sessionId: ctx.resolveSessionId(object),
589
672
  name: requiredStringField(object, "name"),
590
673
  ...urlFilter ? { urlFilter } : {},
591
674
  ...timeoutMs === undefined ? {} : { timeoutMs }
@@ -610,30 +693,204 @@ function makeToolSpecs(relay, currentSession, update = () => {
610
693
  const args = optionalStringArrayField(object, "args");
611
694
  const cwd = optionalStringField(object, "cwd");
612
695
  const timeoutMs = optionalPositiveIntegerField(object, "timeoutMs");
613
- return relay.authRun({
696
+ return ctx.relay.authRun({
614
697
  name: requiredStringField(object, "name"),
615
698
  command: requiredStringField(object, "command"),
616
699
  ...args ? { args } : {},
617
- cwd: path.resolve(cwd ?? process.cwd()),
700
+ cwd: path4.resolve(cwd ?? process.cwd()),
618
701
  ...timeoutMs === undefined ? {} : { timeoutMs }
619
702
  });
620
703
  }
704
+ }
705
+ ]
706
+ });
707
+
708
+ // src/mcp/session.ts
709
+ import { Effect as Effect6, Predicate } from "effect";
710
+ registerMcpTools({
711
+ order: 200,
712
+ build: (ctx) => [
713
+ {
714
+ name: "session_new",
715
+ listsBrowsers: true,
716
+ description: `Create a Browse session and make it current for this MCP server.${ctx.browserNote}`,
717
+ inputSchema: objectSchema({
718
+ browser: ctx.browserProperty,
719
+ id: { type: "string", description: "Optional lowercase session id." },
720
+ readOnly: {
721
+ type: "boolean",
722
+ description: "Create a read-only session: the relay rejects input-dispatching CDP so scripts can inspect but not click or type."
723
+ },
724
+ allowedOrigins: {
725
+ type: "array",
726
+ items: { type: "string" },
727
+ 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."
728
+ }
729
+ }),
730
+ readOnly: false,
731
+ destructive: false,
732
+ idempotent: false,
733
+ handle: (input) => Effect6.gen(function* () {
734
+ const requestedId = optionalStringField(input, "id");
735
+ const readOnly = optionalBooleanField(input, "readOnly");
736
+ const allowedOrigins = optionalStringArrayField(input, "allowedOrigins");
737
+ const browser = optionalStringField(input, "browser");
738
+ const session = yield* ctx.relay.sessionNew(requestedId, {
739
+ ...readOnly ? { readOnly: true } : {},
740
+ ...allowedOrigins ? { allowedOrigins } : {},
741
+ ...browser ? { browser } : {}
742
+ });
743
+ ctx.establishCurrentSession(session.id);
744
+ return { session };
745
+ })
621
746
  },
622
747
  {
623
- name: "skill",
624
- description: "Return the Browse agent skill instructions.",
748
+ name: "session_allowlist",
749
+ 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.",
750
+ inputSchema: objectSchema({
751
+ session: { type: "string", description: "Session id; defaults to the current session." },
752
+ allowedOrigins: {
753
+ type: "array",
754
+ items: { type: "string" },
755
+ description: "Patterns: example.com, *.example.com, https://app.example.com, http://localhost:3000. Empty removes the allowlist."
756
+ }
757
+ }, ["allowedOrigins"]),
758
+ readOnly: false,
759
+ destructive: false,
760
+ idempotent: true,
761
+ handle: (input) => Effect6.gen(function* () {
762
+ const sessionId = ctx.resolveSessionId(input);
763
+ const allowedOrigins = requireObject(input).allowedOrigins;
764
+ if (!Array.isArray(allowedOrigins) || !allowedOrigins.every((item) => Predicate.isString(item))) {
765
+ return yield* Effect6.fail(new Error("allowedOrigins must be an array of strings"));
766
+ }
767
+ return { session: yield* ctx.relay.sessionAllowlist(sessionId, allowedOrigins) };
768
+ })
769
+ },
770
+ {
771
+ name: "session_list",
772
+ requiresRelayCompatibility: false,
773
+ description: "List Browse sessions.",
774
+ inputSchema: emptyInputSchema,
775
+ readOnly: true,
776
+ destructive: false,
777
+ idempotent: false,
778
+ handle: () => ctx.relay.sessions.pipe(Effect6.map((sessions) => ({ sessions })))
779
+ },
780
+ {
781
+ name: "session_current",
782
+ requiresRelayCompatibility: false,
783
+ description: "Return this MCP server's current Browse session id.",
625
784
  inputSchema: emptyInputSchema,
626
785
  readOnly: true,
627
786
  destructive: false,
628
787
  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 })
788
+ handle: () => Effect6.succeed({ currentSession: ctx.currentSession.id })
789
+ },
790
+ {
791
+ name: "session_use",
792
+ description: "Set this MCP server's current Browse session id.",
793
+ inputSchema: objectSchema({
794
+ id: { type: "string", description: "Existing Browse session id." }
795
+ }, ["id"]),
796
+ readOnly: false,
797
+ destructive: false,
798
+ idempotent: true,
799
+ handle: (input) => Effect6.gen(function* () {
800
+ const id = yield* Effect6.try(() => requiredStringField(input, "id"));
801
+ yield* ensureSessionExists(ctx.relay, id);
802
+ ctx.establishCurrentSession(id);
803
+ return { currentSession: ctx.currentSession.id };
804
+ })
805
+ },
806
+ {
807
+ name: "session_reset",
808
+ description: "Reset a Browse session's state and page.",
809
+ inputSchema: objectSchema({
810
+ id: sessionSchemaProperty,
811
+ session: sessionAliasSchemaProperty
812
+ }),
813
+ readOnly: false,
814
+ destructive: true,
815
+ idempotent: false,
816
+ handle: (input) => Effect6.gen(function* () {
817
+ const id = ctx.resolveSessionId(input, "id");
818
+ const reset = yield* ctx.relay.sessionReset(id);
819
+ ctx.establishCurrentSession(id);
820
+ return reset;
821
+ })
822
+ },
823
+ {
824
+ name: "session_delete",
825
+ description: "Delete a Browse session.",
826
+ inputSchema: objectSchema({
827
+ id: sessionSchemaProperty,
828
+ session: sessionAliasSchemaProperty
829
+ }),
830
+ readOnly: false,
831
+ destructive: true,
832
+ idempotent: true,
833
+ handle: (input) => Effect6.gen(function* () {
834
+ const id = ctx.resolveSessionId(input, "id");
835
+ const result = yield* ctx.relay.sessionDelete(id);
836
+ if (ctx.currentSession.id === id) {
837
+ ctx.currentSession.id = `mcp-${crypto.randomUUID().slice(0, 8)}`;
838
+ ctx.currentSession.established = false;
839
+ }
840
+ return { ...result, currentSession: ctx.currentSession.id };
841
+ })
842
+ },
843
+ {
844
+ name: "session_adopt",
845
+ 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.",
846
+ inputSchema: objectSchema({
847
+ session: {
848
+ type: "string",
849
+ 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."
850
+ },
851
+ targetUrl: {
852
+ type: "string",
853
+ 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."
854
+ },
855
+ targetIndex: {
856
+ type: "integer",
857
+ minimum: 0,
858
+ description: "Adopt the attached page at this zero-based target index."
859
+ },
860
+ tabId: {
861
+ type: "integer",
862
+ description: "Adopt the user's tab with this Chrome tab id (the tab= value in status)."
863
+ }
864
+ }),
865
+ readOnly: false,
866
+ destructive: false,
867
+ idempotent: false,
868
+ handle: (input) => Effect6.gen(function* () {
869
+ const args = yield* Effect6.try(() => parseAdoptArguments(input));
870
+ const sessionId = args.session ?? ctx.currentSession.id;
871
+ const result = yield* ctx.relay.sessionAdopt({
872
+ sessionId,
873
+ createIfMissing: !args.session,
874
+ ...args.targetSelection ? { targetSelection: args.targetSelection } : {},
875
+ ...args.tabId === undefined ? {} : { tabId: args.tabId }
876
+ });
877
+ ctx.establishCurrentSession(sessionId);
878
+ return {
879
+ ...result,
880
+ confirmation: `Adopted session '${result.session.id}' default page: ${result.adoptedUrl}`
881
+ };
632
882
  })
633
883
  }
634
- ];
884
+ ]
885
+ });
886
+
887
+ // src/mcp.ts
888
+ var packageRoot = path5.dirname(path5.dirname(fileURLToPath(import.meta.url)));
889
+ function shimSession() {
890
+ const value = process.env.OPENCODE_BROWSER_MCP_SESSION;
891
+ return value && /^mcp-[0-9a-f]{8}$/.test(value) ? value : undefined;
635
892
  }
636
- var registerTools = Effect.gen(function* () {
893
+ var registerTools = Effect7.gen(function* () {
637
894
  const server = yield* McpServer.McpServer;
638
895
  const relay = yield* Service;
639
896
  const configuredSession = Option.getOrUndefined(yield* Config.option(Config.String("OPENCODE_BROWSER_SESSION")));
@@ -654,7 +911,7 @@ var registerTools = Effect.gen(function* () {
654
911
  return result;
655
912
  return new McpSchema.CallToolResult({
656
913
  ...result,
657
- content: [...result.content, McpSchema.TextContent.make({ text: mcpUpdateNote(notice) })]
914
+ content: [...result.content, McpSchema.TextContent.make({ text: mcpUpdateNote2(notice) })]
658
915
  });
659
916
  };
660
917
  const register = (spec) => server.addTool({
@@ -671,37 +928,33 @@ var registerTools = Effect.gen(function* () {
671
928
  }),
672
929
  annotations: Context.empty(),
673
930
  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)));
931
+ const operation = spec.requiresRelayCompatibility !== false ? ensureRelay({ relay }).pipe(Effect7.flatMap((readiness) => readiness.buildProblem ? Effect7.fail(new Error(readiness.buildProblem)) : spec.handle(payload))) : spec.handle(payload);
932
+ return operation.pipe(Effect7.match({
933
+ onFailure: (error) => toolResult({ text: mcpErrorMessage2(spec.name, error.message), isError: true }),
934
+ onSuccess: (value) => toolResultForValue2(value)
935
+ }), Effect7.map((result) => withUpdateNote(spec.name, result)));
679
936
  }
680
937
  });
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* () {
938
+ const connectedBrowsers = relay.extensionStatus.pipe(Effect7.map((status) => (status.browsers ?? []).map(({ id, name, default: isDefault }) => ({ id, name, default: isDefault }))), Effect7.orElseSucceed(() => []));
939
+ let browsers = yield* connectedBrowsers.pipe(Effect7.timeoutOption(1000), Effect7.map(Option.getOrElse(() => [])));
940
+ const buildSpecs = (currentBrowsers) => buildRegisteredMcpTools(createMcpToolContext({
941
+ relay,
942
+ currentSession,
943
+ packageRoot,
944
+ update: () => updates.current,
945
+ browsers: currentBrowsers
946
+ }));
947
+ yield* Effect7.forEach(buildSpecs(browsers), register, { discard: true });
948
+ const refreshBrowsers = Effect7.gen(function* () {
685
949
  const next = yield* connectedBrowsers;
686
- if (browsersNote(next) === browsersNote(browsers))
950
+ if (browsersNote2(next) === browsersNote2(browsers))
687
951
  return;
688
952
  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 });
953
+ const specs = buildSpecs(browsers).filter((spec) => spec.listsBrowsers === true);
954
+ yield* Effect7.forEach(specs, register, { discard: true });
691
955
  });
692
- yield* refreshBrowsers.pipe(Effect.repeat(Schedule.spaced("5 seconds")), Effect.forkDetach);
956
+ yield* refreshBrowsers.pipe(Effect7.repeat(Schedule.spaced("5 seconds")), Effect7.forkDetach);
693
957
  });
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
958
  var mcpServerLayer = McpServer.layerStdio({
706
959
  name: "browse",
707
960
  version: opencodeBrowserVersion2,
@@ -709,162 +962,12 @@ var mcpServerLayer = McpServer.layerStdio({
709
962
  });
710
963
  var mcpToolsLayer = Layer.effectDiscard(registerTools);
711
964
  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
965
  export {
862
- browsersNote,
863
- mcpErrorMessage,
966
+ browsersNote2 as browsersNote,
967
+ mcpErrorMessage2 as mcpErrorMessage,
864
968
  mcpServerLayer,
865
- mcpToolRequiresRelayCompatibility,
866
969
  mcpToolsLayer,
867
- mcpUpdateNote,
970
+ mcpUpdateNote2 as mcpUpdateNote,
868
971
  runMcpServer,
869
- toolResultForValue
972
+ toolResultForValue2 as toolResultForValue
870
973
  };