@webmcp-bridge/local-mcp 0.5.0 → 0.5.2

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