@mathismeadows/roamer-device-auth 1.1.2 → 1.2.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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "AUTH-25: server-mediated device authorization flow + stdio<->HTTP MCP proxy for Roamer MCP. Used by roamer-bridge.sh for every OS and every default browser (no more Safari-specific branching, AUTH-14) Cloudflare Access's Managed OAuth has no device-code grant of its own, so RoamerMcp's server implements it and this script talks to that instead of Entra directly.",
6
+ "description": "AUTH-14/AUTH-25: stdio<->HTTP MCP proxy for Roamer MCP with two auth mechanisms, chosen per-machine by default-browser detection. Safari-default machines use a server-mediated device authorization flow (AUTH-25) since Safari's HTTPS-Only Mode blocks a loopback redirect; every other machine uses a standard direct loopback redirect against Cloudflare Access Managed OAuth instead.",
7
7
  "bin": {
8
8
  "roamer-device-auth": "./roamer-device-auth.mjs"
9
9
  },
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // Roamer MCP — server-mediated device authorization flow (AUTH-25).
2
+ // Roamer MCP — server-mediated device authorization flow (AUTH-25), used only for
3
+ // Safari-default clients since AUTH-14's 2026-08-25 revival. Non-Safari clients (and any
4
+ // OS where Safari isn't an option, e.g. Windows/Linux) instead use a standard direct
5
+ // loopback-redirect flow against Cloudflare Access Managed OAuth — see
6
+ // getValidTokensLoopback() below — removing the device-code confirmation screen AUTH-25's
7
+ // prior unified design imposed on every client, Safari or not.
3
8
  //
4
- // Replaces both the old mcp-remote redirect flow (AUTH-11) and this script's own previous
5
- // Entra-direct device-code flow (AUTH-14) a single mechanism for every OS and every
6
- // default browser now, including Safari, which blocked the old loopback-redirect approach
7
- // entirely (WebKitErrorDomain:305, confirmed live 2026-08-20). Cloudflare Access's Managed
8
- // OAuth has no device-code grant of its own (confirmed live the same day device
9
- // authentication is not supported for MCP portals per Cloudflare's own docs), so
10
- // RoamerMcp's server implements the RFC 8628 shape itself, brokering a real Authorization
11
- // Code + PKCE exchange with Cloudflare server-to-server. This script only ever talks plain
12
- // HTTPS to RoamerMcp — no local port, no local TLS certificate, no browser-default
13
- // detection needed at all.
9
+ // Why Safari still needs device-code: Safari's HTTPS-Only Mode unconditionally blocks the
10
+ // loopback flow's plain http:// callback (WebKitErrorDomain:305, confirmed live
11
+ // 2026-08-20), and Cloudflare Access's Managed OAuth has no device-code grant of its own
12
+ // (confirmed live the same day — device authentication is not supported for MCP portals
13
+ // per Cloudflare's own docs), so RoamerMcp's server implements the RFC 8628 shape itself
14
+ // for that case, brokering a real Authorization Code + PKCE exchange with Cloudflare
15
+ // server-to-server. detectDefaultBrowser() below decides which path a given machine takes.
14
16
  //
15
17
  // AUTH-24: this is the publishable source for the @mathismeadows/roamer-device-auth npm
16
18
  // package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
@@ -19,12 +21,22 @@
19
21
  // stdout is reserved for the JSON-RPC protocol channel; all logging goes to stderr.
20
22
 
21
23
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
24
+ import {
25
+ discoverOAuthServerInfo,
26
+ extractWWWAuthenticateParams,
27
+ registerClient,
28
+ startAuthorization,
29
+ exchangeAuthorization,
30
+ refreshAuthorization,
31
+ } from "@modelcontextprotocol/sdk/client/auth.js";
22
32
  import { readFile, writeFile, mkdir } from "node:fs/promises";
23
33
  import { homedir } from "node:os";
24
34
  import { join } from "node:path";
25
35
  import { setTimeout as sleep } from "node:timers/promises";
26
36
  import { execFile } from "node:child_process";
27
37
  import { promisify } from "node:util";
