@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 +29 -1
- package/access/auth.js +49 -1
- package/access/oauth.js +336 -23
- package/access/tiers.js +15 -0
- package/analytics.js +141 -20
- package/package.json +2 -2
- package/server.js +109 -16
- package/server.json +14 -2
- package/skills/qa-the-card/SKILL.md +179 -0
- package/skills/ship-the-card/SKILL.md +18 -14
- package/tool-annotations.js +10 -2
- package/tools/source-control/connection-tools.js +31 -0
- package/tools/work-board-tools.js +1017 -42
- package/tools/work-hub/audit.js +17 -7
- package/toolsets/revenue/competitive-coverage.js +164 -0
- package/transport-http.js +30 -9
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
|
-
-
|
|
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 =
|
|
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 {
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
|
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,
|