@adrata/adrata-mcp 1.0.2 → 1.0.6

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/README.md CHANGED
@@ -502,6 +502,31 @@ Enterprise authentication uses the `connect_workspace` tool for a browser-based
502
502
 
503
503
  Alternatively, set `ADRATA_OAUTH_TOKEN` directly for CI/CD or scripted environments.
504
504
 
505
+ ### Independent QA worker identities
506
+
507
+ Run QA1 and QA2 as separate MCP processes with separate OAuth stores. Each
508
+ process must complete its own interactive `connect_workspace({ writeAccess:
509
+ true })` flow, which gives it a distinct dynamically registered OAuth client
510
+ and token set:
511
+
512
+ ```bash
513
+ # QA1 process
514
+ ADRATA_MCP_IDENTITY_POOL=qa1 \
515
+ ADRATA_MCP_CONFIG_DIR=/var/lib/adrata/mcp/qa1 \
516
+ npx -y @adrata/adrata-mcp
517
+
518
+ # QA2 process
519
+ ADRATA_MCP_IDENTITY_POOL=qa2 \
520
+ ADRATA_MCP_CONFIG_DIR=/var/lib/adrata/mcp/qa2 \
521
+ npx -y @adrata/adrata-mcp
522
+ ```
523
+
524
+ A named pool intentionally ignores `ADRATA_OAUTH_TOKEN`, the shared
525
+ `adrata login` session, API keys, CLI tokens, and `~/.adrata/tokens.json`.
526
+ Missing or shared `ADRATA_MCP_CONFIG_DIR` configuration fails closed; do not
527
+ copy one pool's token file into another. The pool name is process routing
528
+ metadata, while the independently issued OAuth credential is the QA identity.
529
+
505
530
  ## Troubleshooting
506
531
 
507
532
  ### Server fails to start
@@ -526,7 +551,10 @@ Alternatively, set `ADRATA_OAUTH_TOKEN` directly for CI/CD or scripted environme
526
551
 
527
552
  - The server auto-refreshes stored tokens from `~/.adrata/tokens.json`
528
553
  - If refresh fails, run `connect_workspace` again
529
- - Delete `~/.adrata/tokens.json` to start fresh
554
+ - Never delete or disconnect the shared `~/.adrata/tokens.json` session as a
555
+ repair step; that silently signs every local agent out. Re-run
556
+ `connect_workspace` in the affected process. For a named QA pool, use that
557
+ pool's own `ADRATA_MCP_CONFIG_DIR` and complete consent there.
530
558
 
531
559
  ### Tools return empty results
532
560
 
package/access/auth.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  getValidToken,
22
22
  OAUTH_SCOPE,
23
23
  describeOAuthScopeCapabilities,
24
+ validateNamedPoolStorage,
24
25
  ReconnectRequiredError,
25
26
  TransientRefreshError,
26
27
  } from './oauth.js';
