@mathismeadows/roamer-device-auth 1.3.1 → 1.4.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "private": false,
5
5
  "mcpName": "com.mathismeadows/roamer-mcp",
6
6
  "type": "module",
@@ -20,6 +20,18 @@
20
20
  // package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
21
21
  // version-pinned package via npx rather than running this file in place.
22
22
  //
23
+ // AUTH-51: every cached credential is namespaced per connecting MCP client, not shared
24
+ // machine-wide. Before this, one interactive sign-in (say, from Claude Code) silently
25
+ // authenticated every other local MCP host that later spawned this same bridge (e.g.
26
+ // Cursor) — no re-consent, no visibility, no way to revoke one without the other. Root
27
+ // cause and full context: spec item AUTH-51. Client identity comes from the `clientInfo.name`
28
+ // an MCP host sends in its own `initialize` request — the only client-identifying signal
29
+ // the stdio transport offers. This is NOT a security boundary (any local process can
30
+ // fabricate that name, or read these cache files directly — same-account processes always
31
+ // can) — see AUTH-51's explicit non-goal. Its job is stopping *accidental* sharing between
32
+ // distinct, legitimate MCP hosts, and making any reuse of a cached session visible via an OS
33
+ // notification rather than silent.
34
+ //
23
35
  // stdout is reserved for the JSON-RPC protocol channel; all logging goes to stderr.
24
36
 
25
37
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -48,15 +60,16 @@ const ROAMER_MCP_URL = process.env.ROAMER_MCP_URL ?? "https://roamer-mcp.mathism
48
60
  const ROAMER_MCP_ORIGIN = new URL(ROAMER_MCP_URL).origin;
49
61
 
50
62
  const CACHE_DIR = join(homedir(), ".mcp-auth-device");
51
- const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
52
- const PENDING_FILE = join(CACHE_DIR, "roamer_pending.json");
53
63
 
54
- // AUTH-14: the loopback flow's own independent cachenever assumed interchangeable with
55
- // the device-code cache above, matching this file's existing precedent of not trusting a
56
- // differently-obtained cache just because it happens to share field names (see
57
- // CACHE_VERSION's own comment on the mcp-remote-era predecessor of that exact mistake).
58
- const LOOPBACK_TOKENS_FILE = join(CACHE_DIR, "roamer_loopback_tokens.json");
59
- const LOOPBACK_CLIENT_FILE = join(CACHE_DIR, "roamer_loopback_client.json");
64
+ // AUTH-51: every on-disk cache is namespaced per client slug a null slug (the connecting
65
+ // host sent no usable clientInfo.name) means "never persist": the caller gets a real
66
+ // interactive sign-in on every single invocation instead of falling into any shared
67
+ // catch-all bucket, which would just recreate the bug this item fixes for a smaller
68
+ // population of clients.
69
+ function cacheFilePath(baseName, clientSlug) {
70
+ if (!clientSlug) return null;
71
+ return join(CACHE_DIR, `${baseName}__${clientSlug}.json`);
72
+ }
60
73
 
61
74
  // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
62
75
  // mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
@@ -77,6 +90,28 @@ function isAuthError(err) {
77
90
  return /invalid_token|unauthorized|\b401\b/i.test(err?.message ?? "");
78
91
  }
79
92
 
