@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 +1 -1
- package/access/auth.js +193 -4
- package/access/oauth.js +82 -8
- package/access/tiers.js +33 -0
- package/api-bridge.js +68 -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 +5 -4
- package/tool-annotations.js +57 -7
- package/tools/billing.js +5 -1
- package/tools/roadmap-tools.js +192 -0
- package/tools/work-board-tools.js +286 -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
|
|
@@ -288,7 +300,12 @@ export async function registerNativeClient(
|
|
|
288
300
|
});
|
|
289
301
|
const data = await res.json().catch(() => ({}));
|
|
290
302
|
if (!res.ok) {
|
|
291
|
-
|
|
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 =
|
|
908
|
-
|
|
909
|
-
:
|
|
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
|
-
|
|
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 ||
|
|
1025
|
+
scope: tokenResult.scope || authorizationScope,
|
|
952
1026
|
clientId,
|
|
953
|
-
clientRegistration:
|
|
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
|
-
|
|
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)) {
|