@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.
Files changed (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. 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
+ }