@adrata/adrata-mcp 1.0.3 → 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.
@@ -116,12 +125,52 @@ export const OAUTH_WRITE_SCOPE = [
116
125
  // connect flow at all — there is no Connections UI for source control. Every
117
126
  // card move stayed manual as a result.
118
127
  //
119
- // Still opt-in: this list is only requested by connect_workspace({ writeAccess:
120
- // true }), so the default grant remains read-only and a leaked token cannot
121
- // authorise an integration.
122
- 'write:integrations',
123
128
  ].join(' ');
124
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
+
125
174
  /**
126
175
  * Scope string for a connection. Read-only unless write access is requested.
127
176
  *
@@ -130,7 +179,11 @@ export const OAUTH_WRITE_SCOPE = [
130
179
  * so elevation is opt-in on exactly one value.
131
180
  */
132
181
  export function oauthScopeFor({ writeAccess = false } = {}) {
133
- 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}`;
134
187
  }
135
188
 
136
189
  /**
@@ -256,9 +309,11 @@ export function oauthCallbackPage({ success, title, message, detail }) {
256
309
  }
257
310
 
258
311
  /**
259
- * Derive a machine-specific encryption key from hostname + username.
312
+ * Derive a runtime-layout-specific encryption key from home directory + user.
260
313
  * This is intentionally not high-security — it prevents casual plaintext
261
- * 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.
262
317
  */
263
318
  function deriveKey() {
264
319
  const material = `adrata-mcp:${homedir()}:${process.env.USER || process.env.USERNAME || 'default'}`;
@@ -288,9 +343,76 @@ function normalizeTokenResult(result) {
288
343
  };
289
344
  }
290
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
+
291
408
  /**
292
409
  * Register a fresh native/public client for this explicit connection attempt.
293
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.
294
416
  */
295
417
  export async function registerNativeClient(
296
418
  apiBase,
@@ -303,7 +425,7 @@ export async function registerNativeClient(
303
425
  method: 'POST',
304
426
  headers: { 'Content-Type': 'application/json' },
305
427
  body: JSON.stringify({
306
- client_name: 'Adrata MCP',
428
+ client_name: declaredClientName(),
307
429
  redirect_uris: redirectUris,
308
430
  grant_types: ['authorization_code', 'refresh_token'],
309
431
  response_types: ['code'],
@@ -420,9 +542,91 @@ function decrypt(ciphertext) {
420
542
  // Token storage
421
543
  // ---------------------------------------------------------------------------
422
544
 
423
- function ensureDir() {
424
- if (!existsSync(ADRATA_DIR)) {
425
- 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
+ );
426
630
  }
427
631
  }
428
632
 
@@ -430,14 +634,32 @@ function ensureDir() {
430
634
  * Save tokens to ~/.adrata/tokens.json (encrypted).
431
635
  */
432
636
  export function saveTokens(tokenData) {
433
- ensureDir();
637
+ const storage = tokenStorage();
638
+ if (!storage.valid) throw new Error(storage.message);
639
+ ensureDir(storage.configDir);
640
+ claimNamedPoolStorage(storage);
434
641
  const payload = {
435
642
  v: 1,
436
643
  data: encrypt(JSON.stringify(tokenData)),
437
644
  updatedAt: new Date().toISOString(),
438
645
  };
439
- writeFileSync(TOKEN_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 });
440
- 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
+ }
441
663
  }
442
664
 
443
665
  /**
@@ -445,8 +667,10 @@ export function saveTokens(tokenData) {
445
667
  */
446
668
  export function loadTokens() {
447
669
  try {
448
- if (!existsSync(TOKEN_FILE)) return null;
449
- 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'));
450
674
  if (raw.v !== 1 || !raw.data) return null;
451
675
  return JSON.parse(decrypt(raw.data));
452
676
  } catch {
@@ -459,7 +683,10 @@ export function loadTokens() {
459
683
  */
460
684
  export function clearTokens() {
461
685
  try {
462
- 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);
463
690
  } catch { /* ignore */ }
464
691
  }
465
692
 
@@ -529,6 +756,10 @@ export class TransientRefreshError extends Error {
529
756
 
530
757
  const REFRESH_MAX_ATTEMPTS = 3;
531
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;
532
763
 
533
764
  function refreshBackoffDelay(attempt) {
534
765
  // Exponential backoff with light jitter: ~250ms then ~500ms between attempts.
@@ -549,6 +780,66 @@ function sleep(ms) {
549
780
  */
550
781
  const _inflightRefresh = new Map();
551
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
+
552
843
  /**
553
844
  * Refresh the access token using the refresh token.
554
845
  *
@@ -565,13 +856,35 @@ export async function refreshAccessToken(apiBase, fetchImpl = fetch) {
565
856
  const existing = _inflightRefresh.get(apiBase);
566
857
  if (existing) return existing;
567
858
 
568
- const inflight = performTokenRefresh(apiBase, fetchImpl).finally(() => {
859
+ const observedRevision = tokenRevision(loadTokens());
860
+ const inflight = performLockedTokenRefresh(apiBase, fetchImpl, observedRevision).finally(() => {
569
861
  _inflightRefresh.delete(apiBase);
570
862
  });
571
863
  _inflightRefresh.set(apiBase, inflight);
572
864
  return inflight;
573
865
  }
574
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
+
575
888
  async function performTokenRefresh(apiBase, fetchImpl) {
576
889
  const tokens = loadTokens();
577
890
  if (!tokens || !tokens.refreshToken) {
package/access/tiers.js CHANGED
@@ -218,23 +218,38 @@ export const TOOL_TIERS = {
218
218
  // the tool — only exists on an OAuth token.
219
219
  list_my_work_items: TIERS.ENTERPRISE,
220
220
  list_work_boards: TIERS.ENTERPRISE,
221
+ list_source_control_connections: TIERS.ENTERPRISE,
222
+ get_source_control_connection_events: TIERS.ENTERPRISE,
221
223
  get_work_item_delivery_evidence: TIERS.ENTERPRISE,
222
224
  audit_work_hub: TIERS.ENTERPRISE,
223
225
  list_work_item_acceptance_criteria: TIERS.ENTERPRISE,
226
+ list_work_item_qa_evidence: TIERS.ENTERPRISE,
224
227
  get_work_board: TIERS.ENTERPRISE,
225
228
  get_work_item: TIERS.ENTERPRISE,
229
+ get_work_item_worker_lease: TIERS.ENTERPRISE,
230
+ get_work_item_worker_activity: TIERS.ENTERPRISE,
231
+ claim_work_item_qa_pass: TIERS.ENTERPRISE,
232
+ claim_next_work_item_qa_pass: TIERS.ENTERPRISE,
233
+ heartbeat_work_item_qa_pass: TIERS.ENTERPRISE,
234
+ release_work_item_qa_pass: TIERS.ENTERPRISE,
235
+ record_work_item_qa_failure_and_release: TIERS.ENTERPRISE,
236
+ requeue_work_item_qa_after_fix: TIERS.ENTERPRISE,
226
237
  get_work_item_history: TIERS.ENTERPRISE,
227
238
  get_work_item_comments: TIERS.ENTERPRISE,
228
239
  get_work_board_rollup: TIERS.ENTERPRISE,
229
240
  list_work_board_rollups: TIERS.ENTERPRISE,
230
241
  set_work_board_archived: TIERS.ENTERPRISE,
231
242
  set_work_board_column_wip_limit: TIERS.ENTERPRISE,
243
+ attach_work_item_qa_evidence: TIERS.ENTERPRISE,
244
+ verify_work_item_qa_evidence_playback: TIERS.ENTERPRISE,
232
245
  move_work_item: TIERS.ENTERPRISE,
246
+ transfer_work_item_between_boards: TIERS.ENTERPRISE,
233
247
  set_work_item_tag: TIERS.ENTERPRISE,
234
248
  set_work_item_kind: TIERS.ENTERPRISE,
235
249
  create_work_item: TIERS.ENTERPRISE,
236
250
  add_work_item_acceptance_criterion: TIERS.ENTERPRISE,
237
251
  satisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
252
+ record_work_item_criterion_engineering_proof: TIERS.ENTERPRISE,
238
253
  unsatisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
239
254
  comment_on_work_item: TIERS.ENTERPRISE,
240
255
  flag_work_item: TIERS.ENTERPRISE,