93
+ // AUTH-51: the only client-identifying signal MCP's stdio transport offers — the
94
+ // `clientInfo.name` an MCP host sends in its own `initialize` request, which by protocol is
95
+ // always the very first message a host sends. Deliberately tolerant: any parse failure or
96
+ // missing/blank name just means "no usable identity", never a thrown error — a
97
+ // non-compliant host must degrade gracefully (an uncached, always-fresh sign-in), not crash
98
+ // the bridge.
99
+ function clientSlugFromInitializeLine(line) {
100
+ try {
101
+ const message = JSON.parse(line);
102
+ const name = message?.params?.clientInfo?.name;
103
+ if (typeof name !== "string" || !name.trim()) return null;
104
+ const slug = name
105
+ .trim()
106
+ .toLowerCase()
107
+ .replace(/[^a-z0-9_-]+/g, "-")
108
+ .replace(/^-+|-+$/g, "");
109
+ return slug || null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
80
115
  // A native dialog is an OS-level surface, guaranteed visible independent of whether the MCP
81
116
  // host surfaces this process's stderr anywhere a human will see it (confirmed on the old
82
117
  // Entra-direct flow: a stderr-only first version left the user with no way to see the code).
@@ -91,9 +126,31 @@ async function showDeviceCodeDialog(verificationUri, userCode) {
91
126
  }
92
127
  }
93
128
 
94
- async function readCachedTokens() {
129
+ // AUTH-51: fires once per process, only when the token this run ends up using was obtained
130
+ // without any interactive step this time (a cache hit or a silent background refresh) — the
131
+ // exact moment that used to be completely invisible. Deliberately NOT fired for routine
132
+ // mid-session refreshes of an already-visible, already-established session (see forwardLine
133
+ // below) — only for the initial per-process acquisition, so a long-lived connection doesn't
134
+ // spam a notification every time its token happens to roll over.
135
+ async function notifySessionReused(clientSlug) {
136
+ const label = clientSlug ?? "an unidentified client";
137
+ const message = `Reused an existing Roamer MCP session for ${label}.`;
138
+ try {
139
+ await execFileAsync("osascript", [
140
+ "-e",
141
+ `display notification "${message.replace(/"/g, '\\"')}" with title "Roamer MCP"`,
142
+ ]);
143
+ } catch {
144
+ // Best-effort, macOS-only — the stderr log line below is the fallback for every other OS.
145
+ }
146
+ log(message);
147
+ }
148
+
149
+ async function readCachedTokens(clientSlug) {
150
+ const path = cacheFilePath("roamer_tokens", clientSlug);
151
+ if (!path) return null;
95
152
  try {
96
- const tokens = JSON.parse(await readFile(CACHE_FILE, "utf8"));
153
+ const tokens = JSON.parse(await readFile(path, "utf8"));
97
154
  // A cache from an incompatible prior format must never be trusted just because it
98
155
  // happens to have the right field names — see CACHE_VERSION's comment.
99
156
  if (tokens?.cacheVersion !== CACHE_VERSION) return null;
@@ -103,14 +160,18 @@ async function readCachedTokens() {
103
160
  }
104
161
  }
105
162
 
106
- async function writeCachedTokens(tokens) {
163
+ async function writeCachedTokens(clientSlug, tokens) {
164
+ const path = cacheFilePath("roamer_tokens", clientSlug);
165
+ if (!path) return; // AUTH-51: no usable client identity — never persisted.
107
166
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
108
167
  const stamped = { ...tokens, cacheVersion: CACHE_VERSION };
109
- await writeFile(CACHE_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
168
+ await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
110
169
  }
111
170
 
112
- function clearCachedTokens() {
113
- return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
171
+ function clearCachedTokens(clientSlug) {
172
+ const path = cacheFilePath("roamer_tokens", clientSlug);
173
+ if (!path) return Promise.resolve();
174
+ return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
114
175
  }
115
176
 
116
177
  // AUTH-14/AUTH-38: loopback-flow caches, stamped with the authorization server URL they were
@@ -120,9 +181,11 @@ function clearCachedTokens() {
120
181
  // discovery documents from Cloudflare Access Managed OAuth to RoamerMcp's own AS, and a client_id
121
182
  // or token cached from before that change would otherwise be silently carried forward and
122
183
  // presented to a completely different issuer with no invalidation at all.
123
- async function readLoopbackTokens(issuerUrl) {
184
+ async function readLoopbackTokens(clientSlug, issuerUrl) {
185
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
186
+ if (!path) return null;
124
187
  try {
125
- const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
188
+ const tokens = JSON.parse(await readFile(path, "utf8"));
126
189
  if (tokens?.issuerUrl !== issuerUrl) return null;
127
190
  return tokens?.access_token ? tokens : null;
128
191
  } catch {
@@ -130,22 +193,28 @@ async function readLoopbackTokens(issuerUrl) {
130
193
  }
131
194
  }
132
195
 
133
- async function writeLoopbackTokens(tokens, issuerUrl) {
196
+ async function writeLoopbackTokens(clientSlug, tokens, issuerUrl) {
197
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
198
+ if (!path) return; // AUTH-51: no usable client identity — never persisted.
134
199
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
135
200
  const stamped = { ...tokens, issuerUrl };
136
- await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
201
+ await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
137
202
  }
138
203
 
139
- function clearLoopbackTokens() {
140
- return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
204
+ function clearLoopbackTokens(clientSlug) {
205
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
206
+ if (!path) return Promise.resolve();
207
+ return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
141
208
  }
142
209
 
143
210
  // DCR'd client registration is reused across runs — the authorization server's
144
211
  // registration_endpoint has no reason to be hit on every single sign-in, only the first one (or
145
212
  // after a reset, or after an issuer change per this function's own issuerUrl check above).
146
- async function readLoopbackClientInfo(issuerUrl) {
213
+ async function readLoopbackClientInfo(clientSlug, issuerUrl) {
214
+ const path = cacheFilePath("roamer_loopback_client", clientSlug);
215
+ if (!path) return null;
147
216
  try {
148
- const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
217
+ const info = JSON.parse(await readFile(path, "utf8"));
149
218
  if (info?.issuerUrl !== issuerUrl) return null;
150
219
  return info?.client_id ? info : null;
151
220
  } catch {
@@ -153,10 +222,12 @@ async function readLoopbackClientInfo(issuerUrl) {
153
222
  }
154
223
  }
155
224
 
156
- async function writeLoopbackClientInfo(info, issuerUrl) {
225
+ async function writeLoopbackClientInfo(clientSlug, info, issuerUrl) {
226
+ const path = cacheFilePath("roamer_loopback_client", clientSlug);
227
+ if (!path) return; // AUTH-51: no usable client identity — never persisted.
157
228
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
158
229
  const stamped = { ...info, issuerUrl };
159
- await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
230
+ await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
160
231
  }
161
232
 
162
233
  // AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
@@ -165,7 +236,7 @@ async function writeLoopbackClientInfo(info, issuerUrl) {
165
236
  // non-macOS platform (no Launch Services plist to query, so this always reports "unknown"
166
237
  // there — correctly falling through to the loopback path), takes the lighter direct
167
238
  // redirect. ROAMER_MCP_AUTH_FLOW overrides detection entirely — used by this file's own
168
- // test suite to pin a deterministic path regardless of the CI machine's real OS/browser.
239
+ // test suite to pin a deterministic path regardless of the CI runner's real OS/default browser.
169
240
  async function detectDefaultBrowser() {
170
241
  const override = process.env.ROAMER_MCP_AUTH_FLOW;
171
242
  if (override === "device-code") return "com.apple.safari";
@@ -193,9 +264,11 @@ async function detectDefaultBrowser() {
193
264
  // user_code — the user would see a different code every single retry, forever, with no
194
265
  // path to ever actually finish signing in. Confirmed live 2026-08-21. Persisting the
195
266
  // pending flow means a respawned process resumes polling the SAME still-valid code instead.
196
- async function readPendingFlow() {
267
+ async function readPendingFlow(clientSlug) {
268
+ const path = cacheFilePath("roamer_pending", clientSlug);
269
+ if (!path) return null;
197
270
  try {
198
- const pending = JSON.parse(await readFile(PENDING_FILE, "utf8"));
271
+ const pending = JSON.parse(await readFile(path, "utf8"));
199
272
  if (!pending?.device_code || Date.now() > pending.expiresAt) return null;
200
273
  return pending;
201
274
  } catch {
@@ -203,15 +276,19 @@ async function readPendingFlow() {
203
276
  }
204
277
  }
205
278
 
206
- async function writePendingFlow(device) {
279
+ async function writePendingFlow(clientSlug, device) {
280
+ const path = cacheFilePath("roamer_pending", clientSlug);
281
+ if (!path) return device; // AUTH-51: no usable client identity — never persisted.
207
282
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
208
283
  const pending = { ...device, expiresAt: Date.now() + device.expires_in * 1000 };
209
- await writeFile(PENDING_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
284
+ await writeFile(path, JSON.stringify(pending, null, 2), { mode: 0o600 });
210
285
  return pending;
211
286
  }
212
287
 
213
- function clearPendingFlow() {
214
- return writeFile(PENDING_FILE, "{}", { mode: 0o600 }).catch(() => {});
288
+ function clearPendingFlow(clientSlug) {
289
+ const path = cacheFilePath("roamer_pending", clientSlug);
290
+ if (!path) return Promise.resolve();
291
+ return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
215
292
  }
216
293
 
217
294
  function expiresSoon(tokens) {
@@ -235,8 +312,15 @@ async function refreshTokens(refreshToken) {
235
312
  return response.json();
236
313
  }
237
314
 
238
- async function startDeviceFlow() {
239
- const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/start`, { method: "POST" });
315
+ // AUTH-52: carries the same client identity the loopback flow already sends at DCR
316
+ // registration, so the server's device-code confirm/success/failure/contact-email pages can
317
+ // name which client each sign-in prompt is actually for, instead of a generic message.
318
+ async function startDeviceFlow(clientSlug) {
319
+ const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/start`, {
320
+ method: "POST",
321
+ headers: { "Content-Type": "application/json" },
322
+ body: JSON.stringify({ client_name: clientSlug }),
323
+ });
240
324
  if (!response.ok) {
241
325
  throw new Error(`Device flow start failed: ${response.status} ${await response.text()}`);
242
326
  }
@@ -271,21 +355,28 @@ async function pollDeviceFlow(deviceCode, intervalSeconds) {
271
355
  // their own competing flow — worst case that means multiple device-code dialogs popping up
272
356
  // at once, or two refreshes racing on a rotating refresh_token where the loser's retry then
273
357
  // forces an unnecessary full sign-in. All concurrent callers await the same in-flight op.
358
+ // AUTH-51: still a single (not per-client) in-flight guard — a given process only ever
359
+ // resolves one clientSlug for its whole lifetime, decided once at startup, so there's never
360
+ // more than one client's flow in flight within a single process anyway.
274
361
  let inFlightTokens = null;
275
362
 
276
- function getValidTokens(forceRefresh = false) {
363
+ function getValidTokens(clientSlug, forceRefresh = false) {
277
364
  if (inFlightTokens) return inFlightTokens;
278
- inFlightTokens = doGetValidTokens(forceRefresh).finally(() => {
365
+ inFlightTokens = doGetValidTokens(clientSlug, forceRefresh).finally(() => {
279
366
  inFlightTokens = null;
280
367
  });
281
368
  return inFlightTokens;
282
369
  }
283
370
 
284
- async function doGetValidTokens(forceRefresh) {
285
- let tokens = forceRefresh ? null : await readCachedTokens();
371
+ // AUTH-51: returns { tokens, reusedSilently } — reusedSilently is true only when no
372
+ // interactive step ran this call (a valid cache hit or a silent refresh-token refresh),
373
+ // which is exactly the case that used to be invisible and is now what triggers
374
+ // notifySessionReused in main().
375
+ async function doGetValidTokens(clientSlug, forceRefresh) {
376
+ let tokens = forceRefresh ? null : await readCachedTokens(clientSlug);
286
377
 
287
378
  if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
288
- return tokens;
379
+ return { tokens, reusedSilently: true };
289
380
  }
290
381
 
291
382
  if (!forceRefresh && tokens?.refresh_token) {
@@ -293,23 +384,23 @@ async function doGetValidTokens(forceRefresh) {
293
384
  log("Refreshing cached token...");
294
385
  const fresh = await refreshTokens(tokens.refresh_token);
295
386
  tokens = { ...fresh, obtained_at: Date.now() };
296
- await writeCachedTokens(tokens);
297
- return tokens;
387
+ await writeCachedTokens(clientSlug, tokens);
388
+ return { tokens, reusedSilently: true };
298
389
  } catch (err) {
299
390
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
300
391
  }
301
392
  }
302
393
 
303
- if (forceRefresh) await clearCachedTokens();
394
+ if (forceRefresh) await clearCachedTokens(clientSlug);
304
395
 
305
396
  // Resume an already-in-progress flow (from a process this host killed and respawned)
306
397
  // instead of minting a new device_code the user would have to start over for.
307
- let device = await readPendingFlow();
398
+ let device = await readPendingFlow(clientSlug);
308
399
  let resuming = Boolean(device);
309
400
  if (!resuming) {
310
401
  log("Starting sign-in...");
311
- device = await startDeviceFlow();
312
- await writePendingFlow(device);
402
+ device = await startDeviceFlow(clientSlug);
403
+ await writePendingFlow(clientSlug, device);
313
404
  } else {
314
405
  log("Resuming an already-in-progress sign-in (a prior process was restarted before it finished)...");
315
406
  }
@@ -339,15 +430,15 @@ async function doGetValidTokens(forceRefresh) {
339
430
  try {
340
431
  const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
341
432
  tokens = { ...fresh, obtained_at: Date.now() };
342
- await writeCachedTokens(tokens);
343
- await clearPendingFlow();
433
+ await writeCachedTokens(clientSlug, tokens);
434
+ await clearPendingFlow(clientSlug);
344
435
  log("Sign-in complete.");
345
- return tokens;
436
+ return { tokens, reusedSilently: false };
346
437
  } catch (err) {
347
438
  // A hard failure (expired/denied, not just this process being killed) means the pending
348
439
  // code is genuinely dead — clear it so the next attempt starts a real fresh one instead
349
440
  // of retrying a code that will never succeed.
350
- await clearPendingFlow();
441
+ await clearPendingFlow(clientSlug);
351
442
  throw err;
352
443
  }
353
444
  }
@@ -358,6 +449,9 @@ async function doGetValidTokens(forceRefresh) {
358
449
  // stable across runs. A fixed port sidesteps re-registering on every single interactive
359
450
  // sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
360
451
  // project's original AUTH-11/AUTH-14 mcp-remote-based flow already lived with for months.
452
+ // AUTH-51: this is a pre-existing, unrelated limitation — two different MCP hosts both
453
+ // needing a fresh interactive loopback sign-in at literally the same moment would still
454
+ // race on this one port, same as two runs of the same client already could before this fix.
361
455
  const LOOPBACK_PORT = Number(process.env.ROAMER_MCP_OAUTH_PORT ?? 38271);
362
456
  const LOOPBACK_REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
363
457
 
@@ -415,56 +509,61 @@ async function discoverLoopbackServerInfo() {
415
509
  // of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
416
510
  let inFlightLoopbackTokens = null;
417
511
 
418
- function getValidTokensLoopback(forceRefresh = false) {
512
+ function getValidTokensLoopback(clientSlug, forceRefresh = false) {
419
513
  if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
420
- inFlightLoopbackTokens = doGetValidTokensLoopback(forceRefresh).finally(() => {
514
+ inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh).finally(() => {
421
515
  inFlightLoopbackTokens = null;
422
516
  });
423
517
  return inFlightLoopbackTokens;
424
518
  }
425
519
 
426
- async function doGetValidTokensLoopback(forceRefresh) {
520
+ // AUTH-51: returns { tokens, reusedSilently } — see doGetValidTokens's comment above, same
521
+ // contract for the loopback mechanism.
522
+ async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
427
523
  // AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
428
524
  // cached-but-not-yet-expired token from a prior authorization server would otherwise be
429
525
  // returned early below without ever learning the issuer changed underneath it.
430
526
  const serverInfo = await discoverLoopbackServerInfo();
431
527
  const issuerUrl = serverInfo.authorizationServerUrl.toString();
432
528
 
433
- let tokens = forceRefresh ? null : await readLoopbackTokens(issuerUrl);
529
+ let tokens = forceRefresh ? null : await readLoopbackTokens(clientSlug, issuerUrl);
434
530
 
435
531
  if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
436
- return tokens;
532
+ return { tokens, reusedSilently: true };
437
533
  }
438
534
 
439
- let clientInformation = await readLoopbackClientInfo(issuerUrl);
535
+ let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
440
536
 
441
537
  if (!forceRefresh && tokens?.refresh_token && clientInformation) {
442
538
  try {
443
539
  log("Refreshing cached token...");
444
540
  const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
445
541
  tokens = { ...fresh, obtained_at: Date.now() };
446
- await writeLoopbackTokens(tokens, issuerUrl);
447
- return tokens;
542
+ await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
543
+ return { tokens, reusedSilently: true };
448
544
  } catch (err) {
449
545
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
450
546
  }
451
547
  }
452
548
 
453
- if (forceRefresh) await clearLoopbackTokens();
549
+ if (forceRefresh) await clearLoopbackTokens(clientSlug);
454
550
 
455
551
  if (!clientInformation) {
456
552
  log("Registering as a new OAuth client...");
553
+ // AUTH-51: the registered client_name carries the connecting client's own identity when
554
+ // known, so a future server-side session list/audit view could actually tell clients
555
+ // apart instead of seeing the same generic name for every stdio-bridge user.
457
556
  clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
458
557
  metadata: serverInfo.authorizationServerMetadata,
459
558
  clientMetadata: {
460
- client_name: "Roamer MCP (stdio bridge)",
559
+ client_name: clientSlug ? `Roamer MCP (stdio bridge — ${clientSlug})` : "Roamer MCP (stdio bridge)",
461
560
  redirect_uris: [LOOPBACK_REDIRECT_URI],
462
561
  grant_types: ["authorization_code", "refresh_token"],
463
562
  response_types: ["code"],
464
563
  token_endpoint_auth_method: "none",
465
564
  },
466
565
  });
467
- await writeLoopbackClientInfo(clientInformation, issuerUrl);
566
+ await writeLoopbackClientInfo(clientSlug, clientInformation, issuerUrl);
468
567
  }
469
568
 
470
569
  log("Starting sign-in...");
@@ -517,13 +616,16 @@ async function doGetValidTokensLoopback(forceRefresh) {
517
616
  redirectUri: LOOPBACK_REDIRECT_URI,
518
617
  });
519
618
  tokens = { ...fresh, obtained_at: Date.now() };
520
- await writeLoopbackTokens(tokens, issuerUrl);
619
+ await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
521
620
  log("Sign-in complete.");
522
- return tokens;
621
+ return { tokens, reusedSilently: false };
523
622
  }
524
623
 
525
624
  // AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
526
625
  // queued while sign-in was still in progress, with identical forwarding/retry behavior.
626
+ // AUTH-51: unchanged — takes a plain getFreshTokens(forceRefresh) => Promise<tokens>
627
+ // function; main() adapts the new { tokens, reusedSilently }-returning, clientSlug-aware
628
+ // functions above into that shape rather than changing this function's own contract.
527
629
  async function forwardLine(transport, line, getTokens, setTokens, getFreshTokens) {
528
630
  try {
529
631
  let tokens = getTokens();
@@ -577,6 +679,15 @@ async function main() {
577
679
  let ready = false;
578
680
  const pendingLines = [];
579
681
 
682
+ // AUTH-51: which client is asking, resolved once from the first stdin line (the MCP
683
+ // `initialize` request, always message #1 by protocol) before any credential is touched.
684
+ let clientSlug = null;
685
+ let slugResolved = false;
686
+ let resolveFirstLine;
687
+ const firstLine = new Promise((resolve) => {
688
+ resolveFirstLine = resolve;
689
+ });
690
+
580
691
  const getTokens = () => tokens;
581
692
  const setTokens = (fresh) => {
582
693
  tokens = fresh;
@@ -586,8 +697,9 @@ async function main() {
586
697
  // only consumer) never runs before `ready` flips true, which itself never happens before
587
698
  // these are set, so deferring the assignment past the listener attach is safe and keeps
588
699
  // AUTH-28's guarantee (listener attaches before any await) intact.
589
- let getFreshTokens;
590
- let clearFreshTokens;
700
+ let getFreshTokens; // (clientSlug, forceRefresh) => Promise<{ tokens, reusedSilently }>
701
+ let clearFreshTokens; // () => Promise<void>
702
+ let freshTokensForForward; // (forceRefresh) => Promise<tokens> — forwardLine's plain-tokens contract
591
703
 
592
704
  let buffer = "";
593
705
  process.stdin.setEncoding("utf8");
@@ -598,8 +710,13 @@ async function main() {
598
710
  const line = buffer.slice(0, newlineIndex);
599
711
  buffer = buffer.slice(newlineIndex + 1);
600
712
  if (!line.trim()) continue;
713
+ if (!slugResolved) {
714
+ slugResolved = true;
715
+ clientSlug = clientSlugFromInitializeLine(line);
716
+ resolveFirstLine();
717
+ }
601
718
  if (ready) {
602
- forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
719
+ forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
603
720
  } else {
604
721
  pendingLines.push(line);
605
722
  }
@@ -614,16 +731,29 @@ async function main() {
614
731
 
615
732
  log("Local STDIO proxy running. Press Ctrl+C to exit.");
616
733
 
734
+ // AUTH-51: wait for that first line before authenticating, so the credential this process
735
+ // obtains is scoped to the right client. Safe alongside AUTH-28's no-silence guarantee —
736
+ // the listener above is already attached and queuing by this point, and a well-behaved
737
+ // host sends `initialize` immediately on spawn with nothing required from this process
738
+ // first, so this wait adds no meaningful latency and never reintroduces the
739
+ // respond-before-timeout gap AUTH-28 fixed.
740
+ await firstLine;
741
+ log(clientSlug ? `Client identified as "${clientSlug}".` : "No usable client identity sent — this sign-in will not be cached for reuse.");
742
+
617
743
  // AUTH-14: decided once per process — detectDefaultBrowser() shells out to Launch
618
744
  // Services, no need to re-check mid-session. Safari-default machines keep using the
619
745
  // existing device-code mechanism (AUTH-25); everything else uses the lighter loopback
620
746
  // redirect, including refreshes and reactive re-auth below.
621
747
  const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
622
748
  getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
623
- clearFreshTokens = isSafari ? clearCachedTokens : clearLoopbackTokens;
749
+ clearFreshTokens = () => (isSafari ? clearCachedTokens(clientSlug) : clearLoopbackTokens(clientSlug));
750
+ freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
624
751
 
752
+ let reusedSilently = false;
625
753
  try {
626
- tokens = await getFreshTokens();
754
+ const result = await getFreshTokens(clientSlug);
755
+ tokens = result.tokens;
756
+ reusedSilently = result.reusedSilently;
627
757
  } catch (err) {
628
758
  // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
629
759
  // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
@@ -633,6 +763,10 @@ async function main() {
633
763
  process.exit(1);
634
764
  }
635
765
 
766
+ // AUTH-51: the moment that used to be completely silent — a process starting up and
767
+ // immediately using a credential it never interactively obtained this run.
768
+ if (reusedSilently) await notifySessionReused(clientSlug);
769
+
636
770
  transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
637
771
  requestInit: {
638
772
  get headers() {
@@ -660,7 +794,7 @@ async function main() {
660
794
 
661
795
  ready = true;
662
796
  for (const line of pendingLines) {
663
- forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
797
+ forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
664
798
  }
665
799
  }
666
800
 
@@ -681,6 +815,12 @@ export {
681
815
  respondWithSignInError,
682
816
  forwardLine,
683
817
  detectDefaultBrowser,
818
+ clientSlugFromInitializeLine,
819
+ readCachedTokens,
820
+ writeCachedTokens,
821
+ clearCachedTokens,
822
+ readPendingFlow,
823
+ writePendingFlow,
684
824
  readLoopbackTokens,
685
825
  writeLoopbackTokens,
686
826
  readLoopbackClientInfo,