@@ -108,6 +109,49 @@ export function loadAgentSession() {
108
109
  * 5. No credentials (free)
109
110
  */
110
111
  export function authenticate() {
112
+ const identityPool = namedIdentityPoolConfiguration();
113
+ if (identityPool.active) {
114
+ if (!identityPool.valid) {
115
+ return {
116
+ tier: TIERS.FREE,
117
+ token: null,
118
+ apiKey: null,
119
+ apiUrl: process.env.ADRATA_API_URL || null,
120
+ workspaceId: process.env.ADRATA_WORKSPACE_ID || null,
121
+ authenticated: false,
122
+ source: 'none',
123
+ identityPool: identityPool.name,
124
+ message: identityPool.message,
125
+ };
126
+ }
127
+ const storedTokens = loadTokens();
128
+ if (storedTokens?.accessToken) {
129
+ return {
130
+ tier: TIERS.ENTERPRISE,
131
+ token: storedTokens.accessToken,
132
+ apiKey: null,
133
+ apiUrl: process.env.ADRATA_API_URL || storedTokens.apiBase || null,
134
+ workspaceId: storedTokens.workspaceId || process.env.ADRATA_WORKSPACE_ID || null,
135
+ authenticated: true,
136
+ source: 'stored_pool',
137
+ identityPool: identityPool.name,
138
+ issuerApiBase: storedTokens.apiBase || null,
139
+ _storedTokens: storedTokens,
140
+ };
141
+ }
142
+ return {
143
+ tier: TIERS.FREE,
144
+ token: null,
145
+ apiKey: null,
146
+ apiUrl: process.env.ADRATA_API_URL || null,
147
+ workspaceId: process.env.ADRATA_WORKSPACE_ID || null,
148
+ authenticated: false,
149
+ source: 'none',
150
+ identityPool: identityPool.name,
151
+ message: `No dedicated OAuth connection found for named MCP identity pool ${identityPool.name}. Run connect_workspace in that pool's MCP process.`,
152
+ };
153
+ }
154
+
111
155
  const oauthToken = process.env.ADRATA_OAUTH_TOKEN || '';
112
156
  const cliConfig = loadCliConfig();
113
157
  const apiKey = process.env.ADRATA_API_KEY || process.env.ADRATA_API_TOKEN || cliConfig.token || '';
@@ -189,6 +233,10 @@ export function authenticate() {
189
233
  };
190
234
  }
191
235
 
236
+ export function namedIdentityPoolConfiguration(env = process.env) {
237
+ return validateNamedPoolStorage(env);
238
+ }
239
+
192
240
  /**
193
241
  * Re-authenticate after a connect/disconnect event.
194
242
  * Returns a fresh auth context.
@@ -363,7 +411,7 @@ export async function getValidAgentToken(
363
411
  }
364
412
 
365
413
  export function assertOAuthIssuerMatchesTarget(authContext, apiBase) {
366
- const issuerBound = authContext.source === 'stored' || authContext.source === 'agent_config';
414
+ const issuerBound = ['stored', 'stored_pool', 'agent_config'].includes(authContext.source);
367
415
  if (!issuerBound || !authContext.issuerApiBase) return;
368
416
  const issuer = new URL(authContext.issuerApiBase).origin;
369
417
  const target = new URL(apiBase).origin;
package/access/oauth.js CHANGED
@@ -14,7 +14,18 @@
14
14
  import { createServer } from 'node:http';
15
15
  import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'node:crypto';
16
16
  import { execFile } from 'node:child_process';
17
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, chmodSync } from 'node:fs';
17
+ import {
18
+ chmodSync,
19
+ closeSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ openSync,
23
+ readFileSync,
24
+ renameSync,
25
+ statSync,
26
+ unlinkSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
18
29
  import { homedir } from 'node:os';
19
30
  import { join, resolve } from 'node:path';
20
31
  import { canonicalResource } from './resource-metadata.js';
@@ -23,10 +34,8 @@ import { canonicalResource } from './resource-metadata.js';
23
34
  // Constants
24
35
  // ---------------------------------------------------------------------------
25
36
 
26
- const ADRATA_DIR = process.env.ADRATA_MCP_CONFIG_DIR
27
- ? resolve(process.env.ADRATA_MCP_CONFIG_DIR)
28
- : join(homedir(), '.adrata');
29
- const TOKEN_FILE = join(ADRATA_DIR, 'tokens.json');
37
+ const DEFAULT_ADRATA_DIR = join(homedir(), '.adrata');
38
+ const IDENTITY_POOL_MARKER = 'identity-pool.json';
30
39
  const CALLBACK_PORT = 19472; // Ephemeral port for OAuth callback
31
40
  const CALLBACK_PATH = '/oauth/callback';
32
41
  // IPv4 loopback is FIRST and that order is load-bearing, not cosmetic.
@@ -104,8 +113,64 @@ export const OAUTH_WRITE_SCOPE = [
104
113
  'write:companies', 'write:people', 'write:buyer-groups',
105
114
  'write:opportunities', 'write:actions', 'write:tasks',
106
115
  'write:partnerships', 'write:sequences', 'write:campaigns', 'write:data',
116
+ // Authorising an external system to write into the workspace — connecting a
117
+ // CRM, or binding a source-control repository to a board. Its absence was not
118
+ // a deliberate least-privilege call, because `read:integrations` was already
119
+ // granted by default: the connection could SEE every integration and connect
120
+ // none of them.
121
+ //
122
+ // Measured 2026-08-29. `POST /api/v1/scm/connections` returned 403
123
+ // insufficient_scope against a session holding ten other write scopes, so the
124
+ // Starfield board could not be wired to GitHub from the only layer that has a
125
+ // connect flow at all — there is no Connections UI for source control. Every
126
+ // card move stayed manual as a result.
127
+ //
107
128
  ].join(' ');
108
129
 
130
+ /**
131
+ * Scopes this client knows about that the DEPLOYED authorization server may not.
132
+ *
133
+ * `write:integrations` authorises an external system to write into the
134
+ * workspace — connecting a CRM, or binding a source-control repository to a
135
+ * board. It was added here on 2026-08-29 (#2375) because `read:integrations`
136
+ * was already in the default grant, so a connection could SEE every integration
137
+ * and connect none of them: `POST /api/v1/scm/connections` returned 403
138
+ * insufficient_scope against a session holding ten other write scopes.
139
+ *
140
+ * It is NOT requested by default, and the reason is a property of OAuth rather
141
+ * than of this scope. **One unrecognised scope rejects the entire authorization
142
+ * request** — the server answers `invalid_scope` and issues nothing, so asking
143
+ * for a scope the running API has never heard of does not degrade the grant, it
144
+ * destroys it. Measured the same day: production served build `19ccef3a4a`,
145
+ * twenty-eight commits behind the `main` that introduced the scope, and every
146
+ * `connect_workspace({ writeAccess: true })` failed outright while read-only
147
+ * connected instantly. The board could not be written to at all, by any agent,
148
+ * for hours — a strictly worse outcome than never having asked.
149
+ *
150
+ * Discovery cannot decide this for us today, which is worth recording so the
151
+ * obvious fix is not attempted twice. The API does serve RFC 8414 metadata at
152
+ * `/.well-known/oauth-authorization-server`, but its `scopes_supported`
153
+ * advertises FIVE scopes while the same server happily issues thirteen on a
154
+ * read-only connect. Intersecting a request against that list would strip
155
+ * scopes that demonstrably work. Fix the metadata first; then this list can go
156
+ * away in favour of asking the server what it supports.
157
+ *
158
+ * To request it once the API is deployed at or past the commit that added it:
159
+ *
160
+ * ADRATA_MCP_REQUEST_PENDING_SCOPES=1
161
+ *
162
+ * Verify first, because the failure is total rather than partial:
163
+ *
164
+ * curl -s https://api.adrata.com/health | jq -r .build_sha
165
+ * git show <sha>:code/api/crates/security/src/scopes.rs | grep -c write:integrations
166
+ */
167
+ export const OAUTH_PENDING_SCOPE = ['write:integrations'].join(' ');
168
+
169
+ /** Whether to also request scopes the deployed server may not recognise yet. */
170
+ export function requestsPendingScopes(env = process.env) {
171
+ return env.ADRATA_MCP_REQUEST_PENDING_SCOPES === '1';
172
+ }
173
+
109
174
  /**
110
175
  * Scope string for a connection. Read-only unless write access is requested.
111
176
  *
@@ -114,7 +179,11 @@ export const OAUTH_WRITE_SCOPE = [
114
179
  * so elevation is opt-in on exactly one value.
115
180
  */
116
181
  export function oauthScopeFor({ writeAccess = false } = {}) {
117
- return writeAccess === true ? `${OAUTH_SCOPE} ${OAUTH_WRITE_SCOPE}` : OAUTH_SCOPE;
182
+ if (writeAccess !== true) return OAUTH_SCOPE;
183
+ const write = requestsPendingScopes()
184
+ ? `${OAUTH_WRITE_SCOPE} ${OAUTH_PENDING_SCOPE}`
185
+ : OAUTH_WRITE_SCOPE;
186
+ return `${OAUTH_SCOPE} ${write}`;
118
187
  }
119
188
 
120
189
  /**
@@ -240,9 +309,11 @@ export function oauthCallbackPage({ success, title, message, detail }) {
240
309
  }
241
310
 
242
311
  /**
243
- * Derive a machine-specific encryption key from hostname + username.
312
+ * Derive a runtime-layout-specific encryption key from home directory + user.
244
313
  * This is intentionally not high-security — it prevents casual plaintext
245
- * exposure while remaining portable across sessions without a password.
314
+ * exposure while remaining portable across sessions without a password. AWS
315
+ * workers sharing one token store must therefore pin the same runtime user and
316
+ * home directory; a different container user/home cannot decrypt that store.
246
317
  */
247
318
  function deriveKey() {
248
319
  const material = `adrata-mcp:${homedir()}:${process.env.USER || process.env.USERNAME || 'default'}`;
@@ -272,9 +343,76 @@ function normalizeTokenResult(result) {
272
343
  };
273
344
  }
274
345
 
346
+ /**
347
+ * The default registration name, used when the agent has not declared itself.
348
+ *
349
+ * Every install registered under this until 2026-08-29, which is why the board
350
+ * can say a program moved a card but not WHICH program. The server treats it as
351
+ * an undeclared installation and the card reads `unrecorded` — deliberately, so
352
+ * that the cards already filed under it are never retroactively given a name
353
+ * nobody recorded.
354
+ */
355
+ export const DEFAULT_CLIENT_NAME = 'Adrata MCP';
356
+
357
+ /**
358
+ * The agents that can declare themselves, keyed by the value of `ADRATA_AGENT`.
359
+ *
360
+ * The same closed vocabulary the API resolves against
361
+ * (`adrata_routes_gtm::work_boards::species::NAMED_AGENTS`) and the same one the
362
+ * settings page publishes as `McpClientId`. A value outside it is ignored rather
363
+ * than passed through: a registration name is drawn on a consent screen and, once
364
+ * this lands, next to work on a board, so an arbitrary string is not something to
365
+ * forward on trust.
366
+ */
367
+ const DECLARABLE_AGENTS = new Map([
368
+ ['claude-code', 'Claude Code'],
369
+ ['codex', 'Codex'],
370
+ ['grok', 'Grok'],
371
+ ]);
372
+
373
+ /**
374
+ * What this installation should register itself as.
375
+ *
376
+ * The declaration is made ONCE, here, at connect time — not on every write —
377
+ * because the name then lives in the server's own registration record and is
378
+ * read off the verified credential rather than off a request. That is the whole
379
+ * difference between a label bound to a credential and a header a caller sends.
380
+ *
381
+ * `ADRATA_AGENT` mirrors `JOURNAL_AGENT`, which is the one place this company
382
+ * already asks an agent to name itself, and it carries the same rule from
383
+ * `journal/README.md`: never guess. An unset or unrecognised value registers
384
+ * under {@link DEFAULT_CLIENT_NAME} and the board reads `unrecorded`, which is
385
+ * the honest answer and the one failure direction that under-reports rather than
386
+ * mislabels.
387
+ *
388
+ * What this asserts, precisely: that whoever ran `connect_workspace` said this
389
+ * was Claude Code. It is not proof of what is at the other end — nothing short
390
+ * of a per-vendor attested credential would be — and the API never lets it
391
+ * promote a caller's species or change who a card says did the work.
392
+ *
393
+ * Pure, so the exact bytes sent to Dynamic Client Registration can be asserted
394
+ * in a test rather than discovered in a consent screen.
395
+ */
396
+ export function declaredClientName(env = process.env) {
397
+ const declared = String(env.ADRATA_AGENT ?? '')
398
+ .trim()
399
+ .toLowerCase();
400
+ // The EXACT vocabulary string, with nothing appended. A decorated name like
401
+ // "Claude Code (Adrata MCP)" would read better in a client list and would
402
+ // resolve to nothing, because the API matches the closed vocabulary exactly
403
+ // rather than searching inside a string — and a matcher that searched would be
404
+ // one an arbitrary registration could satisfy by containing the word.
405
+ return DECLARABLE_AGENTS.get(declared) ?? DEFAULT_CLIENT_NAME;
406
+ }
407
+
275
408
  /**
276
409
  * Register a fresh native/public client for this explicit connection attempt.
277
410
  * PKCE protects the code exchange; an npm-embedded client secret would not.
411
+ *
412
+ * A FRESH client per attempt is what makes naming the agent possible at all:
413
+ * Claude Code and Codex on one machine become two distinct `oauth_clients` rows,
414
+ * so the API's per-credential actor key already tells them apart. Until now they
415
+ * merely shared a display name.
278
416
  */
279
417
  export async function registerNativeClient(
280
418
  apiBase,
@@ -287,7 +425,7 @@ export async function registerNativeClient(
287
425
  method: 'POST',
288
426
  headers: { 'Content-Type': 'application/json' },
289
427
  body: JSON.stringify({
290
- client_name: 'Adrata MCP',
428
+ client_name: declaredClientName(),
291
429
  redirect_uris: redirectUris,
292
430
  grant_types: ['authorization_code', 'refresh_token'],
293
431
  response_types: ['code'],
@@ -404,9 +542,91 @@ function decrypt(ciphertext) {
404
542
  // Token storage
405
543
  // ---------------------------------------------------------------------------
406
544
 
407
- function ensureDir() {
408
- if (!existsSync(ADRATA_DIR)) {
409
- mkdirSync(ADRATA_DIR, { recursive: true, mode: 0o700 });
545
+ export function validateNamedPoolStorage(env = process.env, home = homedir()) {
546
+ const name = env.ADRATA_MCP_IDENTITY_POOL?.trim();
547
+ if (!name) {
548
+ return { active: false, valid: true, name: null, configDir: null, message: null };
549
+ }
550
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
551
+ return {
552
+ active: true,
553
+ valid: false,
554
+ name,
555
+ configDir: null,
556
+ message: 'ADRATA_MCP_IDENTITY_POOL must use 1-64 letters, numbers, dots, underscores, or hyphens.',
557
+ };
558
+ }
559
+ const configured = env.ADRATA_MCP_CONFIG_DIR?.trim();
560
+ if (!configured) {
561
+ return {
562
+ active: true,
563
+ valid: false,
564
+ name,
565
+ configDir: null,
566
+ message: `ADRATA_MCP_CONFIG_DIR is required for named MCP identity pool ${name}.`,
567
+ };
568
+ }
569
+ const configDir = resolve(configured);
570
+ const sharedDir = join(home, '.adrata');
571
+ if (configDir === sharedDir) {
572
+ return {
573
+ active: true,
574
+ valid: false,
575
+ name,
576
+ configDir,
577
+ message: `Named MCP identity pool ${name} cannot use the shared ${join(sharedDir, 'tokens.json')} store.`,
578
+ };
579
+ }
580
+ return { active: true, valid: true, name, configDir, message: null };
581
+ }
582
+
583
+ function tokenStorage() {
584
+ const pool = validateNamedPoolStorage();
585
+ if (pool.active && !pool.valid) return { ...pool, tokenFile: null };
586
+ const configDir = pool.active
587
+ ? pool.configDir
588
+ : (process.env.ADRATA_MCP_CONFIG_DIR?.trim()
589
+ ? resolve(process.env.ADRATA_MCP_CONFIG_DIR)
590
+ : DEFAULT_ADRATA_DIR);
591
+ return {
592
+ ...pool,
593
+ configDir,
594
+ tokenFile: join(configDir, 'tokens.json'),
595
+ markerFile: join(configDir, IDENTITY_POOL_MARKER),
596
+ };
597
+ }
598
+
599
+ function ensureDir(configDir) {
600
+ if (!existsSync(configDir)) {
601
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
602
+ }
603
+ }
604
+
605
+ function storedPoolName(storage) {
606
+ if (!storage.active) return null;
607
+ try {
608
+ const marker = JSON.parse(readFileSync(storage.markerFile, 'utf8'));
609
+ return typeof marker?.identityPool === 'string' ? marker.identityPool : null;
610
+ } catch {
611
+ return null;
612
+ }
613
+ }
614
+
615
+ function claimNamedPoolStorage(storage) {
616
+ if (!storage.active) return;
617
+ const marker = `${JSON.stringify({ version: 1, identityPool: storage.name }, null, 2)}\n`;
618
+ try {
619
+ writeFileSync(storage.markerFile, marker, { flag: 'wx', mode: 0o600 });
620
+ try { chmodSync(storage.markerFile, 0o600); } catch { /* best-effort */ }
621
+ return;
622
+ } catch (error) {
623
+ if (error?.code !== 'EEXIST') throw error;
624
+ }
625
+ const owner = storedPoolName(storage);
626
+ if (owner !== storage.name) {
627
+ throw new Error(
628
+ `MCP config directory ${storage.configDir} already belongs to named MCP identity pool ${owner || 'unknown'}.`,
629
+ );
410
630
  }
411
631
  }
412
632
 
@@ -414,14 +634,32 @@ function ensureDir() {
414
634
  * Save tokens to ~/.adrata/tokens.json (encrypted).
415
635
  */
416
636
  export function saveTokens(tokenData) {
417
- ensureDir();
637
+ const storage = tokenStorage();
638
+ if (!storage.valid) throw new Error(storage.message);
639
+ ensureDir(storage.configDir);
640
+ claimNamedPoolStorage(storage);
418
641
  const payload = {
419
642
  v: 1,
420
643
  data: encrypt(JSON.stringify(tokenData)),
421
644
  updatedAt: new Date().toISOString(),
422
645
  };
423
- writeFileSync(TOKEN_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 });
424
- try { chmodSync(TOKEN_FILE, 0o600); } catch { /* best-effort */ }
646
+ // Readers in other MCP processes must see either the complete old envelope
647
+ // or the complete new one. A direct truncate/write can expose half JSON and
648
+ // make a healthy pool look disconnected during refresh-token rotation.
649
+ const nonce = randomBytes(12).toString('hex');
650
+ const temporary = join(storage.configDir, `.tokens-${process.pid}-${nonce}.tmp`);
651
+ try {
652
+ writeFileSync(temporary, JSON.stringify(payload, null, 2), {
653
+ flag: 'wx',
654
+ mode: 0o600,
655
+ });
656
+ try { chmodSync(temporary, 0o600); } catch { /* best-effort */ }
657
+ renameSync(temporary, storage.tokenFile);
658
+ try { chmodSync(storage.tokenFile, 0o600); } catch { /* best-effort */ }
659
+ } catch (error) {
660
+ try { if (existsSync(temporary)) unlinkSync(temporary); } catch { /* best-effort */ }
661
+ throw error;
662
+ }
425
663
  }
426
664
 
427
665
  /**
@@ -429,8 +667,10 @@ export function saveTokens(tokenData) {
429
667
  */
430
668
  export function loadTokens() {
431
669
  try {
432
- if (!existsSync(TOKEN_FILE)) return null;
433
- const raw = JSON.parse(readFileSync(TOKEN_FILE, 'utf8'));
670
+ const storage = tokenStorage();
671
+ if (!storage.valid || !existsSync(storage.tokenFile)) return null;
672
+ if (storage.active && storedPoolName(storage) !== storage.name) return null;
673
+ const raw = JSON.parse(readFileSync(storage.tokenFile, 'utf8'));
434
674
  if (raw.v !== 1 || !raw.data) return null;
435
675
  return JSON.parse(decrypt(raw.data));
436
676
  } catch {
@@ -443,7 +683,10 @@ export function loadTokens() {
443
683
  */
444
684
  export function clearTokens() {
445
685
  try {
446
- if (existsSync(TOKEN_FILE)) unlinkSync(TOKEN_FILE);
686
+ const storage = tokenStorage();
687
+ if (!storage.valid) return;
688
+ if (storage.active && storedPoolName(storage) !== storage.name) return;
689
+ if (existsSync(storage.tokenFile)) unlinkSync(storage.tokenFile);
447
690
  } catch { /* ignore */ }
448
691
  }
449
692
 
@@ -513,6 +756,10 @@ export class TransientRefreshError extends Error {
513
756
 
514
757
  const REFRESH_MAX_ATTEMPTS = 3;
515
758
  const REFRESH_BASE_DELAY_MS = 250;
759
+ const REFRESH_LOCK_FILE = '.tokens-refresh.lock';
760
+ const REFRESH_LOCK_POLL_MS = 25;
761
+ const REFRESH_LOCK_WAIT_MS = 120_000;
762
+ const REFRESH_LOCK_STALE_MS = 300_000;
516
763
 
517
764
  function refreshBackoffDelay(attempt) {
518
765
  // Exponential backoff with light jitter: ~250ms then ~500ms between attempts.
@@ -533,6 +780,66 @@ function sleep(ms) {
533
780
  */
534
781
  const _inflightRefresh = new Map();
535
782
 
783
+ function tokenRevision(tokens) {
784
+ if (!tokens) return null;
785
+ return [
786
+ tokens.clientId,
787
+ tokens.refreshToken,
788
+ tokens.accessToken,
789
+ tokens.expiresAt,
790
+ tokens.apiBase,
791
+ ].map((value) => String(value ?? '')).join('\u0000');
792
+ }
793
+
794
+ /**
795
+ * Elect one refresher across every MCP process sharing this config directory.
796
+ * The file is the lock: `wx` is atomic on the shared filesystem. A generous
797
+ * stale threshold recovers a process/container that died while holding it.
798
+ */
799
+ async function acquireRefreshLock() {
800
+ const storage = tokenStorage();
801
+ if (!storage.valid) throw new Error(storage.message);
802
+ ensureDir(storage.configDir);
803
+ claimNamedPoolStorage(storage);
804
+ const lockFile = join(storage.configDir, REFRESH_LOCK_FILE);
805
+ const nonce = randomBytes(16).toString('hex');
806
+ const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
807
+
808
+ while (true) {
809
+ try {
810
+ const descriptor = openSync(lockFile, 'wx', 0o600);
811
+ try {
812
+ writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, nonce, createdAt: new Date().toISOString() })}\n`);
813
+ } finally {
814
+ closeSync(descriptor);
815
+ }
816
+ return () => {
817
+ try {
818
+ const owner = JSON.parse(readFileSync(lockFile, 'utf8'));
819
+ if (owner?.nonce === nonce) unlinkSync(lockFile);
820
+ } catch { /* already released or replaced after stale recovery */ }
821
+ };
822
+ } catch (error) {
823
+ if (error?.code !== 'EEXIST') throw error;
824
+ }
825
+
826
+ try {
827
+ if (Date.now() - statSync(lockFile).mtimeMs > REFRESH_LOCK_STALE_MS) {
828
+ unlinkSync(lockFile);
829
+ continue;
830
+ }
831
+ } catch (error) {
832
+ if (error?.code === 'ENOENT') continue;
833
+ }
834
+ if (Date.now() >= deadline) {
835
+ throw new TransientRefreshError(
836
+ 'Timed out waiting for another MCP worker to finish rotating the shared refresh token.',
837
+ );
838
+ }
839
+ await sleep(REFRESH_LOCK_POLL_MS + Math.floor(Math.random() * REFRESH_LOCK_POLL_MS));
840
+ }
841
+ }
842
+
536
843
  /**
537
844
  * Refresh the access token using the refresh token.
538
845
  *
@@ -549,13 +856,35 @@ export async function refreshAccessToken(apiBase, fetchImpl = fetch) {
549
856
  const existing = _inflightRefresh.get(apiBase);
550
857
  if (existing) return existing;
551
858
 
552
- const inflight = performTokenRefresh(apiBase, fetchImpl).finally(() => {
859
+ const observedRevision = tokenRevision(loadTokens());
860
+ const inflight = performLockedTokenRefresh(apiBase, fetchImpl, observedRevision).finally(() => {
553
861
  _inflightRefresh.delete(apiBase);
554
862
  });
555
863
  _inflightRefresh.set(apiBase, inflight);
556
864
  return inflight;
557
865
  }
558
866
 
867
+ async function performLockedTokenRefresh(apiBase, fetchImpl, observedRevision) {
868
+ const releaseLock = await acquireRefreshLock();
869
+ try {
870
+ // Another process may have won while this one waited. Reload only after
871
+ // acquiring the filesystem lock and consume its rotated result instead of
872
+ // posting the now-revoked refresh token we originally observed.
873
+ const current = loadTokens();
874
+ if (
875
+ current
876
+ && tokenRevision(current) !== observedRevision
877
+ && storedSessionMatchesApiBase(apiBase, current)
878
+ && !isTokenExpired(current)
879
+ ) {
880
+ return current;
881
+ }
882
+ return await performTokenRefresh(apiBase, fetchImpl);
883
+ } finally {
884
+ releaseLock();
885
+ }
886
+ }
887
+
559
888
  async function performTokenRefresh(apiBase, fetchImpl) {
560
889
  const tokens = loadTokens();
561
890
  if (!tokens || !tokens.refreshToken) {
@@ -576,7 +905,7 @@ async function performTokenRefresh(apiBase, fetchImpl) {
576
905
  refresh_token: tokens.refreshToken,
577
906
  // RFC 8707: keep the same audience binding the initial grant used so the
578
907
  // refreshed access token stays bound to the /api/v1/mcp resource.
579
- resource: tokens.resource || canonicalResource(),
908
+ resource: tokens.resource || canonicalResource(apiBase),
580
909
  });
581
910
 
582
911
  let lastError;
@@ -669,7 +998,7 @@ export async function getValidToken(apiBase, { forceRefresh = false, fetchImpl =
669
998
  /**
670
999
  * Build the OAuth authorization URL.
671
1000
  */
672
- function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH_SCOPE) {
1001
+ export function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH_SCOPE) {
673
1002
  // Use the web app's OAuth authorize page (user-facing login + consent screen)
674
1003
  const url = new URL(`${OAUTH_BASE_PATH}/authorize`, apiBase);
675
1004
  url.searchParams.set('client_id', clientId);
@@ -680,7 +1009,7 @@ function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH
680
1009
  url.searchParams.set('source', 'mcp');
681
1010
  // RFC 8707 Resource Indicator — request a token audience-bound to this MCP
682
1011
  // resource so it cannot be replayed against a different service.
683
- url.searchParams.set('resource', canonicalResource());
1012
+ url.searchParams.set('resource', canonicalResource(apiBase));
684
1013
  url.searchParams.set('code_challenge', pkce.challenge);
685
1014
  url.searchParams.set('code_challenge_method', 'S256');
686
1015
  return { url: url.toString(), state, codeVerifier: pkce.verifier };
@@ -853,7 +1182,7 @@ export function createOAuthCallbackRequestHandler({
853
1182
  /**
854
1183
  * Exchange an authorization code for tokens.
855
1184
  */
856
- async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri) {
1185
+ export async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri, fetchImpl = fetch) {
857
1186
  const url = new URL(`${OAUTH_BASE_PATH}/token`, apiBase);
858
1187
  const body = new URLSearchParams({
859
1188
  grant_type: 'authorization_code',
@@ -861,10 +1190,10 @@ async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri)
861
1190
  code,
862
1191
  code_verifier: codeVerifier,
863
1192
  redirect_uri: redirectUri,
864
- resource: canonicalResource(),
1193
+ resource: canonicalResource(apiBase),
865
1194
  });
866
1195
 
867
- const res = await fetch(url.toString(), {
1196
+ const res = await fetchImpl(url.toString(), {
868
1197
  method: 'POST',
869
1198
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
870
1199
  body,
@@ -1026,7 +1355,7 @@ export async function connectWorkspace(apiBase, { writeAccess = false } = {}) {
1026
1355
  clientId,
1027
1356
  clientRegistration: registration.clientRegistration,
1028
1357
  tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
1029
- resource: canonicalResource(),
1358
+ resource: canonicalResource(apiBase),
1030
1359
  apiBase,
1031
1360
  connectedAt: new Date().toISOString(),
1032
1361
  };
@@ -35,8 +35,16 @@ export function authorizationServers() {
35
35
  * The canonical resource identifier for this MCP server. RFC 8707 clients
36
36
  * send this as the `resource` parameter so the AS can bind the token's
37
37
  * audience to it. Defaults to the REST MCP resource the Rust AS advertises.
38
+ *
39
+ * Client flows pass their explicit API base so a staging authorization request
40
+ * cannot accidentally ask for a production-bound token. Hosted resource-server
41
+ * callers omit it and continue to use the configured authorization server.
42
+ *
43
+ * @param {string} [apiBase] - Explicit authorization-server/API base for a
44
+ * client connection.
38
45
  */
39
- export function canonicalResource() {
46
+ export function canonicalResource(apiBase) {
47
+ if (apiBase) return `${apiBase.replace(/\/$/, '')}/api/v1/mcp`;
40
48
  if (process.env.ADRATA_MCP_RESOURCE) return process.env.ADRATA_MCP_RESOURCE.trim();
41
49
  const as = authorizationServers()[0] || 'https://api.adrata.com';
42
50
  return `${as.replace(/\/$/, '')}/api/v1/mcp`;