38
+ import { pathToFileURL } from "node:url";
39
+ import { createServer } from "node:http";
28
40
  import qrcode from "qrcode-terminal";
29
41
 
30
42
  const execFileAsync = promisify(execFile);
@@ -36,6 +48,13 @@ const CACHE_DIR = join(homedir(), ".mcp-auth-device");
36
48
  const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
37
49
  const PENDING_FILE = join(CACHE_DIR, "roamer_pending.json");
38
50
 
51
+ // AUTH-14: the loopback flow's own independent cache — never assumed interchangeable with
52
+ // the device-code cache above, matching this file's existing precedent of not trusting a
53
+ // differently-obtained cache just because it happens to share field names (see
54
+ // CACHE_VERSION's own comment on the mcp-remote-era predecessor of that exact mistake).
55
+ const LOOPBACK_TOKENS_FILE = join(CACHE_DIR, "roamer_loopback_tokens.json");
56
+ const LOOPBACK_CLIENT_FILE = join(CACHE_DIR, "roamer_loopback_client.json");
57
+
39
58
  // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
40
59
  // mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
41
60
  // (access_token, expires_in, obtained_at) with this format, so a plain presence check isn't
@@ -91,6 +110,69 @@ function clearCachedTokens() {
91
110
  return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
92
111
  }
93
112
 
