@adrata/adrata-mcp 1.0.1 → 1.0.2

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
@@ -288,7 +300,12 @@ export async function registerNativeClient(
288
300
  });
289
301
  const data = await res.json().catch(() => ({}));
290
302
  if (!res.ok) {
291
- throw new Error(`OAuth client registration failed: ${res.status} ${JSON.stringify(data).slice(0, 200)}`);
303
+ const error = new Error(
304
+ `OAuth client registration failed: ${res.status} ${JSON.stringify(data).slice(0, 200)}`
305
+ );
306
+ error.status = res.status;
307
+ error.oauthCode = data?.error;
308
+ throw error;
292
309
  }
293
310
  if (typeof data.client_id !== 'string' || !data.client_id) {
294
311
  throw new Error('OAuth client registration returned no client_id.');
@@ -303,6 +320,61 @@ export async function registerNativeClient(
303
320
  };
304
321
  }
305
322
 
323
+ /**
324
+ * Choose the public client for one connection attempt.
325
+ *
326
+ * Dynamic registration is preferred because it narrows each installation to
327
+ * its exact loopback redirect. Production also carries a seeded public PKCE
328
+ * client for continuity during a rollout, behind a proxy that blocks DCR, or
329
+ * after the per-IP DCR ceiling is reached. A public client id is not a secret;
330
+ * the authorization code remains bound to the exact redirect and PKCE
331
+ * verifier, and write scopes still require the signed-in human consent step.
332
+ */
333
+ export async function registrationForConnection({
334
+ apiBase,
335
+ redirectUris,
336
+ requestedScope,
337
+ fetchImpl = fetch,
338
+ configuredClientId = process.env.ADRATA_MCP_CLIENT_ID?.trim(),
339
+ } = {}) {
340
+ if (configuredClientId) {
341
+ return {
342
+ clientId: configuredClientId,
343
+ tokenEndpointAuthMethod: 'none',
344
+ clientRegistration: 'configured',
345
+ };
346
+ }
347
+
348
+ try {
349
+ return {
350
+ ...(await registerNativeClient(apiBase, fetchImpl, redirectUris, requestedScope)),
351
+ clientRegistration: 'dynamic',
352
+ };
353
+ } catch (error) {
354
+ // These responses mean registration is unavailable, not that the
355
+ // authorization itself was refused. Do not mask a malformed request (400)
356
+ // or an API failure (5xx): both need fixing rather than a second path.
357
+ if (![403, 404, 429].includes(error?.status)) throw error;
358
+ return {
359
+ clientId: SEEDED_PUBLIC_CLIENT_ID,
360
+ tokenEndpointAuthMethod: 'none',
361
+ clientRegistration: 'seeded_fallback',
362
+ // Keep requested write scopes: they are not self-granted here. The
363
+ // authorize endpoint intersects them with the authenticated approver's
364
+ // role and requires explicit consent. Only the extra READ families need
365
+ // narrowing to the seeded client's registered ceiling.
366
+ scope: [
367
+ ...SEEDED_PUBLIC_READ_SCOPE.split(' ').filter((scope) =>
368
+ String(requestedScope ?? '').split(/\s+/).includes(scope)
369
+ ),
370
+ ...String(requestedScope ?? '')
371
+ .split(/\s+/)
372
+ .filter((scope) => scope.startsWith('write:')),
373
+ ].join(' '),
374
+ };
375
+ }
376
+ }
377
+
306
378
  // ---------------------------------------------------------------------------
307
379
  // Encryption helpers
308
380
  // ---------------------------------------------------------------------------
