@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 +29 -1
- package/access/auth.js +49 -1
- package/access/oauth.js +355 -26
- package/access/resource-metadata.js +9 -1
- package/access/tiers.js +17 -0
- package/analytics.js +141 -20
- package/api-bridge.js +16 -0
- 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 +31 -5
- package/tool-annotations.js +15 -1
- package/tools/source-control/connection-tools.js +31 -0
- package/tools/work-board-tools.js +1174 -30
- 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.
|
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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`;
|