113
+ // AUTH-14: loopback-flow cache — a brand-new file, never previously used by any other
114
+ // mechanism, so (unlike CACHE_FILE above) there's no prior-format collision risk to guard
115
+ // against with a version stamp.
116
+ async function readLoopbackTokens() {
117
+ try {
118
+ const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
119
+ return tokens?.access_token ? tokens : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ async function writeLoopbackTokens(tokens) {
126
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
127
+ await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
128
+ }
129
+
130
+ function clearLoopbackTokens() {
131
+ return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
132
+ }
133
+
134
+ // DCR'd client registration is reused across runs — Cloudflare's registration_endpoint has
135
+ // no reason to be hit on every single sign-in, only the first one (or after a reset).
136
+ async function readLoopbackClientInfo() {
137
+ try {
138
+ const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
139
+ return info?.client_id ? info : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ async function writeLoopbackClientInfo(info) {
146
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
147
+ await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(info, null, 2), { mode: 0o600 });
148
+ }
149
+
150
+ // AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
151
+ // Safari isn't an option elsewhere) take the device-code path above, since Safari's
152
+ // HTTPS-Only Mode blocks the loopback callback outright. Everything else, including every
153
+ // non-macOS platform (no Launch Services plist to query, so this always reports "unknown"
154
+ // there — correctly falling through to the loopback path), takes the lighter direct
155
+ // redirect. ROAMER_MCP_AUTH_FLOW overrides detection entirely — used by this file's own
156
+ // test suite to pin a deterministic path regardless of the CI machine's real OS/browser.
157
+ async function detectDefaultBrowser() {
158
+ const override = process.env.ROAMER_MCP_AUTH_FLOW;
159
+ if (override === "device-code") return "com.apple.safari";
160
+ if (override === "loopback") return "unknown";
161
+
162
+ try {
163
+ const plistPath = join(
164
+ homedir(),
165
+ "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
166
+ );
167
+ const { stdout } = await execFileAsync("plutil", ["-convert", "json", "-o", "-", plistPath]);
168
+ const data = JSON.parse(stdout);
169
+ const handler = (data.LSHandlers ?? []).find((h) => h.LSHandlerURLScheme === "http");
170
+ return handler?.LSHandlerRoleAll ?? "unknown";
171
+ } catch {
172
+ return "unknown";
173
+ }
174
+ }
175
+
94
176
  // Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
95
177
  // see a stdio handshake within its own connect timeout — and the interactive device flow
96
178
  // (dialog -> browser -> Cloudflare's consent screen -> poll) routinely takes longer than a
@@ -258,34 +340,239 @@ async function doGetValidTokens(forceRefresh) {
258
340
  }
259
341
  }
260
342
 
261
- async function main() {
262
- let tokens = await getValidTokens();
343
+ // AUTH-14: fixed, well-known port for the loopback callback — Cloudflare's DCR'd client
344
+ // registration is persisted and reused across every future sign-in (see
345
+ // readLoopbackClientInfo), so the redirect_uri baked into that registration has to stay
346
+ // stable across runs. A fixed port sidesteps re-registering on every single interactive
347
+ // sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
348
+ // project's original AUTH-11/AUTH-14 mcp-remote-based flow already lived with for months.
349
+ const LOOPBACK_PORT = Number(process.env.ROAMER_MCP_OAUTH_PORT ?? 38271);
350
+ const LOOPBACK_REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
351
+
352
+ async function refreshLoopbackTokens(refreshToken, serverInfo, clientInformation) {
353
+ const fresh = await refreshAuthorization(serverInfo.authorizationServerUrl, {
354
+ metadata: serverInfo.authorizationServerMetadata,
355
+ clientInformation,
356
+ refreshToken,
357
+ });
358
+ return { ...fresh, refresh_token: fresh.refresh_token ?? refreshToken };
359
+ }
263
360
 
264
- const transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
265
- requestInit: {
266
- get headers() {
267
- return { Authorization: `Bearer ${tokens.access_token}` };
361
+ function waitForLoopbackCallback(server) {
362
+ return new Promise((resolve, reject) => {
363
+ server.on("request", (req, res) => {
364
+ const url = new URL(req.url, LOOPBACK_REDIRECT_URI);
365
+ if (url.pathname !== "/callback") {
366
+ res.writeHead(404);
367
+ res.end();
368
+ return;
369
+ }
370
+ const code = url.searchParams.get("code");
371
+ const error = url.searchParams.get("error");
372
+ res.writeHead(200, { "Content-Type": "text/html" });
373
+ res.end(
374
+ error
375
+ ? `<html><body>Sign-in failed: ${error}. You can close this window.</body></html>`
376
+ : `<html><body>Signed in to Roamer MCP. You can close this window.</body></html>`,
377
+ );
378
+ if (error) reject(new Error(`Loopback authorization failed: ${error}`));
379
+ else if (code) resolve(code);
380
+ else reject(new Error("Loopback callback had neither code nor error"));
381
+ });
382
+ });
383
+ }
384
+
385
+ // AUTH-14: must follow the real WWW-Authenticate resource_metadata hint from a live 401,
386
+ // not RoamerMcp's own default .well-known/oauth-protected-resource document, which still
387
+ // mirrors Entra directly and would send a standards-compliant client to the wrong
388
+ // authorization server entirely — confirmed live 2026-08-26 (see thread
389
+ // SAFARI-DEVICE-CODE-UX; the stale default document itself is a separate, lower-priority
390
+ // gap tracked as AUTH-27, not fixed by this flow following the correct header instead).
391
+ async function discoverLoopbackServerInfo() {
392
+ const probe = await fetch(ROAMER_MCP_URL, {
393
+ method: "POST",
394
+ headers: { "Content-Type": "application/json" },
395
+ body: "{}",
396
+ });
397
+ const { resourceMetadataUrl } = extractWWWAuthenticateParams(probe);
398
+ await probe.body?.cancel?.();
399
+ return discoverOAuthServerInfo(ROAMER_MCP_URL, { resourceMetadataUrl });
400
+ }
401
+
402
+ // Same single-flight guard shape as getValidTokens above, kept independent since only one
403
+ // of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
404
+ let inFlightLoopbackTokens = null;
405
+
406
+ function getValidTokensLoopback(forceRefresh = false) {
407
+ if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
408
+ inFlightLoopbackTokens = doGetValidTokensLoopback(forceRefresh).finally(() => {
409
+ inFlightLoopbackTokens = null;
410
+ });
411
+ return inFlightLoopbackTokens;
412
+ }
413
+
414
+ async function doGetValidTokensLoopback(forceRefresh) {
415
+ let tokens = forceRefresh ? null : await readLoopbackTokens();
416
+
417
+ if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
418
+ return tokens;
419
+ }
420
+
421
+ const serverInfo = await discoverLoopbackServerInfo();
422
+ let clientInformation = await readLoopbackClientInfo();
423
+
424
+ if (!forceRefresh && tokens?.refresh_token && clientInformation) {
425
+ try {
426
+ log("Refreshing cached token...");
427
+ const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
428
+ tokens = { ...fresh, obtained_at: Date.now() };
429
+ await writeLoopbackTokens(tokens);
430
+ return tokens;
431
+ } catch (err) {
432
+ log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
433
+ }
434
+ }
435
+
436
+ if (forceRefresh) await clearLoopbackTokens();
437
+
438
+ if (!clientInformation) {
439
+ log("Registering as a new OAuth client with Cloudflare...");
440
+ clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
441
+ metadata: serverInfo.authorizationServerMetadata,
442
+ clientMetadata: {
443
+ client_name: "Roamer MCP (stdio bridge)",
444
+ redirect_uris: [LOOPBACK_REDIRECT_URI],
445
+ grant_types: ["authorization_code", "refresh_token"],
446
+ response_types: ["code"],
447
+ token_endpoint_auth_method: "none",
268
448
  },
269
- },
449
+ });
450
+ await writeLoopbackClientInfo(clientInformation);
451
+ }
452
+
453
+ log("Starting sign-in...");
454
+ const { authorizationUrl, codeVerifier } = await startAuthorization(serverInfo.authorizationServerUrl, {
455
+ metadata: serverInfo.authorizationServerMetadata,
456
+ clientInformation,
457
+ redirectUrl: LOOPBACK_REDIRECT_URI,
458
+ // RFC 8707: Cloudflare's authorization endpoint rejects the request without this
459
+ // (invalid_target / "No resource parameter found") — the exact same requirement
460
+ // DeviceFlowService.cs already hit and fixed server-side for the device-code path
461
+ // (confirmed live 2026-08-21) and just reconfirmed live here 2026-08-26. Only the
462
+ // authorization step needs it; DeviceFlowService.cs's token/refresh calls don't
463
+ // reference it at all, so exchangeAuthorization/refreshAuthorization below don't either.
464
+ resource: new URL(ROAMER_MCP_URL),
270
465
  });
271
466
 
272
- transport.onerror = (err) => {
273
- log(`Transport error: ${err.message}`);
274
- // Same reasoning as the send-path retry below: an auth error means whatever's cached is
275
- // known-bad, so drop it now rather than let the next proactive expiresSoon() check
276
- // (which only reasons about calendar time) keep handing it out.
277
- if (isAuthError(err)) clearCachedTokens();
467
+ const server = createServer();
468
+ const callbackPromise = waitForLoopbackCallback(server);
469
+ await new Promise((resolve, reject) => {
470
+ server.once("error", reject);
471
+ server.listen(LOOPBACK_PORT, "127.0.0.1", resolve);
472
+ }).catch((err) => {
473
+ throw new Error(
474
+ `Could not start the local sign-in listener on port ${LOOPBACK_PORT} (${err.message}). ` +
475
+ "Another roamer-device-auth process may already be signing in — wait for it to finish, " +
476
+ "or set ROAMER_MCP_OAUTH_PORT to a free port.",
477
+ );
478
+ });
479
+
480
+ log(`Go to ${authorizationUrl} to sign in.`);
481
+ try {
482
+ const { default: open } = await import("open");
483
+ await open(authorizationUrl.toString());
484
+ } catch {
485
+ // Best-effort only — the logged URL above is the fallback for any environment without a
486
+ // way to auto-launch a browser.
487
+ }
488
+
489
+ let authorizationCode;
490
+ try {
491
+ authorizationCode = await callbackPromise;
492
+ } finally {
493
+ server.close();
494
+ }
495
+
496
+ const fresh = await exchangeAuthorization(serverInfo.authorizationServerUrl, {
497
+ metadata: serverInfo.authorizationServerMetadata,
498
+ clientInformation,
499
+ authorizationCode,
500
+ codeVerifier,
501
+ redirectUri: LOOPBACK_REDIRECT_URI,
502
+ });
503
+ tokens = { ...fresh, obtained_at: Date.now() };
504
+ await writeLoopbackTokens(tokens);
505
+ log("Sign-in complete.");
506
+ return tokens;
507
+ }
508
+
509
+ // AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
510
+ // queued while sign-in was still in progress, with identical forwarding/retry behavior.
511
+ async function forwardLine(transport, line, getTokens, setTokens, getFreshTokens) {
512
+ try {
513
+ let tokens = getTokens();
514
+ if (expiresSoon(tokens)) {
515
+ tokens = await getFreshTokens();
516
+ setTokens(tokens);
517
+ }
518
+ try {
519
+ await transport.send(JSON.parse(line));
520
+ } catch (err) {
521
+ // Reactive invalidation: proactive expiry math can't catch everything (server-side
522
+ // revocation, clock skew, or an incompatible cache — see CACHE_VERSION). A real
523
+ // auth failure from the server is the ground truth; when we see one, invalidate
524
+ // whatever we're holding, force a genuinely fresh token, and retry once.
525
+ if (!isAuthError(err)) throw err;
526
+ log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
527
+ tokens = await getFreshTokens(true);
528
+ setTokens(tokens);
529
+ await transport.send(JSON.parse(line));
530
+ }
531
+ } catch (err) {
532
+ log(`Send failed: ${err.message}`);
533
+ }
534
+ }
535
+
536
+ // AUTH-28: a hard sign-in failure (denied/expired device code) must tell the host exactly
537
+ // that, per request, rather than leaving requests unanswered for the host to time out on.
538
+ function respondWithSignInError(line, err) {
539
+ let id = null;
540
+ try {
541
+ id = JSON.parse(line)?.id ?? null;
542
+ } catch {
543
+ // Malformed input never had a usable id anyway — respond with null per JSON-RPC convention.
544
+ }
545
+ const response = {
546
+ jsonrpc: "2.0",
547
+ id,
548
+ error: { code: -32001, message: `Roamer MCP sign-in failed: ${err.message}` },
278
549
  };
279
- await transport.start();
280
- log("Connected to remote server using StreamableHTTPClientTransport.");
550
+ process.stdout.write(`${JSON.stringify(response)}\n`);
551
+ }
281
552
 
282
- // stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC message;
283
- // transport.send() delivers it, and transport.onmessage delivers whatever comes back
284
- // (including server-initiated messages over the SSE half of the streamable-HTTP transport).
285
- transport.onmessage = (message) => {
286
- process.stdout.write(`${JSON.stringify(message)}\n`);
553
+ async function main() {
554
+ // AUTH-28: stdin is read (and, until sign-in completes, queued) from the very first tick —
555
+ // a cold-cache device-code flow can take minutes, and the MCP host must never see this
556
+ // process as unresponsive/silent during that window. That silence, not the auth flow
557
+ // itself, is what triggers the host's connect-timeout kill/respawn (see readPendingFlow's
558
+ // comment above) — this fix targets the silence, not the flow's real, unavoidable duration.
559
+ let tokens = null;
560
+ let transport = null;
561
+ let ready = false;
562
+ const pendingLines = [];
563
+
564
+ const getTokens = () => tokens;
565
+ const setTokens = (fresh) => {
566
+ tokens = fresh;
287
567
  };
288
568
 
569
+ // AUTH-14: assigned below, only after the stdin listener is attached — forwardLine (the
570
+ // only consumer) never runs before `ready` flips true, which itself never happens before
571
+ // these are set, so deferring the assignment past the listener attach is safe and keeps
572
+ // AUTH-28's guarantee (listener attaches before any await) intact.
573
+ let getFreshTokens;
574
+ let clearFreshTokens;
575
+
289
576
  let buffer = "";
290
577
  process.stdin.setEncoding("utf8");
291
578
  process.stdin.on("data", (chunk) => {
@@ -295,40 +582,80 @@ async function main() {
295
582
  const line = buffer.slice(0, newlineIndex);
296
583
  buffer = buffer.slice(newlineIndex + 1);
297
584
  if (!line.trim()) continue;
298
- (async () => {
299
- try {
300
- if (expiresSoon(tokens)) {
301
- tokens = await getValidTokens();
302
- }
303
- try {
304
- await transport.send(JSON.parse(line));
305
- } catch (err) {
306
- // Reactive invalidation: proactive expiry math can't catch everything (server-side
307
- // revocation, clock skew, or an incompatible cache — see CACHE_VERSION). A real
308
- // auth failure from the server is the ground truth; when we see one, invalidate
309
- // whatever we're holding, force a genuinely fresh token, and retry once.
310
- if (!isAuthError(err)) throw err;
311
- log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
312
- tokens = await getValidTokens(true);
313
- await transport.send(JSON.parse(line));
314
- }
315
- } catch (err) {
316
- log(`Send failed: ${err.message}`);
317
- }
318
- })();
585
+ if (ready) {
586
+ forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
587
+ } else {
588
+ pendingLines.push(line);
589
+ }
319
590
  }
320
591
  });
321
592
 
322
593
  process.stdin.on("end", async () => {
323
594
  log("stdin closed, shutting down.");
324
- await transport.close();
595
+ if (transport) await transport.close();
325
596
  process.exit(0);
326
597
  });
327
598
 
328
599
  log("Local STDIO proxy running. Press Ctrl+C to exit.");
600
+
601
+ // AUTH-14: decided once per process — detectDefaultBrowser() shells out to Launch
602
+ // Services, no need to re-check mid-session. Safari-default machines keep using the
603
+ // existing device-code mechanism (AUTH-25); everything else uses the lighter loopback
604
+ // redirect, including refreshes and reactive re-auth below.
605
+ const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
606
+ getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
607
+ clearFreshTokens = isSafari ? clearCachedTokens : clearLoopbackTokens;
608
+
609
+ try {
610
+ tokens = await getFreshTokens();
611
+ } catch (err) {
612
+ // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
613
+ // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
614
+ // explicit failure instead of a process it has to silently time out on.
615
+ log(`Sign-in failed: ${err.message}`);
616
+ for (const line of pendingLines) respondWithSignInError(line, err);
617
+ process.exit(1);
618
+ }
619
+
620
+ transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
621
+ requestInit: {
622
+ get headers() {
623
+ return { Authorization: `Bearer ${tokens.access_token}` };
624
+ },
625
+ },
626
+ });
627
+
628
+ transport.onerror = (err) => {
629
+ log(`Transport error: ${err.message}`);
630
+ // Same reasoning as the send-path retry above: an auth error means whatever's cached is
631
+ // known-bad, so drop it now rather than let the next proactive expiresSoon() check
632
+ // (which only reasons about calendar time) keep handing it out.
633
+ if (isAuthError(err)) clearFreshTokens();
634
+ };
635
+ // stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC message;
636
+ // transport.send() delivers it, and transport.onmessage delivers whatever comes back
637
+ // (including server-initiated messages over the SSE half of the streamable-HTTP transport).
638
+ transport.onmessage = (message) => {
639
+ process.stdout.write(`${JSON.stringify(message)}\n`);
640
+ };
641
+
642
+ await transport.start();
643
+ log("Connected to remote server using StreamableHTTPClientTransport.");
644
+
645
+ ready = true;
646
+ for (const line of pendingLines) {
647
+ forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
648
+ }
649
+ }
650
+
651
+ // Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
652
+ // must not trigger a live device-auth flow and stdio takeover as a side effect.
653
+ const isMainModule = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
654
+ if (isMainModule) {
655
+ main().catch((err) => {
656
+ log(`Fatal error: ${err.stack ?? err.message}`);
657
+ process.exit(1);
658
+ });
329
659
  }
330
660
 
331
- main().catch((err) => {
332
- log(`Fatal error: ${err.stack ?? err.message}`);
333
- process.exit(1);
334
- });
661
+ export { respondWithSignInError, forwardLine, detectDefaultBrowser };