@adrata/adrata-mcp 1.0.0
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 +548 -0
- package/access/auth.js +289 -0
- package/access/oauth.js +1059 -0
- package/access/resource-metadata.js +167 -0
- package/access/tiers.js +422 -0
- package/analytics.js +634 -0
- package/api-bridge.js +499 -0
- package/governance/money.js +141 -0
- package/output-formatter.js +589 -0
- package/package.json +68 -0
- package/resources.js +246 -0
- package/security.js +690 -0
- package/server.js +2139 -0
- package/server.json +55 -0
- package/skills/backlog-triage/SKILL.md +115 -0
- package/skills/board-review/SKILL.md +96 -0
- package/skills/incident-to-card/SKILL.md +126 -0
- package/skills/log-outreach.md +62 -0
- package/skills/ship-the-card/SKILL.md +155 -0
- package/tool-annotations.js +269 -0
- package/tools/billing.js +149 -0
- package/tools/email-tools.js +652 -0
- package/tools/enterprise-tools.js +651 -0
- package/tools/free-search.js +160 -0
- package/tools/memory.js +440 -0
- package/tools/morning-brief.js +551 -0
- package/tools/paper-tools.js +563 -0
- package/tools/scheduling.js +322 -0
- package/tools/work-board-tools.js +758 -0
- package/toolsets/communications.js +276 -0
- package/toolsets/crm.js +495 -0
- package/toolsets/extensibility.js +1131 -0
- package/toolsets/infrastructure.js +757 -0
- package/toolsets/intelligence.js +232 -0
- package/toolsets/knowledge.js +154 -0
- package/toolsets/matrix.js +217 -0
- package/toolsets/outreach.js +432 -0
- package/toolsets/prospecting.js +314 -0
- package/toolsets/revenue/always-loaded.js +341 -0
- package/toolsets/revenue/sloan-tools.js +81 -0
- package/transport-http.js +505 -0
package/api-bridge.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
2
|
+
const SAFE_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
3
|
+
|
|
4
|
+
const BLOCKED_PREFIXES = [
|
|
5
|
+
'/api/v1/auth',
|
|
6
|
+
'/api/v1/api-keys',
|
|
7
|
+
'/api/v1/billing',
|
|
8
|
+
'/api/v1/custom-integrations',
|
|
9
|
+
'/api/v1/integrations',
|
|
10
|
+
'/api/v1/observability',
|
|
11
|
+
'/api/v1/users',
|
|
12
|
+
'/api/v1/webhooks',
|
|
13
|
+
'/api/v1/webhooks/inbound',
|
|
14
|
+
'/api/v1/webhooks/stripe',
|
|
15
|
+
'/auth',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const BLOCKED_SURFACES = {
|
|
19
|
+
billing: ['/api/v1/billing'],
|
|
20
|
+
apiKeys: ['/api/v1/api-keys'],
|
|
21
|
+
observability: ['/api/v1/observability'],
|
|
22
|
+
users: ['/api/v1/users'],
|
|
23
|
+
integrations: ['/api/v1/integrations', '/api/v1/custom-integrations'],
|
|
24
|
+
webhooks: ['/api/v1/webhooks'],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ALLOWED_PREFIXES = [
|
|
28
|
+
'/api/v1/actions',
|
|
29
|
+
'/api/v1/action-columns',
|
|
30
|
+
'/api/v1/agent-tasks',
|
|
31
|
+
'/api/v1/afternoon-brief',
|
|
32
|
+
'/api/v1/ai',
|
|
33
|
+
'/api/v1/ai-analytics-tools',
|
|
34
|
+
'/api/v1/ai-chat',
|
|
35
|
+
'/api/v1/ai-chat-debug',
|
|
36
|
+
'/api/v1/ai-chat-stream',
|
|
37
|
+
'/api/v1/ai-communication-tools',
|
|
38
|
+
'/api/v1/ai-context',
|
|
39
|
+
'/api/v1/ai-crm-tools',
|
|
40
|
+
'/api/v1/ai-evaluate',
|
|
41
|
+
'/api/v1/ai-executive-assistant',
|
|
42
|
+
'/api/v1/ai-features',
|
|
43
|
+
'/api/v1/ai-tools',
|
|
44
|
+
'/api/v1/analytics',
|
|
45
|
+
'/api/v1/api-keys',
|
|
46
|
+
'/api/v1/area',
|
|
47
|
+
'/api/v1/automations',
|
|
48
|
+
'/api/v1/billing',
|
|
49
|
+
'/api/v1/briefs',
|
|
50
|
+
'/api/v1/buyer-group',
|
|
51
|
+
'/api/v1/buyer-groups',
|
|
52
|
+
'/api/v1/calendar',
|
|
53
|
+
'/api/v1/calls',
|
|
54
|
+
'/api/v1/calling',
|
|
55
|
+
'/api/v1/campaigns',
|
|
56
|
+
'/api/v1/capabilities',
|
|
57
|
+
'/api/v1/causal',
|
|
58
|
+
'/api/v1/chat',
|
|
59
|
+
'/api/v1/clients',
|
|
60
|
+
// Adrata Cloud: governed, workspace-scoped, read-only semantic query over the
|
|
61
|
+
// bitemporal record substrate.
|
|
62
|
+
'/api/v1/cloud',
|
|
63
|
+
'/api/v1/coaching',
|
|
64
|
+
'/api/v1/common-models',
|
|
65
|
+
'/api/v1/communications',
|
|
66
|
+
'/api/v1/companies',
|
|
67
|
+
'/api/v1/company-lists',
|
|
68
|
+
'/api/v1/competitors',
|
|
69
|
+
'/api/v1/compliance',
|
|
70
|
+
'/api/v1/conformal',
|
|
71
|
+
'/api/v1/contacts',
|
|
72
|
+
'/api/v1/conversations',
|
|
73
|
+
'/api/v1/content',
|
|
74
|
+
'/api/v1/credits',
|
|
75
|
+
'/api/v1/crm',
|
|
76
|
+
'/api/v1/crm-overlay',
|
|
77
|
+
'/api/v1/crm-providers',
|
|
78
|
+
'/api/v1/custom-fields',
|
|
79
|
+
'/api/v1/custom-integrations',
|
|
80
|
+
'/api/v1/customer-signals',
|
|
81
|
+
'/api/v1/customers',
|
|
82
|
+
'/api/v1/data',
|
|
83
|
+
'/api/v1/data-lake',
|
|
84
|
+
'/api/v1/deletion',
|
|
85
|
+
'/api/v1/deal-authority',
|
|
86
|
+
'/api/v1/diagnostics',
|
|
87
|
+
'/api/v1/dhc-forecast',
|
|
88
|
+
'/api/v1/documents',
|
|
89
|
+
'/api/v1/drive',
|
|
90
|
+
'/api/v1/editions',
|
|
91
|
+
'/api/v1/email',
|
|
92
|
+
'/api/v1/email-analytics',
|
|
93
|
+
'/api/v1/email-deliverability',
|
|
94
|
+
'/api/v1/email-intelligence',
|
|
95
|
+
'/api/v1/email-provisioning',
|
|
96
|
+
'/api/v1/emails',
|
|
97
|
+
'/api/v1/engagement',
|
|
98
|
+
'/api/v1/entities',
|
|
99
|
+
'/api/v1/env-audit',
|
|
100
|
+
'/api/v1/events',
|
|
101
|
+
'/api/v1/evidence',
|
|
102
|
+
'/api/v1/expansion',
|
|
103
|
+
'/api/v1/extensions',
|
|
104
|
+
'/api/v1/feature-flags',
|
|
105
|
+
'/api/v1/feature-permissions',
|
|
106
|
+
'/api/v1/files',
|
|
107
|
+
'/api/v1/forecast',
|
|
108
|
+
'/api/v1/forecasting',
|
|
109
|
+
'/api/v1/goal',
|
|
110
|
+
'/api/v1/icp-drift',
|
|
111
|
+
'/api/v1/icp-profiles',
|
|
112
|
+
'/api/v1/icp-scoring',
|
|
113
|
+
'/api/v1/icp-user-profiles',
|
|
114
|
+
'/api/v1/identify',
|
|
115
|
+
'/api/v1/imessage',
|
|
116
|
+
'/api/v1/inbox',
|
|
117
|
+
'/api/v1/ingest',
|
|
118
|
+
'/api/v1/integrations',
|
|
119
|
+
'/api/v1/intelligence',
|
|
120
|
+
'/api/v1/intent-signals',
|
|
121
|
+
'/api/v1/jobs',
|
|
122
|
+
'/api/v1/knowledge',
|
|
123
|
+
'/api/v1/lead-lifecycle',
|
|
124
|
+
'/api/v1/lead-routing',
|
|
125
|
+
'/api/v1/leads',
|
|
126
|
+
'/api/v1/leaderboard',
|
|
127
|
+
'/api/v1/linkedin',
|
|
128
|
+
'/api/v1/media',
|
|
129
|
+
'/api/v1/meetings',
|
|
130
|
+
'/api/v1/memories',
|
|
131
|
+
'/api/v1/metrics',
|
|
132
|
+
'/api/v1/migrations',
|
|
133
|
+
'/api/v1/monaco',
|
|
134
|
+
'/api/v1/morning-brief',
|
|
135
|
+
'/api/v1/mcp',
|
|
136
|
+
'/api/v1/network',
|
|
137
|
+
'/api/v1/news',
|
|
138
|
+
'/api/v1/notes',
|
|
139
|
+
'/api/v1/opportunities',
|
|
140
|
+
'/api/v1/operations',
|
|
141
|
+
'/api/v1/orchestration',
|
|
142
|
+
'/api/v1/outbound',
|
|
143
|
+
'/api/v1/oauth',
|
|
144
|
+
'/api/v1/observability',
|
|
145
|
+
'/api/v1/onboarding',
|
|
146
|
+
'/api/v1/patterns',
|
|
147
|
+
'/api/v1/people',
|
|
148
|
+
'/api/v1/pipeline',
|
|
149
|
+
'/api/v1/pipeline-stages',
|
|
150
|
+
'/api/v1/platform',
|
|
151
|
+
'/api/v1/plays',
|
|
152
|
+
'/api/v1/privacy',
|
|
153
|
+
'/api/v1/providers',
|
|
154
|
+
'/api/v1/quotas',
|
|
155
|
+
'/api/v1/realtime',
|
|
156
|
+
'/api/v1/reports',
|
|
157
|
+
'/api/v1/research',
|
|
158
|
+
'/api/v1/retention',
|
|
159
|
+
'/api/v1/revenue',
|
|
160
|
+
'/api/v1/revenue-cloud',
|
|
161
|
+
'/api/v1/sales',
|
|
162
|
+
'/api/v1/scim',
|
|
163
|
+
'/api/v1/scoring',
|
|
164
|
+
'/api/v1/security',
|
|
165
|
+
'/api/v1/self-service',
|
|
166
|
+
'/api/v1/sequences',
|
|
167
|
+
'/api/v1/settings',
|
|
168
|
+
'/api/v1/signals',
|
|
169
|
+
'/api/v1/signal-triggers',
|
|
170
|
+
'/api/v1/simple-features',
|
|
171
|
+
'/api/v1/sms',
|
|
172
|
+
'/api/v1/sso',
|
|
173
|
+
'/api/v1/speedrun',
|
|
174
|
+
'/api/v1/supersets',
|
|
175
|
+
'/api/v1/survival',
|
|
176
|
+
'/api/v1/sync',
|
|
177
|
+
'/api/v1/synthesis',
|
|
178
|
+
'/api/v1/tasks',
|
|
179
|
+
'/api/v1/targets',
|
|
180
|
+
'/api/v1/team',
|
|
181
|
+
'/api/v1/trash',
|
|
182
|
+
'/api/v1/usage',
|
|
183
|
+
'/api/v1/user-voice-profile',
|
|
184
|
+
'/api/v1/users',
|
|
185
|
+
'/api/v1/videos',
|
|
186
|
+
'/api/v1/voice',
|
|
187
|
+
'/api/v1/warmup',
|
|
188
|
+
'/api/v1/webhooks',
|
|
189
|
+
// Starfield's work boards. Reads are ordinary; a card move is a governed
|
|
190
|
+
// write and goes through the same dryRun/approved/reason/idempotencyKey
|
|
191
|
+
// contract as every other write on this bridge.
|
|
192
|
+
'/api/v1/work-board-rollups',
|
|
193
|
+
'/api/v1/work-boards',
|
|
194
|
+
'/api/v1/work-items',
|
|
195
|
+
'/api/v1/workflow-runtime',
|
|
196
|
+
'/api/v1/workflows',
|
|
197
|
+
'/api/v1/workspace',
|
|
198
|
+
'/api/v1/workspace-features',
|
|
199
|
+
'/api/v1/workspace-settings',
|
|
200
|
+
'/api/v1/zoho',
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
function matchesPathPrefix(path, prefix) {
|
|
204
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function normalizeApiPath(path) {
|
|
208
|
+
if (!path || typeof path !== 'string') throw new Error('path is required');
|
|
209
|
+
if (!path.startsWith('/')) throw new Error('path must start with /');
|
|
210
|
+
if (path.includes('://')) throw new Error('path must be relative, not an absolute URL');
|
|
211
|
+
return path;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The write scope the API's scope_guard will demand for a given path.
|
|
216
|
+
*
|
|
217
|
+
* Longest prefix wins, so `/api/v1/company-lists` does not accidentally match
|
|
218
|
+
* `/api/v1/companies`. Returns null when no specific scope is known — the
|
|
219
|
+
* caller then reports "unknown" rather than asserting the write will succeed.
|
|
220
|
+
*/
|
|
221
|
+
const PATH_WRITE_SCOPES = [
|
|
222
|
+
['/api/v1/buyer-groups', 'write:buyer-groups'],
|
|
223
|
+
['/api/v1/buyer-group', 'write:buyer-groups'],
|
|
224
|
+
['/api/v1/opportunities', 'write:opportunities'],
|
|
225
|
+
['/api/v1/partnerships', 'write:partnerships'],
|
|
226
|
+
['/api/v1/partners', 'write:partnerships'],
|
|
227
|
+
['/api/v1/company-lists', 'write:companies'],
|
|
228
|
+
['/api/v1/companies', 'write:companies'],
|
|
229
|
+
['/api/v1/contacts', 'write:people'],
|
|
230
|
+
['/api/v1/people', 'write:people'],
|
|
231
|
+
['/api/v1/leads', 'write:people'],
|
|
232
|
+
['/api/v1/agent-tasks', 'write:tasks'],
|
|
233
|
+
['/api/v1/tasks', 'write:tasks'],
|
|
234
|
+
['/api/v1/actions', 'write:actions'],
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
export function requiredWriteScopeForPath(path) {
|
|
238
|
+
const normalized = normalizeApiPath(path);
|
|
239
|
+
let best = null;
|
|
240
|
+
for (const [prefix, scope] of PATH_WRITE_SCOPES) {
|
|
241
|
+
if (matchesPathPrefix(normalized, prefix)) {
|
|
242
|
+
if (!best || prefix.length > best.prefix.length) best = { prefix, scope };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return best ? best.scope : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function validateApiBridgeRequest({
|
|
249
|
+
method,
|
|
250
|
+
path,
|
|
251
|
+
dryRun,
|
|
252
|
+
approved,
|
|
253
|
+
reason,
|
|
254
|
+
idempotencyKey,
|
|
255
|
+
grantedScope,
|
|
256
|
+
}) {
|
|
257
|
+
const normalizedMethod = String(method || 'GET').toUpperCase();
|
|
258
|
+
const normalizedPath = normalizeApiPath(path);
|
|
259
|
+
|
|
260
|
+
if (!SAFE_METHODS.has(normalizedMethod)) {
|
|
261
|
+
throw new Error(`unsupported method: ${normalizedMethod}`);
|
|
262
|
+
}
|
|
263
|
+
if (BLOCKED_PREFIXES.some((prefix) => matchesPathPrefix(normalizedPath, prefix))) {
|
|
264
|
+
throw new Error(`blocked path for agent bridge: ${normalizedPath}`);
|
|
265
|
+
}
|
|
266
|
+
if (!ALLOWED_PREFIXES.some((prefix) => matchesPathPrefix(normalizedPath, prefix))) {
|
|
267
|
+
throw new Error(`path is outside the agent bridge allowlist: ${normalizedPath}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const isWrite = WRITE_METHODS.has(normalizedMethod);
|
|
271
|
+
if (isWrite && dryRun !== false) {
|
|
272
|
+
// A dry-run used to answer `wouldSend: true` unconditionally, which is the
|
|
273
|
+
// single most misleading thing this tool could say: it reported success for
|
|
274
|
+
// a connection holding zero write scopes, so the "safe preview" step
|
|
275
|
+
// predicted nothing about the live write that followed. Check the scope the
|
|
276
|
+
// connection actually holds and say so.
|
|
277
|
+
const requiredScope = requiredWriteScopeForPath(normalizedPath);
|
|
278
|
+
const held = String(grantedScope || '').split(/\s+/).filter(Boolean);
|
|
279
|
+
const scopeKnown = requiredScope !== null;
|
|
280
|
+
const hasScope = scopeKnown ? held.includes(requiredScope) : null;
|
|
281
|
+
const scopeChecked = grantedScope !== undefined && grantedScope !== null;
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
dryRun: true,
|
|
285
|
+
method: normalizedMethod,
|
|
286
|
+
path: normalizedPath,
|
|
287
|
+
// Only claim the request would be sent when nothing known blocks it.
|
|
288
|
+
wouldSend: scopeChecked && scopeKnown ? hasScope : true,
|
|
289
|
+
requiredScope: requiredScope ?? 'unknown (no mapping for this path)',
|
|
290
|
+
scopeHeld: scopeChecked ? hasScope : 'not checked (no stored connection)',
|
|
291
|
+
...(scopeChecked && scopeKnown && !hasScope
|
|
292
|
+
? {
|
|
293
|
+
blocked: true,
|
|
294
|
+
reason: `connection lacks ${requiredScope}; the API will reject this with 403 insufficient_scope`,
|
|
295
|
+
fix: 'run connect_workspace with writeAccess:true and approve the scope on the consent screen',
|
|
296
|
+
}
|
|
297
|
+
: {}),
|
|
298
|
+
requiredForLiveWrite: ['dryRun:false', 'approved:true', 'reason', 'idempotencyKey'],
|
|
299
|
+
note: 'Dry-run checks method, path allowlist, and write scope. It does NOT validate the body against the endpoint schema.',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (isWrite) {
|
|
304
|
+
if (approved !== true) throw new Error('live writes require approved:true');
|
|
305
|
+
if (!reason || !String(reason).trim()) throw new Error('live writes require a reason');
|
|
306
|
+
if (!idempotencyKey || !String(idempotencyKey).trim()) {
|
|
307
|
+
throw new Error('live writes require an idempotencyKey');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { dryRun: false, method: normalizedMethod, path: normalizedPath };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Make a value safe to put in an HTTP header.
|
|
316
|
+
*
|
|
317
|
+
* Header values are a ByteString — Latin-1, one byte per character — and
|
|
318
|
+
* `fetch` throws outright on anything above U+00FF rather than dropping it:
|
|
319
|
+
*
|
|
320
|
+
* TypeError: Cannot convert argument to a ByteString because the character
|
|
321
|
+
* at index 28 has a value of 8212 which is greater than 255.
|
|
322
|
+
*
|
|
323
|
+
* 8212 is an em dash. A human writing an audit reason produces em dashes,
|
|
324
|
+
* curly quotes, arrows and accented names constantly — this repo's own skills
|
|
325
|
+
* suggest "Picked up — reproducing against staging first" — so without this the
|
|
326
|
+
* governed write path fails on the most natural reason anybody would type, and
|
|
327
|
+
* fails at the transport layer where the message names a character index rather
|
|
328
|
+
* than the field. Measured against the running stack on 2026-08-04: a live
|
|
329
|
+
* claim threw before the request was ever sent.
|
|
330
|
+
*
|
|
331
|
+
* Percent-encoding rather than stripping, because the header is an audit
|
|
332
|
+
* artefact: silently dropping characters would leave a reason that reads
|
|
333
|
+
* subtly differently from the one the caller gave. The BODY still carries the
|
|
334
|
+
* exact text — that is what lands on `work_item_column_history.reason` and what
|
|
335
|
+
* a human reads on the card — so nothing about the stored record changes here.
|
|
336
|
+
*/
|
|
337
|
+
export function toHeaderValue(value) {
|
|
338
|
+
return String(value).replace(/[^\x20-\x7E]/g, (character) => encodeURIComponent(character));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function buildMutationHeaders({
|
|
342
|
+
reason,
|
|
343
|
+
idempotencyKey,
|
|
344
|
+
approved,
|
|
345
|
+
actor = 'mcp-agent',
|
|
346
|
+
}) {
|
|
347
|
+
const headers = {};
|
|
348
|
+
if (reason) headers['X-Adrata-Reason'] = toHeaderValue(reason);
|
|
349
|
+
if (idempotencyKey) headers['Idempotency-Key'] = toHeaderValue(idempotencyKey);
|
|
350
|
+
if (approved === true) headers['X-Adrata-Approved'] = 'true';
|
|
351
|
+
if (actor) headers['X-Adrata-Actor'] = toHeaderValue(actor);
|
|
352
|
+
return headers;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Run a *typed* tool's write through exactly the governance the generic
|
|
357
|
+
* `adrata_api_request` bridge enforces.
|
|
358
|
+
*
|
|
359
|
+
* # Why this exists
|
|
360
|
+
*
|
|
361
|
+
* `validateApiBridgeRequest` + `buildMutationHeaders` were used at only two
|
|
362
|
+
* call sites (`adrata_api_request` and `move_pipeline_card`). Every typed
|
|
363
|
+
* write tool called `api()` raw, so the purpose-built tools were strictly LESS
|
|
364
|
+
* safe than the generic fallback they exist to replace: a caller could delete
|
|
365
|
+
* 50 buyer rooms — each cascading to its member seats — with one tool call,
|
|
366
|
+
* no confirmation, no audit reason, and no idempotency key, while the very
|
|
367
|
+
* same DELETE routed through `adrata_api_request` demanded all three.
|
|
368
|
+
*
|
|
369
|
+
* A write therefore previews by default and only executes with
|
|
370
|
+
* `dryRun:false` + `approved:true` + `reason` + `idempotencyKey`. The preview
|
|
371
|
+
* is not a courtesy — it is the refusal, rendered as the exact call that was
|
|
372
|
+
* withheld plus what is missing to authorize it.
|
|
373
|
+
*
|
|
374
|
+
* This governs the *client*. It does not replace the server-side scope check:
|
|
375
|
+
* the API still rejects a token without the matching `write:*` scope with 403
|
|
376
|
+
* insufficient_scope.
|
|
377
|
+
*
|
|
378
|
+
* @param api the server's `api(method, path, opts)` callable
|
|
379
|
+
* @param args the tool arguments, carrying dryRun/approved/reason/idempotencyKey
|
|
380
|
+
* @param request {method, path, body, preview} describing the intended call
|
|
381
|
+
* @returns `{ dryRun: true, preview }` or `{ dryRun: false, result }`
|
|
382
|
+
*/
|
|
383
|
+
export async function governedWrite(api, args = {}, { method, path, body, preview = {} } = {}) {
|
|
384
|
+
const validation = validateApiBridgeRequest({
|
|
385
|
+
method,
|
|
386
|
+
path,
|
|
387
|
+
dryRun: args.dryRun,
|
|
388
|
+
approved: args.approved,
|
|
389
|
+
reason: args.reason,
|
|
390
|
+
idempotencyKey: args.idempotencyKey,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
if (validation.dryRun) {
|
|
394
|
+
return {
|
|
395
|
+
dryRun: true,
|
|
396
|
+
preview: {
|
|
397
|
+
...validation,
|
|
398
|
+
body: body ?? {},
|
|
399
|
+
...preview,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const result = await api(validation.method, validation.path, {
|
|
405
|
+
body,
|
|
406
|
+
headers: buildMutationHeaders(args),
|
|
407
|
+
});
|
|
408
|
+
return { dryRun: false, result };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function buildRealtimePayload({
|
|
412
|
+
workspaceId,
|
|
413
|
+
userId = 'agent',
|
|
414
|
+
entityType,
|
|
415
|
+
entityId,
|
|
416
|
+
entityName,
|
|
417
|
+
changes = {},
|
|
418
|
+
type = 'record_updated',
|
|
419
|
+
source = 'agent',
|
|
420
|
+
}) {
|
|
421
|
+
const event = {
|
|
422
|
+
type,
|
|
423
|
+
source,
|
|
424
|
+
workspaceId,
|
|
425
|
+
userId,
|
|
426
|
+
timestamp: new Date().toISOString(),
|
|
427
|
+
entityType,
|
|
428
|
+
entityId,
|
|
429
|
+
entityName,
|
|
430
|
+
changes,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
if (type === 'pipeline_stage_changed') {
|
|
434
|
+
event.previousStage = changes.previousStage;
|
|
435
|
+
event.newStage = changes.newStage;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const channels = [`workspace:${workspaceId}`];
|
|
439
|
+
if (entityType === 'company') channels.push(`company:${entityId}`);
|
|
440
|
+
if (entityType === 'opportunity') channels.push(`deal:${entityId}`);
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
channels,
|
|
444
|
+
event: 'voice-update',
|
|
445
|
+
data: event,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function apiBridgeCatalog() {
|
|
450
|
+
return {
|
|
451
|
+
purpose:
|
|
452
|
+
'Governed headless access to Adrata platform APIs from Claude Code, Codex, and MCP clients.',
|
|
453
|
+
read: 'GET requests run directly against allowlisted /api/v1 platform APIs.',
|
|
454
|
+
write:
|
|
455
|
+
'POST/PUT/PATCH/DELETE default to dry-run. Live writes require dryRun:false, approved:true, reason, and idempotencyKey.',
|
|
456
|
+
leastPrivilege:
|
|
457
|
+
'adrata_api_request blocks sensitive administrative surfaces even when their route families exist in the broad platform allowlist. Use purpose-built MCP tools or adrata_ai_tool_execute instead.',
|
|
458
|
+
approvalEnvelopeGap:
|
|
459
|
+
'The MCP bridge adds approval, reason, actor, and Idempotency-Key metadata for live writes, but not every downstream API endpoint has a uniform server-side approval/idempotency envelope yet. Treat adrata_api_request as a governed fallback and prefer targeted tools.',
|
|
460
|
+
realtime: 'Use realtimePublish for UI fan-out, or use move_pipeline_card for board moves.',
|
|
461
|
+
aiToolDispatcher:
|
|
462
|
+
'Use adrata_ai_tool_catalog and adrata_ai_tool_execute for the same Channels, external catalogs, ICP ranking, and batch import tools Adrata chat uses.',
|
|
463
|
+
blocked: BLOCKED_PREFIXES,
|
|
464
|
+
blockedSurfaces: BLOCKED_SURFACES,
|
|
465
|
+
allowed: ALLOWED_PREFIXES,
|
|
466
|
+
examples: [
|
|
467
|
+
{
|
|
468
|
+
tool: 'adrata_api_request',
|
|
469
|
+
read: { method: 'GET', path: '/api/v1/opportunities', params: { limit: 10 } },
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
tool: 'move_pipeline_card',
|
|
473
|
+
write: {
|
|
474
|
+
entityType: 'opportunity',
|
|
475
|
+
entityId: 'opp_123',
|
|
476
|
+
targetStage: 'proposal',
|
|
477
|
+
targetRank: 1,
|
|
478
|
+
approved: true,
|
|
479
|
+
reason: 'User asked agent to move the deal card',
|
|
480
|
+
idempotencyKey: 'move-opp_123-proposal-1',
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
tool: 'list_external_pipelines',
|
|
485
|
+
read: { kind: 'company', adapter: 'yc_oss_api', limit: 25 },
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
tool: 'import_external_pipeline_members',
|
|
489
|
+
write: {
|
|
490
|
+
channelId: '01KF...',
|
|
491
|
+
topNByIcp: 20,
|
|
492
|
+
dryRun: false,
|
|
493
|
+
approved: true,
|
|
494
|
+
reason: 'User asked agent to import the top-fit external pipeline members',
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
};
|
|
499
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governance envelope for MONEY writes, plus client-side pagination caps for
|
|
3
|
+
* finance list tools.
|
|
4
|
+
*
|
|
5
|
+
* Money writes — partner revenue attribution, finance approval of an
|
|
6
|
+
* attribution, and committed-vs-consumed revenue records — follow the same
|
|
7
|
+
* contract as the governed api-bridge (`api-bridge.js`):
|
|
8
|
+
*
|
|
9
|
+
* 1. `dryRun` defaults to TRUE. The default call returns a preview of the
|
|
10
|
+
* exact request that would be sent (method, path, validated payload) and
|
|
11
|
+
* writes nothing.
|
|
12
|
+
* 2. Live execution requires ALL of:
|
|
13
|
+
* - `dryRun: false`
|
|
14
|
+
* - `approved: true` (explicit approval of this specific write)
|
|
15
|
+
* - `auditReason` (non-empty; why the write is happening)
|
|
16
|
+
* - `idempotencyKey` (unique string; reuse the SAME key on retry
|
|
17
|
+
* so a network blip cannot double-book revenue)
|
|
18
|
+
* 3. A live write issues exactly one POST, carrying the reason and key as
|
|
19
|
+
* `X-Adrata-Reason` / `Idempotency-Key` headers via the shared
|
|
20
|
+
* `buildMutationHeaders` helper.
|
|
21
|
+
*
|
|
22
|
+
* NOTE: the Rust partner routes (`code/api/src/routes/v1/data/partners.rs`) do
|
|
23
|
+
* not yet read the `Idempotency-Key` header — server-side dedup enforcement is
|
|
24
|
+
* a follow-up. The header is sent now so the contract is stable and the API
|
|
25
|
+
* can start honouring it without another MCP release.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { buildMutationHeaders } from '../api-bridge.js';
|
|
29
|
+
|
|
30
|
+
/** What a caller must supply to turn a preview into a live money write. */
|
|
31
|
+
export const MONEY_WRITE_REQUIREMENTS = Object.freeze([
|
|
32
|
+
'dryRun:false',
|
|
33
|
+
'approved:true',
|
|
34
|
+
'auditReason',
|
|
35
|
+
'idempotencyKey',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether a money-write call is a dry-run preview or a validated live
|
|
40
|
+
* write. Pure — never performs I/O.
|
|
41
|
+
*
|
|
42
|
+
* @returns `{ dryRun: true, ...preview }` for previews, or
|
|
43
|
+
* `{ dryRun: false, method, path, body, headers }` for a validated
|
|
44
|
+
* live write. Throws when a live write is missing approval, reason,
|
|
45
|
+
* or idempotency key.
|
|
46
|
+
*/
|
|
47
|
+
export function evaluateMoneyWrite({
|
|
48
|
+
tool,
|
|
49
|
+
method = 'POST',
|
|
50
|
+
path,
|
|
51
|
+
payload,
|
|
52
|
+
dryRun,
|
|
53
|
+
approved,
|
|
54
|
+
auditReason,
|
|
55
|
+
idempotencyKey,
|
|
56
|
+
}) {
|
|
57
|
+
if (dryRun !== false) {
|
|
58
|
+
return {
|
|
59
|
+
dryRun: true,
|
|
60
|
+
tool,
|
|
61
|
+
wouldSend: { method, path, body: payload ?? null },
|
|
62
|
+
requiredForLiveWrite: MONEY_WRITE_REQUIREMENTS,
|
|
63
|
+
note: 'Dry run — nothing was written. Review the payload above, then re-call with dryRun:false, approved:true, a non-empty auditReason, and an idempotencyKey to execute.',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (approved !== true) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${tool} is a live money write: it requires approved:true (explicit approval), plus auditReason and idempotencyKey. Call without dryRun:false first to preview.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!auditReason || !String(auditReason).trim()) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`${tool} is a live money write: it requires a non-empty auditReason explaining why this write is happening.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!idempotencyKey || !String(idempotencyKey).trim()) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${tool} is a live money write: it requires an idempotencyKey (any unique string, e.g. a ULID). Reuse the SAME key when retrying so the write cannot double-book.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
dryRun: false,
|
|
83
|
+
method,
|
|
84
|
+
path,
|
|
85
|
+
body: payload,
|
|
86
|
+
headers: buildMutationHeaders({ reason: auditReason, idempotencyKey }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run the full money-write flow against the shared `api` helper: return the
|
|
92
|
+
* preview on dry-run, or issue exactly one governed request on a validated
|
|
93
|
+
* live write.
|
|
94
|
+
*/
|
|
95
|
+
export async function executeMoneyWrite(api, spec) {
|
|
96
|
+
const decision = evaluateMoneyWrite(spec);
|
|
97
|
+
if (decision.dryRun) return decision;
|
|
98
|
+
return api(decision.method, decision.path, {
|
|
99
|
+
body: decision.body,
|
|
100
|
+
headers: decision.headers,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Client-side pagination caps
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Hard cap applied when a backing route returns an unbounded list. The Rust
|
|
110
|
+
* partner sub-list routes (attributions, work-streams, EPMs, consumption) and
|
|
111
|
+
* the installed-base invoices route accept no limit/offset, so the cap lives
|
|
112
|
+
* here until the routes grow real pagination.
|
|
113
|
+
*/
|
|
114
|
+
export const CLIENT_PAGE_CAP = 200;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply limit/offset to an already-fetched list. Used for tools whose backing
|
|
118
|
+
* route supports no pagination: the MCP server truncates the response instead
|
|
119
|
+
* of flooding the model's context. Non-array input passes through untouched.
|
|
120
|
+
*
|
|
121
|
+
* @returns `{ rows, page }` — `page` is null for non-array input, otherwise
|
|
122
|
+
* `{ limit, offset, returned, totalFetched, truncated, note }`.
|
|
123
|
+
*/
|
|
124
|
+
export function capClientSide(rows, { limit, offset } = {}) {
|
|
125
|
+
if (!Array.isArray(rows)) return { rows, page: null };
|
|
126
|
+
const off = Math.max(0, Math.trunc(Number(offset) || 0));
|
|
127
|
+
const requested = Math.trunc(Number(limit) || 0);
|
|
128
|
+
const lim = requested > 0 ? Math.min(requested, CLIENT_PAGE_CAP) : CLIENT_PAGE_CAP;
|
|
129
|
+
const sliced = rows.slice(off, off + lim);
|
|
130
|
+
return {
|
|
131
|
+
rows: sliced,
|
|
132
|
+
page: {
|
|
133
|
+
limit: lim,
|
|
134
|
+
offset: off,
|
|
135
|
+
returned: sliced.length,
|
|
136
|
+
totalFetched: rows.length,
|
|
137
|
+
truncated: off + sliced.length < rows.length,
|
|
138
|
+
note: 'Pagination applied client-side by the MCP server; the backing API route returns the full list.',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|