@webmcp-bridge/local-mcp 0.5.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.js CHANGED
@@ -1,23 +1,13 @@
1
1
  /**
2
- * This module composes runtime startup with the stdio MCP server into one lifecycle handle.
3
- * It depends on site resolution, runtime startup, and session-control helpers so local-mcp can keep one MCP process alive across runtime, bootstrap, and attach transitions.
2
+ * This module composes site/runtime startup with the stdio MCP server into one lifecycle handle.
3
+ * It depends on agent-browser-core orchestration, site resolution, and server wiring so local-mcp stays a thin MCP facade over one browser session.
4
4
  */
5
+ import { assertAuthSensitiveBrowserSupport, resolveAuthPolicy, startBrowserSessionController, } from "@webmcp-bridge/agent-browser-core";
5
6
  import { createLocalMcpStdioServer, } from "./server.js";
6
7
  import { resolveCdpConnectUrl, startLocalMcpRuntime, } from "./runtime.js";
7
- import { assertAuthSensitiveBrowserSupport, backupAndResetProfile, describeSessionStateFromAuth, ensureManagedProfile, findBrowserProcessForProfile, focusBrowserWindow, isProcessRunning, launchBootstrapBrowser, launchManagedAttachBrowser, readSessionMetadata, resolveAuthPolicy, stopBrowserProcess, stopManagedBrowser, updateSessionMetadata, waitForProcessExit, } from "./session.js";
8
+ import { evaluateDebugScript, evaluateOverlayTool, OverlayStore, } from "./overlays.js";
9
+ import { resolveDefaultUserDataDir } from "./profiles.js";
8
10
  import { createNativeSiteDefinition, resolveSiteSource, } from "./sites.js";