@@ -901,24 +973,26 @@ export async function connectWorkspace(apiBase, { writeAccess = false } = {}) {
901
973
  // Prefer an explicitly provisioned client when an operator supplies one.
902
974
  // Otherwise use RFC 7591 DCR so every installation gets a real public client
903
975
  // instead of relying on a database seed or a secret embedded in npm.
904
- const configuredClientId = process.env.ADRATA_MCP_CLIENT_ID?.trim();
905
976
  let registration;
906
977
  try {
907
- registration = configuredClientId
908
- ? { clientId: configuredClientId, tokenEndpointAuthMethod: 'none' }
909
- : await registerNativeClient(apiBase, fetch, [callbackServer.redirectUri], requestedScope);
978
+ registration = await registrationForConnection({
979
+ apiBase,
980
+ redirectUris: [callbackServer.redirectUri],
981
+ requestedScope,
982
+ });
910
983
  } catch (error) {
911
984
  callbackServer.close();
912
985
  throw error;
913
986
  }
914
987
  const clientId = registration.clientId;
988
+ const authorizationScope = registration.scope || requestedScope;
915
989
  const authUrl = buildAuthUrl(
916
990
  apiBase,
917
991
  clientId,
918
992
  callbackServer.redirectUri,
919
993
  state,
920
994
  pkce,
921
- requestedScope,
995
+ authorizationScope,
922
996
  ).url;
923
997
 
924
998
  if (process.env.ADRATA_MCP_PRINT_AUTH_URL === '1') {
@@ -948,9 +1022,9 @@ export async function connectWorkspace(apiBase, { writeAccess = false } = {}) {
948
1022
  // The SERVER's granted scope wins. If the authorization server narrows the
949
1023
  // grant (unregistered client scope, consent declined), the stored scope
950
1024
  // must reflect what was actually granted -- never what we asked for.
951
- scope: tokenResult.scope || requestedScope,
1025
+ scope: tokenResult.scope || authorizationScope,
952
1026
  clientId,
953
- clientRegistration: configuredClientId ? 'configured' : 'dynamic',
1027
+ clientRegistration: registration.clientRegistration,
954
1028
  tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
955
1029
  resource: canonicalResource(),
956
1030
  apiBase,
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,28 @@ 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,
208
237
  comment_on_work_item: TIERS.ENTERPRISE,
209
238
  flag_work_item: TIERS.ENTERPRISE,
239
+ // The containers above the cards, and the "add this to the roadmap" verb.
240
+ // Same reasoning as every board tool: scopes are real workspace records.
241
+ list_work_scopes: TIERS.ENTERPRISE,
242
+ add_to_roadmap: TIERS.ENTERPRISE,
210
243
 
211
244
  // --- ENTERPRISE tier: Paper desktop app surfaces ---
212
245
  paper_list_documents: TIERS.ENTERPRISE,
package/api-bridge.js CHANGED
@@ -91,11 +91,19 @@ const ALLOWED_PREFIXES = [
91
91
  '/api/v1/email',
92
92
  '/api/v1/email-analytics',
93
93
  '/api/v1/email-deliverability',
94
+ // `matchesPathPrefix` is segment-boundary matched, so `/api/v1/email` does
95
+ // NOT annex `/api/v1/email-drafts` — the draft surface the Email app reads
96
+ // and writes on every compose was outside the allowlist entirely.
97
+ '/api/v1/email-drafts',
94
98
  '/api/v1/email-intelligence',
95
99
  '/api/v1/email-provisioning',
96
100
  '/api/v1/emails',
97
101
  '/api/v1/engagement',
98
- '/api/v1/entities',
102
+ // What the workspace is entitled to. The nine-space chooser at /spaces reads
103
+ // exactly this and nothing else, so without it an agent cannot answer "which
104
+ // spaces does this workspace even have" — the first question about the app's
105
+ // new top-level shape.
106
+ '/api/v1/entitlements',
99
107
  '/api/v1/env-audit',
100
108
  '/api/v1/events',
101
109
  '/api/v1/evidence',
@@ -121,8 +129,13 @@ const ALLOWED_PREFIXES = [
121
129
  '/api/v1/jobs',
122
130
  '/api/v1/knowledge',
123
131
  '/api/v1/lead-lifecycle',
132
+ // Same segment-boundary point as email-drafts: `/api/v1/leads` does not
133
+ // annex `/api/v1/lead-lists`. The Deals list joins lead lists and their
134
+ // members, so the list surface was half-reachable without this.
135
+ '/api/v1/lead-lists',
124
136
  '/api/v1/lead-routing',
125
137
  '/api/v1/leads',
138
+ '/api/v1/lists',
126
139
  '/api/v1/leaderboard',
127
140
  '/api/v1/linkedin',
128
141
  '/api/v1/media',
@@ -158,8 +171,6 @@ const ALLOWED_PREFIXES = [
158
171
  '/api/v1/retention',
159
172
  '/api/v1/revenue',
160
173
  '/api/v1/revenue-cloud',
161
- '/api/v1/sales',
162
- '/api/v1/scim',
163
174
  '/api/v1/scoring',
164
175
  '/api/v1/security',
165
176
  '/api/v1/self-service',
@@ -169,7 +180,6 @@ const ALLOWED_PREFIXES = [
169
180
  '/api/v1/signal-triggers',
170
181
  '/api/v1/simple-features',
171
182
  '/api/v1/sms',
172
- '/api/v1/sso',
173
183
  '/api/v1/speedrun',
174
184
  '/api/v1/supersets',
175
185
  '/api/v1/survival',
@@ -179,6 +189,7 @@ const ALLOWED_PREFIXES = [
179
189
  '/api/v1/targets',
180
190
  '/api/v1/team',
181
191
  '/api/v1/trash',
192
+ '/api/v1/triggers',
182
193
  '/api/v1/usage',
183
194
  '/api/v1/user-voice-profile',
184
195
  '/api/v1/users',
@@ -192,12 +203,36 @@ const ALLOWED_PREFIXES = [
192
203
  '/api/v1/work-board-rollups',
193
204
  '/api/v1/work-boards',
194
205
  '/api/v1/work-items',
206
+ // The containers above the cards (initiative / epic / feature) and the
207
+ // "add this to the roadmap" verb. Creating an epic proposal is a governed
208
+ // write like any other; the strategic commitment itself still moves through
209
+ // spoq/ PRs (company/decisions/2026-08-06-spoq-roadmap-sync.md).
210
+ '/api/v1/work-scopes',
195
211
  '/api/v1/workflow-runtime',
196
212
  '/api/v1/workflows',
197
213
  '/api/v1/workspace',
198
214
  '/api/v1/workspace-features',
199
215
  '/api/v1/workspace-settings',
200
216
  '/api/v1/zoho',
217
+ // ---------------------------------------------------------------------
218
+ // /api/v2 — the desktop read models and the aimed send path.
219
+ //
220
+ // Until this block existed the bridge allowlisted `/api/v1` exclusively, so
221
+ // EVERY v2 path failed `path is outside the agent bridge allowlist`. That
222
+ // was not a niche gap: the app's Email and Calendar surfaces read from v2,
223
+ // not v1. `GET /api/v2/emails/threads` backs the inbox list,
224
+ // `GET /api/v2/emails/counts` backs the folder badges, and
225
+ // `GET /api/v2/events/calendar` backs the entire agenda. A person could open
226
+ // their inbox and their calendar; an agent could reach neither, while the
227
+ // v1 write paths for the same records (PUT /api/v1/events/{id}, the draft
228
+ // routes) were allowlisted the whole time. The bridge could modify a
229
+ // calendar it could not read.
230
+ //
231
+ // The v2 namespace is deliberately small — `code/api/src/routes/v2/mod.rs`
232
+ // nests exactly `/emails` and `/events` — so these two prefixes are the
233
+ // whole surface, not a wildcard.
234
+ '/api/v2/emails',
235
+ '/api/v2/events',
201
236
  ];
202
237
 
203
238
  function matchesPathPrefix(path, prefix) {
@@ -232,10 +267,38 @@ const PATH_WRITE_SCOPES = [
232
267
  ['/api/v1/agent-tasks', 'write:tasks'],
233
268
  ['/api/v1/tasks', 'write:tasks'],
234
269
  ['/api/v1/actions', 'write:actions'],
270
+ // Starfield boards. A card IS a task, so the API's scope_guard maps all three
271
+ // board families onto the SAME read:tasks/write:tasks pair rather than a new
272
+ // scope family — see `required_scopes_for` in
273
+ // code/api/crates/middleware/src/scope_guard/mod.rs and the test
274
+ // `work_board_routes_are_reachable_by_the_credential_the_mcp_actually_issues`.
275
+ // Without these three rows a board write previewed as
276
+ // `requiredScope: "unknown (no mapping for this path)"` with `wouldSend: true`,
277
+ // so the preview could not warn that the connection lacked write:tasks — the
278
+ // exact "safe preview that predicts nothing" this map exists to prevent.
279
+ // All three are listed separately because `matchesPathPrefix` is
280
+ // segment-boundary matched: `/work-boards` does not annex
281
+ // `/work-board-rollups`.
282
+ ['/api/v1/work-boards', 'write:tasks'],
283
+ ['/api/v1/work-items', 'write:tasks'],
284
+ ['/api/v1/work-board-rollups', 'write:tasks'],
235
285
  ];
236
286
 
287
+ /**
288
+ * The API classifies a path AFTER stripping its version prefix, so
289
+ * `/api/v2/emails/direct` is scoped by exactly the same rules as
290
+ * `/api/v1/emails/direct` (`VERSIONED_API_PREFIXES` in
291
+ * code/api/crates/middleware/src/scope_guard/mod.rs). Mirror that here instead
292
+ * of duplicating every row under a second version — a duplicated map is a map
293
+ * that drifts, and a v2 path silently falling through to "no mapping" is the
294
+ * "preview that predicts nothing" failure this module exists to prevent.
295
+ */
296
+ function toScopeClassificationPath(path) {
297
+ return path.startsWith('/api/v2/') ? `/api/v1/${path.slice('/api/v2/'.length)}` : path;
298
+ }
299
+
237
300
  export function requiredWriteScopeForPath(path) {
238
- const normalized = normalizeApiPath(path);
301
+ const normalized = toScopeClassificationPath(normalizeApiPath(path));
239
302
  let best = null;
240
303
  for (const [prefix, scope] of PATH_WRITE_SCOPES) {
241
304
  if (matchesPathPrefix(normalized, prefix)) {