@adrata/adrata-mcp 1.0.1 → 1.0.3
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 +1 -1
- package/access/auth.js +193 -4
- package/access/oauth.js +105 -15
- package/access/resource-metadata.js +9 -1
- package/access/tiers.js +35 -0
- package/api-bridge.js +84 -5
- package/governance/governed-args.js +261 -0
- package/package.json +5 -4
- package/product-profile.js +43 -0
- package/resources.js +22 -10
- package/security.js +34 -6
- package/server.js +282 -58
- package/server.json +0 -6
- package/skills/board-review/SKILL.md +10 -2
- package/skills/incident-to-card/SKILL.md +23 -15
- package/skills/ship-the-card/SKILL.md +27 -4
- package/tool-annotations.js +63 -7
- package/tools/billing.js +5 -1
- package/tools/roadmap-tools.js +192 -0
- package/tools/work-board-tools.js +455 -16
- package/tools/work-hub/audit.js +162 -0
- package/toolsets/crm.js +72 -10
- package/toolsets/prospecting.js +118 -12
- package/toolsets/spaces.js +628 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ This package is also published as **[`@adrata/starfield-mcp`](https://www.npmjs.
|
|
|
10
10
|
(binary `starfield-mcp`), for people wiring a coding agent to their Starfield
|
|
11
11
|
board rather than to the CRM. It is not a fork and not a subset: it depends on
|
|
12
12
|
this package, defaults `ADRATA_MCP_SERVER_NAME` to `Starfield` so the client's
|
|
13
|
-
server list says Starfield, and imports the same `server.js`. Same
|
|
13
|
+
server list says Starfield, and imports the same `server.js`. Same governed tool surface,
|
|
14
14
|
same tiers, same governed-write contract, and nothing to keep in sync.
|
|
15
15
|
|
|
16
16
|
Install whichever name matches what you are doing. If you already have one, you
|
package/access/auth.js
CHANGED
|
@@ -16,10 +16,27 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { TIERS, getToolTier, tierSatisfies } from './tiers.js';
|
|
19
|
-
import {
|
|
20
|
-
|
|
19
|
+
import {
|
|
20
|
+
loadTokens,
|
|
21
|
+
getValidToken,
|
|
22
|
+
OAUTH_SCOPE,
|
|
23
|
+
describeOAuthScopeCapabilities,
|
|
24
|
+
ReconnectRequiredError,
|
|
25
|
+
TransientRefreshError,
|
|
26
|
+
} from './oauth.js';
|
|
27
|
+
import {
|
|
28
|
+
closeSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
openSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
renameSync,
|
|
34
|
+
statSync,
|
|
35
|
+
unlinkSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from 'node:fs';
|
|
21
38
|
import { homedir } from 'node:os';
|
|
22
|
-
import { isAbsolute, resolve, join } from 'node:path';
|
|
39
|
+
import { dirname, isAbsolute, resolve, join } from 'node:path';
|
|
23
40
|
|
|
24
41
|
const DEFAULT_CLI_CONFIG_PATH = join(homedir(), '.config', 'adrata', 'cli.json');
|
|
25
42
|
const DEFAULT_AGENT_CONFIG_PATH = join(homedir(), '.config', 'adrata', 'agent.json');
|
|
@@ -53,7 +70,7 @@ export function loadCliConfig() {
|
|
|
53
70
|
|
|
54
71
|
/**
|
|
55
72
|
* Read the shared agent session (device-flow OAuth, written by `adrata login`).
|
|
56
|
-
* Returns
|
|
73
|
+
* Returns the full refreshable session or null when absent/unusable.
|
|
57
74
|
*/
|
|
58
75
|
export function loadAgentSession() {
|
|
59
76
|
try {
|
|
@@ -66,8 +83,14 @@ export function loadAgentSession() {
|
|
|
66
83
|
if (!accessToken) return null;
|
|
67
84
|
return {
|
|
68
85
|
accessToken,
|
|
86
|
+
refreshToken: str('refreshToken'),
|
|
87
|
+
accessExpiresAt:
|
|
88
|
+
typeof parsed.accessExpiresAt === 'number' && Number.isFinite(parsed.accessExpiresAt)
|
|
89
|
+
? parsed.accessExpiresAt
|
|
90
|
+
: null,
|
|
69
91
|
apiBase: str('apiBase')?.replace(/\/+$/, '') ?? null,
|
|
70
92
|
workspaceId: str('workspaceId'),
|
|
93
|
+
user: parsed.user && typeof parsed.user === 'object' ? parsed.user : null,
|
|
71
94
|
};
|
|
72
95
|
} catch {
|
|
73
96
|
return null;
|
|
@@ -119,6 +142,7 @@ export function authenticate() {
|
|
|
119
142
|
authenticated: true,
|
|
120
143
|
source: 'agent_config',
|
|
121
144
|
issuerApiBase: agentSession.apiBase || null,
|
|
145
|
+
_agentSession: agentSession,
|
|
122
146
|
};
|
|
123
147
|
}
|
|
124
148
|
|
|
@@ -173,6 +197,171 @@ export function reauthenticate() {
|
|
|
173
197
|
return authenticate();
|
|
174
198
|
}
|
|
175
199
|
|
|
200
|
+
const AGENT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
201
|
+
const AGENT_REFRESH_LOCK_STALE_MS = 30_000;
|
|
202
|
+
const AGENT_REFRESH_LOCK_WAIT_MS = 10_000;
|
|
203
|
+
const AGENT_REFRESH_MAX_ATTEMPTS = 3;
|
|
204
|
+
const AGENT_REFRESH_BASE_DELAY_MS = 250;
|
|
205
|
+
const _agentRefreshInflight = new Map();
|
|
206
|
+
|
|
207
|
+
function agentSessionFresh(session) {
|
|
208
|
+
return (
|
|
209
|
+
typeof session?.accessExpiresAt === 'number' &&
|
|
210
|
+
Date.now() < session.accessExpiresAt - AGENT_REFRESH_BUFFER_MS
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function persistAgentSession(path, session) {
|
|
215
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
216
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
217
|
+
writeFileSync(temporary, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
|
|
218
|
+
renameSync(temporary, path);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function agentRefreshBackoffDelay(attempt) {
|
|
222
|
+
const base = AGENT_REFRESH_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
223
|
+
return base + Math.floor(Math.random() * AGENT_REFRESH_BASE_DELAY_MS);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function wait(ms) {
|
|
227
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function acquireAgentRefreshLock(path) {
|
|
231
|
+
const lockPath = `${path}.refresh.lock`;
|
|
232
|
+
const deadline = Date.now() + AGENT_REFRESH_LOCK_WAIT_MS;
|
|
233
|
+
while (Date.now() < deadline) {
|
|
234
|
+
try {
|
|
235
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
236
|
+
closeSync(fd);
|
|
237
|
+
return () => {
|
|
238
|
+
try {
|
|
239
|
+
unlinkSync(lockPath);
|
|
240
|
+
} catch {
|
|
241
|
+
// Another process may have cleaned a stale lock after this process was interrupted.
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
246
|
+
try {
|
|
247
|
+
if (Date.now() - statSync(lockPath).mtimeMs > AGENT_REFRESH_LOCK_STALE_MS) {
|
|
248
|
+
unlinkSync(lockPath);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw new TransientRefreshError('Timed out waiting for another MCP client to refresh the shared session.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Return a valid token from the shared `adrata login` session.
|
|
262
|
+
*
|
|
263
|
+
* Refresh tokens rotate, while several MCP hosts may read the same agent.json. The in-process
|
|
264
|
+
* single-flight prevents duplicate refreshes inside one server and the adjacent lock file extends
|
|
265
|
+
* that guarantee across Codex/Claude/Cursor processes. The winner atomically replaces agent.json;
|
|
266
|
+
* waiters re-read it and reuse the rotated token instead of presenting the now-revoked old one.
|
|
267
|
+
*/
|
|
268
|
+
export async function getValidAgentToken(
|
|
269
|
+
apiBase,
|
|
270
|
+
{ fetchImpl = fetch, forceRefresh = false, rejectedToken = null } = {},
|
|
271
|
+
) {
|
|
272
|
+
const path = getAgentConfigPath();
|
|
273
|
+
const quick = loadAgentSession();
|
|
274
|
+
if (!quick) return null;
|
|
275
|
+
if (!forceRefresh && agentSessionFresh(quick)) return quick.accessToken;
|
|
276
|
+
|
|
277
|
+
const key = `${path}|${new URL(apiBase).origin}`;
|
|
278
|
+
const existing = _agentRefreshInflight.get(key);
|
|
279
|
+
if (existing) return existing;
|
|
280
|
+
|
|
281
|
+
const inflight = (async () => {
|
|
282
|
+
const release = await acquireAgentRefreshLock(path);
|
|
283
|
+
try {
|
|
284
|
+
const current = loadAgentSession();
|
|
285
|
+
if (!current) return null;
|
|
286
|
+
if (current.apiBase && new URL(current.apiBase).origin !== new URL(apiBase).origin) {
|
|
287
|
+
throw new ReconnectRequiredError(
|
|
288
|
+
'Shared agent session/API mismatch. Run adrata login against the target environment.',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (rejectedToken && current.accessToken !== rejectedToken) return current.accessToken;
|
|
292
|
+
if (!forceRefresh && agentSessionFresh(current)) return current.accessToken;
|
|
293
|
+
if (!current.refreshToken) {
|
|
294
|
+
throw new ReconnectRequiredError(
|
|
295
|
+
'The shared adrata login session has no refresh token. Run adrata login once to reconnect.',
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let data;
|
|
300
|
+
let lastTransientError;
|
|
301
|
+
for (let attempt = 1; attempt <= AGENT_REFRESH_MAX_ATTEMPTS; attempt += 1) {
|
|
302
|
+
let response;
|
|
303
|
+
try {
|
|
304
|
+
response = await fetchImpl(`${apiBase.replace(/\/+$/, '')}/api/auth/refresh-token`, {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'Content-Type': 'application/json' },
|
|
307
|
+
body: JSON.stringify({ refreshToken: current.refreshToken }),
|
|
308
|
+
});
|
|
309
|
+
} catch (error) {
|
|
310
|
+
lastTransientError = new TransientRefreshError(
|
|
311
|
+
`Shared session refresh network error: ${error.message}`,
|
|
312
|
+
{ cause: error },
|
|
313
|
+
);
|
|
314
|
+
if (attempt < AGENT_REFRESH_MAX_ATTEMPTS) {
|
|
315
|
+
await wait(agentRefreshBackoffDelay(attempt));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
throw lastTransientError;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
data = await response.json().catch(() => ({}));
|
|
322
|
+
if (response.ok) break;
|
|
323
|
+
if (response.status === 429 || response.status >= 500) {
|
|
324
|
+
lastTransientError = new TransientRefreshError(
|
|
325
|
+
`Shared session refresh failed (${response.status}).`,
|
|
326
|
+
);
|
|
327
|
+
if (attempt < AGENT_REFRESH_MAX_ATTEMPTS) {
|
|
328
|
+
await wait(agentRefreshBackoffDelay(attempt));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
throw lastTransientError;
|
|
332
|
+
}
|
|
333
|
+
throw new ReconnectRequiredError(
|
|
334
|
+
`Shared adrata login refresh was rejected (${response.status}). Run adrata login once to reconnect.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!data?.accessToken || !data?.refreshToken || !Number.isFinite(data?.expires)) {
|
|
339
|
+
throw new TransientRefreshError('Shared session refresh returned an incomplete token response.');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const workspaceId =
|
|
343
|
+
data.user?.activeWorkspaceId ?? data.user?.workspaces?.[0]?.id ?? current.workspaceId;
|
|
344
|
+
const updated = {
|
|
345
|
+
...current,
|
|
346
|
+
apiBase: apiBase.replace(/\/+$/, ''),
|
|
347
|
+
accessToken: data.accessToken,
|
|
348
|
+
refreshToken: data.refreshToken,
|
|
349
|
+
accessExpiresAt: data.expires * 1000,
|
|
350
|
+
workspaceId,
|
|
351
|
+
user: data.user ?? current.user,
|
|
352
|
+
updatedAt: new Date().toISOString(),
|
|
353
|
+
};
|
|
354
|
+
persistAgentSession(path, updated);
|
|
355
|
+
return updated.accessToken;
|
|
356
|
+
} finally {
|
|
357
|
+
release();
|
|
358
|
+
}
|
|
359
|
+
})().finally(() => _agentRefreshInflight.delete(key));
|
|
360
|
+
|
|
361
|
+
_agentRefreshInflight.set(key, inflight);
|
|
362
|
+
return inflight;
|
|
363
|
+
}
|
|
364
|
+
|
|
176
365
|
export function assertOAuthIssuerMatchesTarget(authContext, apiBase) {
|
|
177
366
|
const issuerBound = authContext.source === 'stored' || authContext.source === 'agent_config';
|
|
178
367
|
if (!issuerBound || !authContext.issuerApiBase) return;
|
package/access/oauth.js
CHANGED
|
@@ -51,6 +51,18 @@ export const OAUTH_CALLBACK_ENDPOINTS = Object.freeze([
|
|
|
51
51
|
{ host: '::1', url: `http://[::1]:${CALLBACK_PORT}${CALLBACK_PATH}` },
|
|
52
52
|
]);
|
|
53
53
|
const OAUTH_BASE_PATH = '/api/v1/enterprise/oauth';
|
|
54
|
+
export const SEEDED_PUBLIC_CLIENT_ID = 'adrata-mcp-client';
|
|
55
|
+
// The static production client's registered read ceiling. Newer optional read
|
|
56
|
+
// families are available through DCR, but asking the seeded client for them
|
|
57
|
+
// makes `/authorize` reject the WHOLE request before it can present consent.
|
|
58
|
+
// Write scopes are intentionally not listed: current APIs may elevate those
|
|
59
|
+
// only after a signed-in workspace member approves them.
|
|
60
|
+
export const SEEDED_PUBLIC_READ_SCOPE = [
|
|
61
|
+
'read:companies', 'read:people', 'read:opportunities', 'read:actions',
|
|
62
|
+
'read:pipeline', 'read:speedrun', 'read:signals', 'read:search',
|
|
63
|
+
'read:analytics', 'read:buyer-groups', 'read:tasks', 'read:integrations',
|
|
64
|
+
'ai:base',
|
|
65
|
+
].join(' ');
|
|
54
66
|
// Read-only surface + the AI dispatcher. `ai:base` is required for anything
|
|
55
67
|
// routing through POST /api/v1/ai-crm-tools/execute. The additional read:*
|
|
56
68
|
// scopes unblock ~30 tools that scope_guard gates behind their own families
|
|
@@ -92,6 +104,22 @@ export const OAUTH_WRITE_SCOPE = [
|
|
|
92
104
|
'write:companies', 'write:people', 'write:buyer-groups',
|
|
93
105
|
'write:opportunities', 'write:actions', 'write:tasks',
|
|
94
106
|
'write:partnerships', 'write:sequences', 'write:campaigns', 'write:data',
|
|
107
|
+
// Authorising an external system to write into the workspace — connecting a
|
|
108
|
+
// CRM, or binding a source-control repository to a board. Its absence was not
|
|
109
|
+
// a deliberate least-privilege call, because `read:integrations` was already
|
|
110
|
+
// granted by default: the connection could SEE every integration and connect
|
|
111
|
+
// none of them.
|
|
112
|
+
//
|
|
113
|
+
// Measured 2026-08-29. `POST /api/v1/scm/connections` returned 403
|
|
114
|
+
// insufficient_scope against a session holding ten other write scopes, so the
|
|
115
|
+
// Starfield board could not be wired to GitHub from the only layer that has a
|
|
116
|
+
// connect flow at all — there is no Connections UI for source control. Every
|
|
117
|
+
// card move stayed manual as a result.
|
|
118
|
+
//
|
|
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',
|
|
95
123
|
].join(' ');
|
|
96
124
|
|
|
97
125
|
/**
|
|
@@ -288,7 +316,12 @@ export async function registerNativeClient(
|
|
|
288
316
|
});
|
|
289
317
|
const data = await res.json().catch(() => ({}));
|
|
290
318
|
if (!res.ok) {
|
|
291
|
-
|
|
319
|
+
const error = new Error(
|
|
320
|
+
`OAuth client registration failed: ${res.status} ${JSON.stringify(data).slice(0, 200)}`
|
|
321
|
+
);
|
|
322
|
+
error.status = res.status;
|
|
323
|
+
error.oauthCode = data?.error;
|
|
324
|
+
throw error;
|
|
292
325
|
}
|
|
293
326
|
if (typeof data.client_id !== 'string' || !data.client_id) {
|
|
294
327
|
throw new Error('OAuth client registration returned no client_id.');
|
|
@@ -303,6 +336,61 @@ export async function registerNativeClient(
|
|
|
303
336
|
};
|
|
304
337
|
}
|
|
305
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Choose the public client for one connection attempt.
|
|
341
|
+
*
|
|
342
|
+
* Dynamic registration is preferred because it narrows each installation to
|
|
343
|
+
* its exact loopback redirect. Production also carries a seeded public PKCE
|
|
344
|
+
* client for continuity during a rollout, behind a proxy that blocks DCR, or
|
|
345
|
+
* after the per-IP DCR ceiling is reached. A public client id is not a secret;
|
|
346
|
+
* the authorization code remains bound to the exact redirect and PKCE
|
|
347
|
+
* verifier, and write scopes still require the signed-in human consent step.
|
|
348
|
+
*/
|
|
349
|
+
export async function registrationForConnection({
|
|
350
|
+
apiBase,
|
|
351
|
+
redirectUris,
|
|
352
|
+
requestedScope,
|
|
353
|
+
fetchImpl = fetch,
|
|
354
|
+
configuredClientId = process.env.ADRATA_MCP_CLIENT_ID?.trim(),
|
|
355
|
+
} = {}) {
|
|
356
|
+
if (configuredClientId) {
|
|
357
|
+
return {
|
|
358
|
+
clientId: configuredClientId,
|
|
359
|
+
tokenEndpointAuthMethod: 'none',
|
|
360
|
+
clientRegistration: 'configured',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
return {
|
|
366
|
+
...(await registerNativeClient(apiBase, fetchImpl, redirectUris, requestedScope)),
|
|
367
|
+
clientRegistration: 'dynamic',
|
|
368
|
+
};
|
|
369
|
+
} catch (error) {
|
|
370
|
+
// These responses mean registration is unavailable, not that the
|
|
371
|
+
// authorization itself was refused. Do not mask a malformed request (400)
|
|
372
|
+
// or an API failure (5xx): both need fixing rather than a second path.
|
|
373
|
+
if (![403, 404, 429].includes(error?.status)) throw error;
|
|
374
|
+
return {
|
|
375
|
+
clientId: SEEDED_PUBLIC_CLIENT_ID,
|
|
376
|
+
tokenEndpointAuthMethod: 'none',
|
|
377
|
+
clientRegistration: 'seeded_fallback',
|
|
378
|
+
// Keep requested write scopes: they are not self-granted here. The
|
|
379
|
+
// authorize endpoint intersects them with the authenticated approver's
|
|
380
|
+
// role and requires explicit consent. Only the extra READ families need
|
|
381
|
+
// narrowing to the seeded client's registered ceiling.
|
|
382
|
+
scope: [
|
|
383
|
+
...SEEDED_PUBLIC_READ_SCOPE.split(' ').filter((scope) =>
|
|
384
|
+
String(requestedScope ?? '').split(/\s+/).includes(scope)
|
|
385
|
+
),
|
|
386
|
+
...String(requestedScope ?? '')
|
|
387
|
+
.split(/\s+/)
|
|
388
|
+
.filter((scope) => scope.startsWith('write:')),
|
|
389
|
+
].join(' '),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
306
394
|
// ---------------------------------------------------------------------------
|
|
307
395
|
// Encryption helpers
|
|
308
396
|
// ---------------------------------------------------------------------------
|
|
@@ -504,7 +592,7 @@ async function performTokenRefresh(apiBase, fetchImpl) {
|
|
|
504
592
|
refresh_token: tokens.refreshToken,
|
|
505
593
|
// RFC 8707: keep the same audience binding the initial grant used so the
|
|
506
594
|
// refreshed access token stays bound to the /api/v1/mcp resource.
|
|
507
|
-
resource: tokens.resource || canonicalResource(),
|
|
595
|
+
resource: tokens.resource || canonicalResource(apiBase),
|
|
508
596
|
});
|
|
509
597
|
|
|
510
598
|
let lastError;
|
|
@@ -597,7 +685,7 @@ export async function getValidToken(apiBase, { forceRefresh = false, fetchImpl =
|
|
|
597
685
|
/**
|
|
598
686
|
* Build the OAuth authorization URL.
|
|
599
687
|
*/
|
|
600
|
-
function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH_SCOPE) {
|
|
688
|
+
export function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH_SCOPE) {
|
|
601
689
|
// Use the web app's OAuth authorize page (user-facing login + consent screen)
|
|
602
690
|
const url = new URL(`${OAUTH_BASE_PATH}/authorize`, apiBase);
|
|
603
691
|
url.searchParams.set('client_id', clientId);
|
|
@@ -608,7 +696,7 @@ function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH
|
|
|
608
696
|
url.searchParams.set('source', 'mcp');
|
|
609
697
|
// RFC 8707 Resource Indicator — request a token audience-bound to this MCP
|
|
610
698
|
// resource so it cannot be replayed against a different service.
|
|
611
|
-
url.searchParams.set('resource', canonicalResource());
|
|
699
|
+
url.searchParams.set('resource', canonicalResource(apiBase));
|
|
612
700
|
url.searchParams.set('code_challenge', pkce.challenge);
|
|
613
701
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
614
702
|
return { url: url.toString(), state, codeVerifier: pkce.verifier };
|
|
@@ -781,7 +869,7 @@ export function createOAuthCallbackRequestHandler({
|
|
|
781
869
|
/**
|
|
782
870
|
* Exchange an authorization code for tokens.
|
|
783
871
|
*/
|
|
784
|
-
async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri) {
|
|
872
|
+
export async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri, fetchImpl = fetch) {
|
|
785
873
|
const url = new URL(`${OAUTH_BASE_PATH}/token`, apiBase);
|
|
786
874
|
const body = new URLSearchParams({
|
|
787
875
|
grant_type: 'authorization_code',
|
|
@@ -789,10 +877,10 @@ async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri)
|
|
|
789
877
|
code,
|
|
790
878
|
code_verifier: codeVerifier,
|
|
791
879
|
redirect_uri: redirectUri,
|
|
792
|
-
resource: canonicalResource(),
|
|
880
|
+
resource: canonicalResource(apiBase),
|
|
793
881
|
});
|
|
794
882
|
|
|
795
|
-
const res = await
|
|
883
|
+
const res = await fetchImpl(url.toString(), {
|
|
796
884
|
method: 'POST',
|
|
797
885
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
798
886
|
body,
|
|
@@ -901,24 +989,26 @@ export async function connectWorkspace(apiBase, { writeAccess = false } = {}) {
|
|
|
901
989
|
// Prefer an explicitly provisioned client when an operator supplies one.
|
|
902
990
|
// Otherwise use RFC 7591 DCR so every installation gets a real public client
|
|
903
991
|
// instead of relying on a database seed or a secret embedded in npm.
|
|
904
|
-
const configuredClientId = process.env.ADRATA_MCP_CLIENT_ID?.trim();
|
|
905
992
|
let registration;
|
|
906
993
|
try {
|
|
907
|
-
registration =
|
|
908
|
-
|
|
909
|
-
:
|
|
994
|
+
registration = await registrationForConnection({
|
|
995
|
+
apiBase,
|
|
996
|
+
redirectUris: [callbackServer.redirectUri],
|
|
997
|
+
requestedScope,
|
|
998
|
+
});
|
|
910
999
|
} catch (error) {
|
|
911
1000
|
callbackServer.close();
|
|
912
1001
|
throw error;
|
|
913
1002
|
}
|
|
914
1003
|
const clientId = registration.clientId;
|
|
1004
|
+
const authorizationScope = registration.scope || requestedScope;
|
|
915
1005
|
const authUrl = buildAuthUrl(
|
|
916
1006
|
apiBase,
|
|
917
1007
|
clientId,
|
|
918
1008
|
callbackServer.redirectUri,
|
|
919
1009
|
state,
|
|
920
1010
|
pkce,
|
|
921
|
-
|
|
1011
|
+
authorizationScope,
|
|
922
1012
|
).url;
|
|
923
1013
|
|
|
924
1014
|
if (process.env.ADRATA_MCP_PRINT_AUTH_URL === '1') {
|
|
@@ -948,11 +1038,11 @@ export async function connectWorkspace(apiBase, { writeAccess = false } = {}) {
|
|
|
948
1038
|
// The SERVER's granted scope wins. If the authorization server narrows the
|
|
949
1039
|
// grant (unregistered client scope, consent declined), the stored scope
|
|
950
1040
|
// must reflect what was actually granted -- never what we asked for.
|
|
951
|
-
scope: tokenResult.scope ||
|
|
1041
|
+
scope: tokenResult.scope || authorizationScope,
|
|
952
1042
|
clientId,
|
|
953
|
-
clientRegistration:
|
|
1043
|
+
clientRegistration: registration.clientRegistration,
|
|
954
1044
|
tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
|
|
955
|
-
resource: canonicalResource(),
|
|
1045
|
+
resource: canonicalResource(apiBase),
|
|
956
1046
|
apiBase,
|
|
957
1047
|
connectedAt: new Date().toISOString(),
|
|
958
1048
|
};
|
|
@@ -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`;
|
package/access/tiers.js
CHANGED
|
@@ -100,6 +100,26 @@ export const TOOL_TIERS = {
|
|
|
100
100
|
list_overdue_actions: TIERS.PRO,
|
|
101
101
|
list_today_actions: TIERS.PRO,
|
|
102
102
|
list_notes: TIERS.PRO,
|
|
103
|
+
|
|
104
|
+
// --- The nine spaces, as read surfaces ---
|
|
105
|
+
//
|
|
106
|
+
// Every one of these is a READ over data a seller can already see in the app, so they sit at
|
|
107
|
+
// PRO alongside the record reads they summarise. None mutates anything; the writes behind these
|
|
108
|
+
// surfaces (a stage move, an edit) go through the existing governed tools rather than getting a
|
|
109
|
+
// second, laxer door here.
|
|
110
|
+
//
|
|
111
|
+
// list_spaces is the map of what a workspace can actually open, so it answers the same question
|
|
112
|
+
// the chooser does and is deliberately the cheapest call in the set.
|
|
113
|
+
list_spaces: TIERS.PRO,
|
|
114
|
+
get_pipeline_board: TIERS.PRO,
|
|
115
|
+
get_calendar_agenda: TIERS.PRO,
|
|
116
|
+
get_customer_health: TIERS.PRO,
|
|
117
|
+
get_market_overview: TIERS.PRO,
|
|
118
|
+
list_content_files: TIERS.PRO,
|
|
119
|
+
// Finance and the connector estate describe the WORKSPACE rather than a seller's book, so they
|
|
120
|
+
// sit with the other administrative reads: an operator question, not a selling one.
|
|
121
|
+
get_finance_overview: TIERS.ENTERPRISE,
|
|
122
|
+
list_workspace_connectors: TIERS.ENTERPRISE,
|
|
103
123
|
log_interaction: TIERS.PRO,
|
|
104
124
|
|
|
105
125
|
// --- ENTERPRISE tier: CRM CRUD, sequences, campaigns, admin, bulk ---
|
|
@@ -166,6 +186,9 @@ export const TOOL_TIERS = {
|
|
|
166
186
|
adrata_api_request: TIERS.ENTERPRISE,
|
|
167
187
|
adrata_ai_tool_catalog: TIERS.ENTERPRISE,
|
|
168
188
|
adrata_ai_tool_execute: TIERS.ENTERPRISE,
|
|
189
|
+
search_capabilities: TIERS.ENTERPRISE,
|
|
190
|
+
describe_capability: TIERS.ENTERPRISE,
|
|
191
|
+
run_capability: TIERS.ENTERPRISE,
|
|
169
192
|
// Sloan (AI executive assistant) — same governed dispatcher path as
|
|
170
193
|
// adrata_ai_tool_execute, so same tier.
|
|
171
194
|
sloan_status: TIERS.ENTERPRISE,
|
|
@@ -195,18 +218,30 @@ export const TOOL_TIERS = {
|
|
|
195
218
|
// the tool — only exists on an OAuth token.
|
|
196
219
|
list_my_work_items: TIERS.ENTERPRISE,
|
|
197
220
|
list_work_boards: TIERS.ENTERPRISE,
|
|
221
|
+
get_work_item_delivery_evidence: TIERS.ENTERPRISE,
|
|
222
|
+
audit_work_hub: TIERS.ENTERPRISE,
|
|
223
|
+
list_work_item_acceptance_criteria: TIERS.ENTERPRISE,
|
|
198
224
|
get_work_board: TIERS.ENTERPRISE,
|
|
199
225
|
get_work_item: TIERS.ENTERPRISE,
|
|
200
226
|
get_work_item_history: TIERS.ENTERPRISE,
|
|
201
227
|
get_work_item_comments: TIERS.ENTERPRISE,
|
|
202
228
|
get_work_board_rollup: TIERS.ENTERPRISE,
|
|
203
229
|
list_work_board_rollups: TIERS.ENTERPRISE,
|
|
230
|
+
set_work_board_archived: TIERS.ENTERPRISE,
|
|
231
|
+
set_work_board_column_wip_limit: TIERS.ENTERPRISE,
|
|
204
232
|
move_work_item: TIERS.ENTERPRISE,
|
|
205
233
|
set_work_item_tag: TIERS.ENTERPRISE,
|
|
206
234
|
set_work_item_kind: TIERS.ENTERPRISE,
|
|
207
235
|
create_work_item: TIERS.ENTERPRISE,
|
|
236
|
+
add_work_item_acceptance_criterion: TIERS.ENTERPRISE,
|
|
237
|
+
satisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
|
|
238
|
+
unsatisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
|
|
208
239
|
comment_on_work_item: TIERS.ENTERPRISE,
|
|
209
240
|
flag_work_item: TIERS.ENTERPRISE,
|
|
241
|
+
// The containers above the cards, and the "add this to the roadmap" verb.
|
|
242
|
+
// Same reasoning as every board tool: scopes are real workspace records.
|
|
243
|
+
list_work_scopes: TIERS.ENTERPRISE,
|
|
244
|
+
add_to_roadmap: TIERS.ENTERPRISE,
|
|
210
245
|
|
|
211
246
|
// --- ENTERPRISE tier: Paper desktop app surfaces ---
|
|
212
247
|
paper_list_documents: TIERS.ENTERPRISE,
|