9
- const BOOTSTRAP_BROWSER_CLOSE_TIMEOUT_MS = 5_000;
10
- const BOOTSTRAP_PROFILE_RELEASE_DELAY_MS = 500;
11
- function readAuthState(value) {
12
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
13
- return "unknown";
14
- }
15
- const state = value.state;
16
- if (state === "authenticated" || state === "auth_required" || state === "challenge_required") {
17
- return state;
18
- }
19
- return "unknown";
20
- }
21
11
  async function resolveSiteDefinitionFromBridgeOptions(options) {
22
12
  const hasAdapterSource = Boolean(options.site || options.adapterModule);
23
13
  if (hasAdapterSource) {
@@ -41,7 +31,8 @@ async function resolveSiteDefinitionFromBridgeOptions(options) {
41
31
  async function startRuntime(options) {
42
32
  return await startLocalMcpRuntime(options);
43
33
  }
44
- function buildRuntimeStartOptions(baseOptions, siteDefinition, controlMode, preferredPresentationMode, browserUrl) {
34
+ function buildRuntimeStartOptions(baseOptions, siteDefinition, userDataDir, controlMode, preferredPresentationMode, browserUrl) {
35
+ const configuredBrowserUrl = baseOptions.browserUrl?.trim() || undefined;
45
36
  const nextOptions = {
46
37
  siteDefinition,
47
38
  preferredPresentationMode,
@@ -52,8 +43,8 @@ function buildRuntimeStartOptions(baseOptions, siteDefinition, controlMode, pref
52
43
  if (baseOptions.browser !== undefined) {
53
44
  nextOptions.browser = baseOptions.browser;
54
45
  }
55
- if (baseOptions.userDataDir !== undefined) {
56
- nextOptions.userDataDir = baseOptions.userDataDir;
46
+ if (userDataDir !== undefined) {
47
+ nextOptions.userDataDir = userDataDir;
57
48
  }
58
49
  if (baseOptions.preferNative !== undefined) {
59
50
  nextOptions.preferNative = baseOptions.preferNative;
@@ -65,7 +56,7 @@ function buildRuntimeStartOptions(baseOptions, siteDefinition, controlMode, pref
65
56
  if (!browserUrl) {
66
57
  throw new Error("CONFIG_ERROR: attach mode requires a browserUrl");
67
58
  }
68
- const explicitAttach = baseOptions.browserUrl !== undefined;
59
+ const explicitAttach = configuredBrowserUrl !== undefined;
69
60
  if (explicitAttach && baseOptions.browserChannel !== undefined) {
70
61
  throw new Error("CONFIG_ERROR: --browser-url cannot be combined with --browser-channel");
71
62
  }
@@ -84,6 +75,9 @@ function buildRuntimeStartOptions(baseOptions, siteDefinition, controlMode, pref
84
75
  }
85
76
  return nextOptions;
86
77
  }
78
+ function toLocalBridgeState(status) {
79
+ return status;
80
+ }
87
81
  export async function startLocalMcpBridge(options) {
88
82
  const siteDefinition = await resolveSiteDefinitionFromBridgeOptions(options);
89
83
  const authPolicy = resolveAuthPolicy(siteDefinition.manifest);
@@ -91,625 +85,194 @@ export async function startLocalMcpBridge(options) {
91
85
  if (!targetUrl) {
92
86
  throw new Error("CONFIG_ERROR: no target url provided (missing --url and manifest.defaultUrl)");
93
87
  }
88
+ const configuredBrowserUrl = options.browserUrl?.trim() || undefined;
89
+ const managedUserDataDir = configuredBrowserUrl === undefined
90
+ ? (options.userDataDir ?? resolveDefaultUserDataDir(siteDefinition, targetUrl))
91
+ : undefined;
94
92
  if (authPolicy.mode === "bootstrap_then_attach") {
95
- assertAuthSensitiveBrowserSupport(options.browser, options.userDataDir);
93
+ assertAuthSensitiveBrowserSupport(options.browser, managedUserDataDir);
96
94
  }
97
- let runtime;
98
- let runtimeMode = "control-only";
99
- let controlMode = "none";
100
- let ownership = "none";
101
- let authState = "unknown";
102
- let sessionState = authPolicy.mode === "bootstrap_then_attach" ? "profile_missing" : "runtime_active";
103
- let browserUrl = options.browserUrl;
104
- let browserPid;
105
- const configuredPreferredPresentationMode = options.preferredPresentationMode;
106
- let preferredPresentationMode = options.preferredPresentationMode ?? "headed";
107
- let presentationMode = preferredPresentationMode;
108
- let lastBackupPath;
109
- let metadata;
95
+ const overlayStore = new OverlayStore(siteDefinition.id, managedUserDataDir);
96
+ await overlayStore.load();
110
97
  let server;
111
- let closed = false;
112
- let lifecycleTransition = Promise.resolve();
113
- const resourceUpdatedListeners = new Set();
114
- let unsubscribeRuntimeResourceUpdates;
115
- let ownerSessionGeneration = 0;
116
- const profilePath = options.userDataDir;
117
- const metadataFallback = profilePath
118
- ? {
119
- site: siteDefinition.id,
120
- targetUrl,
121
- authPolicy,
122
- }
123
- : undefined;
124
- const refreshStatus = () => {
125
- const state = {
126
- site: siteDefinition.id,
127
- targetUrl,
128
- controlMode,
129
- ...(browserUrl !== undefined ? { browserUrl } : {}),
130
- mode: runtimeMode,
131
- presentationMode,
132
- preferredPresentationMode,
133
- authPolicyMode: authPolicy.mode,
134
- authState,
135
- sessionState,
136
- ownership,
137
- ...(profilePath !== undefined ? { profilePath } : {}),
138
- ...(browserPid !== undefined ? { browserPid } : {}),
139
- ...(lastBackupPath !== undefined ? { lastBackupPath } : {}),
140
- };
141
- return state;
142
- };
143
- const syncFromMetadata = (nextMetadata) => {
144
- metadata = nextMetadata;
145
- controlMode = nextMetadata.controlMode;
146
- browserUrl = nextMetadata.browserUrl;
147
- browserPid = nextMetadata.browserPid;
148
- authState = nextMetadata.authState;
149
- sessionState = nextMetadata.sessionState;
150
- ownership = nextMetadata.ownership;
151
- presentationMode = nextMetadata.presentationMode;
152
- preferredPresentationMode = configuredPreferredPresentationMode ?? nextMetadata.preferredPresentationMode;
153
- if (nextMetadata.lastBackupPath !== undefined) {
154
- lastBackupPath = nextMetadata.lastBackupPath;
155
- }
156
- if (runtime === undefined) {
157
- runtimeMode = "control-only";
158
- presentationMode = nextMetadata.controlMode === "bootstrap" ? "headed" : nextMetadata.presentationMode;
159
- }
160
- };
161
- const writeMetadata = async (patch) => {
162
- if (!profilePath || !metadataFallback) {
163
- return;
164
- }
165
- const nextMetadata = await updateSessionMetadata(profilePath, metadataFallback, patch);
166
- syncFromMetadata(nextMetadata);
167
- };
168
- const hasRunningBootstrapBrowser = async (sourceMetadata) => {
169
- const activeMetadata = sourceMetadata ?? metadata;
170
- if (!activeMetadata) {
171
- return false;
172
- }
173
- if (authPolicy.mode !== "bootstrap_then_attach" ||
174
- activeMetadata.controlMode !== "bootstrap" ||
175
- activeMetadata.ownership !== "external") {
176
- return false;
177
- }
178
- if (await isProcessRunning(activeMetadata.browserPid)) {
179
- return true;
180
- }
181
- if (!profilePath) {
182
- return false;
183
- }
184
- const discoveredPid = await findBrowserProcessForProfile(profilePath);
185
- if (!discoveredPid) {
186
- return false;
187
- }
188
- if (discoveredPid !== activeMetadata.browserPid) {
189
- await writeMetadata({
190
- browserPid: discoveredPid,
191
- });
192
- }
193
- return true;
194
- };
195
- const bindRuntime = (nextRuntime, nextOwnership) => {
196
- runtime = nextRuntime;
197
- runtimeMode = nextRuntime.mode;
198
- controlMode = nextRuntime.controlMode;
199
- presentationMode = nextRuntime.presentationMode;
200
- ownership = nextOwnership;
201
- unsubscribeRuntimeResourceUpdates?.();
202
- unsubscribeRuntimeResourceUpdates = nextRuntime.gateway.onResourceUpdated((uri) => {
203
- for (const listener of resourceUpdatedListeners) {
204
- listener(uri);
205
- }
206
- });
207
- ownerSessionGeneration += 1;
208
- const generation = ownerSessionGeneration;
209
- void nextRuntime.ownerSessionEnded.then(() => {
210
- if (closed || generation !== ownerSessionGeneration) {
211
- return;
98
+ let closeRequested = false;
99
+ let closeResources = async () => { };
100
+ const toolsetListeners = new Set();
101
+ const notifyToolsetMayHaveChanged = () => {
102
+ for (const listener of toolsetListeners) {
103
+ try {
104
+ listener();
212
105
  }
213
- void closeResources().catch((error) => {
106
+ catch (error) {
214
107
  options.onError?.(error);
215
- });
216
- });
217
- };
218
- const clearRuntime = () => {
219
- runtime = undefined;
220
- runtimeMode = "control-only";
221
- unsubscribeRuntimeResourceUpdates?.();
222
- unsubscribeRuntimeResourceUpdates = undefined;
223
- };
224
- const closeRuntime = async () => {
225
- if (!runtime) {
226
- return;
108
+ }
227
109
  }
228
- const activeRuntime = runtime;
229
- clearRuntime();
230
- await activeRuntime.close();
231
110
  };
232
- const probeRuntimeAuthState = async (activeRuntime) => {
233
- if (!authPolicy.authProbeTool) {
234
- return "authenticated";
235
- }
236
- try {
237
- const result = await activeRuntime.gateway.callTool(authPolicy.authProbeTool, {});
238
- return readAuthState(result);
239
- }
240
- catch {
241
- return "unknown";
111
+ const requestClose = () => {
112
+ closeRequested = true;
113
+ if (server === undefined) {
114
+ return;
242
115
  }
116
+ void closeResources().catch((error) => {
117
+ options.onError?.(error);
118
+ });
243
119
  };
244
- const closeResourcesInternal = async () => {
120
+ const controller = await startBrowserSessionController({
121
+ site: siteDefinition.id,
122
+ targetUrl,
123
+ authPolicy,
124
+ ...(managedUserDataDir !== undefined ? { profilePath: managedUserDataDir } : {}),
125
+ ...(options.browserChannel !== undefined ? { browserChannel: options.browserChannel } : {}),
126
+ ...(options.browserUrl !== undefined ? { browserUrl: options.browserUrl } : {}),
127
+ ...(options.preferredPresentationMode !== undefined
128
+ ? { preferredPresentationMode: options.preferredPresentationMode }
129
+ : {}),
130
+ runtimeFactory: async ({ controlMode, preferredPresentationMode, browserUrl }) => await startRuntime(buildRuntimeStartOptions(options, siteDefinition, managedUserDataDir, controlMode, preferredPresentationMode, browserUrl)),
131
+ browserUrlHealthCheck: async (browserUrl) => {
132
+ await resolveCdpConnectUrl(browserUrl);
133
+ },
134
+ onCloseRequested: requestClose,
135
+ ...(options.onError !== undefined ? { onError: options.onError } : {}),
136
+ });
137
+ const unsubscribeControllerToolset = controller.onToolsetMayHaveChanged(() => {
138
+ notifyToolsetMayHaveChanged();
139
+ });
140
+ let closed = false;
141
+ let lastState = toLocalBridgeState(controller.getState());
142
+ closeResources = async () => {
245
143
  if (closed) {
246
144
  return;
247
145
  }
248
146
  closed = true;
249
- unsubscribeRuntimeResourceUpdates?.();
250
- const activeMetadata = metadata;
251
- const activeRuntime = runtime;
252
- clearRuntime();
253
- const results = await Promise.allSettled([
254
- server?.close(),
255
- activeRuntime?.close(),
256
- activeMetadata?.ownership === "managed" ? stopManagedBrowser(activeMetadata) : undefined,
257
- ]);
147
+ lastState = toLocalBridgeState(controller.getState());
148
+ unsubscribeControllerToolset();
149
+ const activeServer = server;
150
+ server = undefined;
151
+ const results = await Promise.allSettled([activeServer?.close(), controller.close()]);
258
152
  const firstFailure = results.find((result) => result.status === "rejected");
259
153
  if (firstFailure) {
260
154
  throw firstFailure.reason;
261
155
  }
262
156
  };
263
- const runLifecycleTransition = async (operation) => {
264
- const previousTransition = lifecycleTransition;
265
- let releaseTransition;
266
- lifecycleTransition = new Promise((resolve) => {
267
- releaseTransition = resolve;
268
- });
269
- await previousTransition.catch(() => {
270
- // Ignore previous transition failures so later lifecycle operations can still proceed.
271
- });
272
- try {
273
- return await operation();
274
- }
275
- finally {
276
- releaseTransition();
277
- }
278
- };
279
- const closeResources = async () => {
280
- await runLifecycleTransition(async () => {
281
- await closeResourcesInternal();
282
- });
283
- };
284
- const bootstrapSessionInternal = async (nextAuthState = "unknown") => {
285
- if (authPolicy.mode !== "bootstrap_then_attach" || !profilePath) {
286
- throw new Error("UNSUPPORTED_SESSION_CONTROL: bootstrap is available only for auth-sensitive managed sessions");
287
- }
288
- await closeRuntime();
289
- if (await hasRunningBootstrapBrowser()) {
290
- return refreshStatus();
291
- }
292
- if (metadata?.ownership === "managed") {
293
- await stopManagedBrowser(metadata);
294
- }
295
- await ensureManagedProfile(profilePath);
296
- const bootstrapOptions = {
297
- targetUrl,
298
- userDataDir: profilePath,
299
- };
300
- if (options.browserChannel !== undefined) {
301
- bootstrapOptions.browserChannel = options.browserChannel;
302
- }
303
- const launchResult = await launchBootstrapBrowser(bootstrapOptions);
304
- const bootstrapPatch = {
305
- presentationMode: "headed",
306
- preferredPresentationMode,
307
- sessionState: nextAuthState === "unknown" ? "bootstrap_active" : describeSessionStateFromAuth(nextAuthState),
308
- authState: nextAuthState,
309
- controlMode: "bootstrap",
310
- ownership: "external",
311
- browserUrl: null,
312
- browserPid: null,
313
- };
314
- if (launchResult.pid !== undefined) {
315
- bootstrapPatch.browserPid = launchResult.pid;
316
- }
317
- await writeMetadata(bootstrapPatch);
318
- return refreshStatus();
319
- };
320
- const adoptManagedAttachBrowserAsBootstrap = async (nextAuthState, nextBrowserPid) => {
321
- await closeRuntime();
322
- const bootstrapPatch = {
323
- presentationMode: "headed",
324
- preferredPresentationMode: "headed",
325
- sessionState: describeSessionStateFromAuth(nextAuthState),
326
- authState: nextAuthState,
327
- controlMode: "bootstrap",
328
- ownership: "external",
329
- browserUrl: null,
330
- browserPid: null,
331
- };
332
- if (nextBrowserPid !== undefined) {
333
- bootstrapPatch.browserPid = nextBrowserPid;
334
- }
335
- preferredPresentationMode = "headed";
336
- await writeMetadata(bootstrapPatch);
337
- return refreshStatus();
338
- };
339
- const activateRuntime = async (nextRuntime, nextOwnership, nextBrowserUrl, nextBrowserPid) => {
340
- const nextAuthState = await probeRuntimeAuthState(nextRuntime);
341
- bindRuntime(nextRuntime, nextOwnership);
342
- browserUrl = nextBrowserUrl;
343
- browserPid = nextBrowserPid;
344
- authState = nextAuthState;
345
- sessionState = "runtime_active";
346
- presentationMode = nextRuntime.presentationMode;
347
- if (profilePath && metadataFallback) {
348
- const runtimePatch = {
349
- presentationMode: nextRuntime.presentationMode,
350
- preferredPresentationMode,
351
- sessionState: "runtime_active",
352
- authState: nextAuthState,
353
- controlMode: nextRuntime.controlMode,
354
- ownership: nextOwnership,
355
- };
356
- if (nextBrowserUrl !== undefined) {
357
- runtimePatch.browserUrl = nextBrowserUrl;
358
- }
359
- else {
360
- runtimePatch.browserUrl = null;
361
- }
362
- if (nextBrowserPid !== undefined) {
363
- runtimePatch.browserPid = nextBrowserPid;
364
- }
365
- else {
366
- runtimePatch.browserPid = null;
367
- }
368
- await writeMetadata(runtimePatch);
369
- }
370
- return refreshStatus();
371
- };
372
- const attachSessionInternal = async (requestedBrowserUrl, requestedPresentationMode = preferredPresentationMode) => {
373
- const explicitBrowserUrl = requestedBrowserUrl?.trim() || options.browserUrl?.trim();
374
- const relaunchManagedAttachBrowser = !explicitBrowserUrl &&
375
- controlMode === "attach" &&
376
- ownership === "managed" &&
377
- requestedPresentationMode !== presentationMode;
378
- const activeBrowserUrl = explicitBrowserUrl || (relaunchManagedAttachBrowser ? undefined : browserUrl);
379
- const nextOwnership = explicitBrowserUrl ? "external" : "managed";
380
- if (runtime) {
381
- await closeRuntime();
382
- }
383
- if (relaunchManagedAttachBrowser) {
384
- const managedBrowserPid = browserPid ?? (profilePath ? await findBrowserProcessForProfile(profilePath) : undefined);
385
- if (profilePath && metadataFallback) {
386
- await updateSessionMetadata(profilePath, metadataFallback, {
387
- controlMode: "none",
388
- ownership: "none",
389
- browserUrl: null,
390
- browserPid: null,
391
- });
392
- }
393
- await stopBrowserProcess(managedBrowserPid);
394
- if (managedBrowserPid) {
395
- const didExit = await waitForProcessExit(managedBrowserPid, BOOTSTRAP_BROWSER_CLOSE_TIMEOUT_MS);
396
- if (!didExit) {
397
- throw new Error(`BROWSER_CLOSE_TIMEOUT: timed out waiting for managed browser ${String(managedBrowserPid)} to exit`);
398
- }
399
- await new Promise((resolve) => setTimeout(resolve, BOOTSTRAP_PROFILE_RELEASE_DELAY_MS));
400
- }
401
- browserUrl = undefined;
402
- browserPid = undefined;
403
- }
404
- let managedAttachPid;
405
- let attachBrowserUrl = activeBrowserUrl;
406
- if (!explicitBrowserUrl && attachBrowserUrl && ownership === "managed") {
407
- const managedBrowserPid = browserPid ?? (profilePath ? await findBrowserProcessForProfile(profilePath) : undefined);
408
- const managedBrowserRunning = await isProcessRunning(managedBrowserPid);
409
- let managedBrowserUrlHealthy = managedBrowserRunning;
410
- if (managedBrowserRunning) {
411
- try {
412
- await resolveCdpConnectUrl(attachBrowserUrl);
413
- }
414
- catch {
415
- managedBrowserUrlHealthy = false;
416
- }
417
- }
418
- if (!managedBrowserRunning || !managedBrowserUrlHealthy) {
419
- if (managedBrowserRunning) {
420
- await stopBrowserProcess(managedBrowserPid);
421
- if (managedBrowserPid) {
422
- const didExit = await waitForProcessExit(managedBrowserPid, BOOTSTRAP_BROWSER_CLOSE_TIMEOUT_MS);
423
- if (!didExit) {
424
- throw new Error(`BROWSER_CLOSE_TIMEOUT: timed out waiting for stale managed browser ${String(managedBrowserPid)} to exit`);
425
- }
426
- await new Promise((resolve) => setTimeout(resolve, BOOTSTRAP_PROFILE_RELEASE_DELAY_MS));
427
- }
428
- }
429
- if (profilePath && metadataFallback) {
430
- await updateSessionMetadata(profilePath, metadataFallback, {
431
- controlMode: "none",
432
- ownership: "none",
433
- browserUrl: null,
434
- browserPid: null,
435
- });
436
- }
437
- attachBrowserUrl = undefined;
438
- browserUrl = undefined;
439
- browserPid = undefined;
440
- }
441
- }
442
- if (!attachBrowserUrl) {
443
- if (authPolicy.mode !== "bootstrap_then_attach" || !profilePath) {
444
- throw new Error("CONFIG_ERROR: bridge.session.attach requires browserUrl when no managed attach session exists");
445
- }
446
- if (!explicitBrowserUrl && (await hasRunningBootstrapBrowser())) {
447
- const bootstrapPid = metadata?.browserPid ?? (profilePath ? await findBrowserProcessForProfile(profilePath) : undefined);
448
- await stopBrowserProcess(bootstrapPid);
449
- const didExit = await waitForProcessExit(bootstrapPid, BOOTSTRAP_BROWSER_CLOSE_TIMEOUT_MS);
450
- if (!didExit) {
451
- throw new Error(`BOOTSTRAP_BROWSER_CLOSE_TIMEOUT: timed out waiting for bootstrap browser ${String(bootstrapPid)} to exit`);
452
- }
453
- await writeMetadata({
454
- controlMode: "none",
455
- ownership: "none",
456
- browserUrl: null,
457
- browserPid: null,
458
- });
459
- await new Promise((resolve) => setTimeout(resolve, BOOTSTRAP_PROFILE_RELEASE_DELAY_MS));
460
- }
461
- const existingProfileBrowserPid = await findBrowserProcessForProfile(profilePath);
462
- if (existingProfileBrowserPid) {
463
- await stopBrowserProcess(existingProfileBrowserPid);
464
- const didExit = await waitForProcessExit(existingProfileBrowserPid, BOOTSTRAP_BROWSER_CLOSE_TIMEOUT_MS);
465
- if (!didExit) {
466
- throw new Error(`BROWSER_CLOSE_TIMEOUT: timed out waiting for existing browser ${String(existingProfileBrowserPid)} to exit`);
467
- }
468
- await new Promise((resolve) => setTimeout(resolve, BOOTSTRAP_PROFILE_RELEASE_DELAY_MS));
469
- }
470
- await ensureManagedProfile(profilePath);
471
- const attachOptions = {
472
- targetUrl,
473
- userDataDir: profilePath,
474
- presentationMode: requestedPresentationMode,
475
- };
476
- if (options.browserChannel !== undefined) {
477
- attachOptions.browserChannel = options.browserChannel;
478
- }
479
- const managedAttach = await launchManagedAttachBrowser(attachOptions);
480
- attachBrowserUrl = managedAttach.browserUrl;
481
- managedAttachPid = managedAttach.pid;
482
- }
483
- try {
484
- const nextRuntime = await startRuntime(buildRuntimeStartOptions(options, siteDefinition, "attach", requestedPresentationMode, attachBrowserUrl));
485
- const nextAuthState = await probeRuntimeAuthState(nextRuntime);
486
- if (authPolicy.mode === "bootstrap_then_attach" &&
487
- !explicitBrowserUrl &&
488
- (nextAuthState === "auth_required" || nextAuthState === "challenge_required")) {
489
- if (managedAttachPid && requestedPresentationMode === "headed") {
490
- bindRuntime(nextRuntime, "managed");
491
- return await adoptManagedAttachBrowserAsBootstrap(nextAuthState, managedAttachPid);
492
- }
493
- await nextRuntime.close();
494
- return await bootstrapSessionInternal(nextAuthState);
495
- }
496
- preferredPresentationMode = requestedPresentationMode;
497
- return await activateRuntime(nextRuntime, nextOwnership, attachBrowserUrl, managedAttachPid ?? browserPid);
498
- }
499
- catch (error) {
500
- if (managedAttachPid && profilePath && metadataFallback) {
501
- const cleanupMetadata = await updateSessionMetadata(profilePath, metadataFallback, {
502
- controlMode: "none",
503
- ownership: "none",
504
- browserUrl: null,
505
- browserPid: null,
506
- });
507
- await stopManagedBrowser({
508
- ...cleanupMetadata,
509
- ownership: "managed",
510
- browserPid: managedAttachPid,
511
- });
512
- }
513
- await closeResourcesInternal().catch(options.onError);
514
- throw error;
515
- }
516
- };
517
- const setPresentationModeInternal = async (setModeOptions) => {
518
- if (closed) {
519
- throw new Error("SESSION_NOT_AVAILABLE: local-mcp bridge session is closed");
520
- }
521
- const requestedPresentationMode = setModeOptions.presentationMode;
522
- const previousRuntime = runtime;
523
- const previousState = refreshStatus();
524
- const previousPreferredPresentationMode = preferredPresentationMode;
525
- if (controlMode === "bootstrap") {
526
- throw new Error("UNSUPPORTED_SESSION_CONTROL: bridge.session.mode.set is unavailable while the bridge is in bootstrap mode");
527
- }
528
- if (ownership === "external") {
529
- throw new Error("UNSUPPORTED_SESSION_CONTROL: bridge.session.mode.set is unavailable for external attach sessions");
530
- }
531
- if (requestedPresentationMode === presentationMode) {
532
- preferredPresentationMode = requestedPresentationMode;
533
- if (profilePath && metadataFallback) {
534
- await writeMetadata({
535
- presentationMode,
536
- preferredPresentationMode,
537
- });
538
- }
539
- return refreshStatus();
540
- }
541
- if (controlMode === "attach") {
542
- preferredPresentationMode = requestedPresentationMode;
543
- return await attachSessionInternal(undefined, requestedPresentationMode);
544
- }
545
- await closeRuntime();
546
- preferredPresentationMode = requestedPresentationMode;
547
- try {
548
- const nextRuntime = await startRuntime(buildRuntimeStartOptions(options, siteDefinition, "launch", requestedPresentationMode));
549
- return await activateRuntime(nextRuntime, "managed");
550
- }
551
- catch (error) {
552
- preferredPresentationMode = previousPreferredPresentationMode;
553
- if (previousRuntime) {
554
- try {
555
- const recoveredRuntime = await startRuntime(buildRuntimeStartOptions(options, siteDefinition, "launch", previousState.presentationMode));
556
- await activateRuntime(recoveredRuntime, previousState.ownership);
557
- }
558
- catch (recoveryError) {
559
- options.onError?.(recoveryError);
560
- await closeResourcesInternal().catch(options.onError);
561
- }
562
- }
563
- else {
564
- await closeResourcesInternal().catch(options.onError);
565
- }
566
- throw error;
567
- }
568
- };
569
- const setPresentationMode = async (setModeOptions) => {
570
- return await runLifecycleTransition(async () => await setPresentationModeInternal(setModeOptions));
571
- };
572
- const resetProfileInternal = async () => {
573
- if (!profilePath || !metadataFallback) {
574
- throw new Error("UNSUPPORTED_SESSION_CONTROL: reset_profile requires a managed --user-data-dir");
575
- }
576
- await closeRuntime();
577
- if (metadata?.ownership === "managed") {
578
- await stopManagedBrowser(metadata);
579
- }
580
- const resetResult = await backupAndResetProfile(profilePath, metadataFallback);
581
- syncFromMetadata(resetResult.metadata);
582
- lastBackupPath = resetResult.backupPath;
583
- if (authPolicy.mode === "bootstrap_then_attach") {
584
- return await bootstrapSessionInternal();
585
- }
586
- return refreshStatus();
587
- };
588
- const initializeControlPlane = async () => {
589
- if (authPolicy.mode !== "bootstrap_then_attach") {
590
- const nextControlMode = options.browserUrl ? "attach" : "launch";
591
- const nextRuntime = await startRuntime(buildRuntimeStartOptions(options, siteDefinition, nextControlMode, preferredPresentationMode, options.browserUrl));
592
- await activateRuntime(nextRuntime, nextControlMode === "attach" ? "external" : "managed", options.browserUrl);
593
- return;
594
- }
595
- const managedProfilePath = profilePath;
596
- metadata = await readSessionMetadata(managedProfilePath, metadataFallback);
597
- syncFromMetadata(metadata);
598
- if (options.browserUrl) {
599
- await attachSessionInternal(options.browserUrl);
600
- return;
601
- }
602
- const hasRunningBootstrapExternal = await hasRunningBootstrapBrowser(metadata);
603
- if (hasRunningBootstrapExternal) {
604
- return;
605
- }
606
- const hasRunningManagedAttach = metadata.controlMode === "attach" &&
607
- metadata.browserUrl !== undefined &&
608
- metadata.ownership === "managed" &&
609
- (await isProcessRunning(metadata.browserPid));
610
- if (hasRunningManagedAttach) {
611
- await attachSessionInternal();
612
- return;
613
- }
614
- if (metadata.authState === "authenticated") {
615
- await attachSessionInternal();
616
- return;
617
- }
618
- if (metadata.sessionState !== "profile_missing") {
619
- await attachSessionInternal();
620
- return;
621
- }
622
- await bootstrapSessionInternal(metadata.authState === "auth_required" || metadata.authState === "challenge_required"
623
- ? metadata.authState
624
- : "unknown");
625
- };
626
- await initializeControlPlane();
627
157
  const gateway = {
628
158
  listTools: async () => {
159
+ const runtime = controller.getRuntime();
629
160
  if (!runtime) {
630
161
  return [];
631
162
  }
632
- return await runtime.gateway.listTools();
163
+ const pageTools = await runtime.gateway.listTools();
164
+ return [...pageTools, ...overlayStore.listEnabledToolDefinitions()];
633
165
  },
634
166
  callTool: async (name, input) => {
167
+ const runtime = controller.getRuntime();
635
168
  if (!runtime) {
636
169
  throw new Error("SESSION_NOT_AVAILABLE: page tools are unavailable while local-mcp is waiting for bootstrap or attach");
637
170
  }
638
- return await runtime.gateway.callTool(name, input);
171
+ const overlayTool = overlayStore.getOverlayTool(name);
172
+ if (overlayTool) {
173
+ return await evaluateOverlayTool(runtime.page, overlayTool.overlay, overlayTool.tool, input);
174
+ }
175
+ return (await runtime.gateway.callTool(name, input));
639
176
  },
640
177
  listResources: async () => {
178
+ const runtime = controller.getRuntime();
641
179
  if (!runtime) {
642
180
  return [];
643
181
  }
644
182
  return await runtime.gateway.listResources();
645
183
  },
646
184
  readResource: async (uri) => {
185
+ const runtime = controller.getRuntime();
647
186
  if (!runtime) {
648
187
  throw new Error(`RESOURCE_NOT_FOUND: ${uri}`);
649
188
  }
650
189
  return await runtime.gateway.readResource(uri);
651
190
  },
652
- onResourceUpdated: (listener) => {
653
- resourceUpdatedListeners.add(listener);
654
- return () => {
655
- resourceUpdatedListeners.delete(listener);
656
- };
657
- },
191
+ onResourceUpdated: (listener) => controller.onResourceUpdated(listener),
658
192
  };
659
193
  try {
660
194
  const serverOptions = {
661
195
  gateway,
662
196
  bridgeControl: {
663
- getState: refreshStatus,
664
- openWindow: async () => {
665
- if (runtime) {
666
- return await runtime.openWindow();
667
- }
668
- if (authPolicy.mode === "bootstrap_then_attach") {
669
- if (await hasRunningBootstrapBrowser()) {
670
- await focusBrowserWindow(options.browserChannel).catch(() => {
671
- // Focusing an external browser is best-effort; reuse still avoids duplicate windows.
672
- });
673
- return "focused";
674
- }
675
- await runLifecycleTransition(async () => {
676
- await bootstrapSessionInternal(authState);
677
- });
678
- return "opened";
679
- }
680
- throw new Error("SESSION_NOT_AVAILABLE: current page is closed");
197
+ getState: () => {
198
+ lastState = toLocalBridgeState(controller.getState());
199
+ return lastState;
681
200
  },
201
+ openWindow: async () => await controller.openWindow(),
682
202
  bootstrapSession: async () => {
683
- return await runLifecycleTransition(async () => await bootstrapSessionInternal(authState));
203
+ lastState = toLocalBridgeState(await controller.bootstrapSession());
204
+ return lastState;
684
205
  },
685
206
  attachSession: async (requestedBrowserUrl) => {
686
- return await runLifecycleTransition(async () => await attachSessionInternal(requestedBrowserUrl));
207
+ lastState = toLocalBridgeState(await controller.attachSession(requestedBrowserUrl));
208
+ return lastState;
209
+ },
210
+ debugEval: async (script, args) => {
211
+ const runtime = controller.getRuntime();
212
+ if (!runtime) {
213
+ throw new Error("SESSION_NOT_AVAILABLE: debug eval requires an active browser runtime");
214
+ }
215
+ return await evaluateDebugScript(runtime.page, script, args);
216
+ },
217
+ listOverlays: async () => overlayStore.list(),
218
+ installOverlay: async (installOptions) => {
219
+ const overlay = await overlayStore.install(installOptions);
220
+ notifyToolsetMayHaveChanged();
221
+ return overlay;
222
+ },
223
+ updateOverlay: async (updateOptions) => {
224
+ const overlay = await overlayStore.update(updateOptions);
225
+ notifyToolsetMayHaveChanged();
226
+ return overlay;
227
+ },
228
+ enableOverlay: async (id) => {
229
+ const overlay = await overlayStore.enable(id);
230
+ notifyToolsetMayHaveChanged();
231
+ return overlay;
232
+ },
233
+ disableOverlay: async (id) => {
234
+ const overlay = await overlayStore.disable(id);
235
+ notifyToolsetMayHaveChanged();
236
+ return overlay;
237
+ },
238
+ deleteOverlay: async (id) => {
239
+ await overlayStore.delete(id);
240
+ notifyToolsetMayHaveChanged();
241
+ },
242
+ getPresentationMode: () => controller.getPresentationMode(),
243
+ setPresentationMode: async (setModeOptions) => {
244
+ lastState = toLocalBridgeState(await controller.setPresentationMode(setModeOptions));
245
+ return lastState;
687
246
  },
688
- getPresentationMode: () => refreshStatus().presentationMode,
689
- setPresentationMode: async (setModeOptions) => await setPresentationMode(setModeOptions),
690
247
  resetProfile: async () => {
691
- return await runLifecycleTransition(async () => await resetProfileInternal());
248
+ lastState = toLocalBridgeState(await controller.resetProfile());
249
+ await overlayStore.load();
250
+ notifyToolsetMayHaveChanged();
251
+ return lastState;
692
252
  },
693
253
  closeBridge: async () => {
694
254
  await closeResources();
695
255
  },
696
256
  },
697
257
  serviceVersion: options.serviceVersion,
258
+ onToolsetMayHaveChanged: (listener) => {
259
+ toolsetListeners.add(listener);
260
+ return () => {
261
+ toolsetListeners.delete(listener);
262
+ };
263
+ },
264
+ ...(options.input !== undefined ? { input: options.input } : {}),
265
+ ...(options.output !== undefined ? { output: options.output } : {}),
266
+ ...(options.onError !== undefined ? { onError: options.onError } : {}),
698
267
  };
699
- if (options.input !== undefined) {
700
- serverOptions.input = options.input;
701
- }
702
- if (options.output !== undefined) {
703
- serverOptions.output = options.output;
704
- }
705
- if (options.onError !== undefined) {
706
- serverOptions.onError = options.onError;
707
- }
708
268
  server = createLocalMcpStdioServer(serverOptions);
709
269
  await server.start();
270
+ if (closeRequested) {
271
+ await closeResources();
272
+ }
710
273
  }
711
274
  catch (error) {
712
- await closeResourcesInternal().catch(options.onError);
275
+ await closeResources().catch(options.onError);
713
276
  throw error;
714
277
  }
715
278
  const input = options.input ?? process.stdin;
@@ -721,22 +284,24 @@ export async function startLocalMcpBridge(options) {
721
284
  input.once("end", handleInputEnded);
722
285
  return {
723
286
  get site() {
724
- return siteDefinition.id;
287
+ return lastState.site;
725
288
  },
726
289
  get targetUrl() {
727
- return targetUrl;
290
+ return lastState.targetUrl;
728
291
  },
729
292
  get controlMode() {
730
- return controlMode;
293
+ return lastState.controlMode;
731
294
  },
732
295
  get mode() {
733
- return runtimeMode;
296
+ return lastState.mode;
734
297
  },
735
298
  get presentationMode() {
736
- return presentationMode;
299
+ lastState = toLocalBridgeState(controller.getState());
300
+ return lastState.presentationMode;
737
301
  },
738
302
  get preferredPresentationMode() {
739
- return preferredPresentationMode;
303
+ lastState = toLocalBridgeState(controller.getState());
304
+ return lastState.preferredPresentationMode;
740
305
  },
741
306
  close: async () => {
742
307
  input.removeListener("end", handleInputEnded);