@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 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 233 tools,
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 { loadTokens, getValidToken, OAUTH_SCOPE, describeOAuthScopeCapabilities } from './oauth.js';
20
- import { existsSync, readFileSync } from 'node:fs';
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 { accessToken, apiBase, workspaceId } or null when absent/unusable.
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
- throw new Error(`OAuth client registration failed: ${res.status} ${JSON.stringify(data).slice(0, 200)}`);
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 fetch(url.toString(), {
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 = configuredClientId
908
- ? { clientId: configuredClientId, tokenEndpointAuthMethod: 'none' }
909
- : await registerNativeClient(apiBase, fetch, [callbackServer.redirectUri], requestedScope);
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
- requestedScope,
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 || requestedScope,
1041
+ scope: tokenResult.scope || authorizationScope,
952
1042
  clientId,
953
- clientRegistration: configuredClientId ? 'configured' : 'dynamic',
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,