@adrata/adrata-mcp 1.0.0 → 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.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Shared governed-write plumbing for the toolset packs.
3
+ *
4
+ * # Why this file exists
5
+ *
6
+ * `governedWrite` (api-bridge.js) is the enforcement point: a write previews by
7
+ * default and only executes with `dryRun:false` + `approved:true` + a non-empty
8
+ * `reason` + a non-empty `idempotencyKey`. server.js wired 18 tools to it, but
9
+ * it did so with two private helpers — `governedWriteArgs` (the zod fields) and
10
+ * `governedWriteNote` (the description sentence) — that live inside server.js
11
+ * and cannot be imported. Every other pack (`toolsets/*.js`, `tools/*.js`) was
12
+ * therefore left calling `api()` raw, which is exactly the asymmetry
13
+ * `governedWrite` was written to end: the purpose-built tool was strictly less
14
+ * safe than the generic `adrata_api_request` fallback it exists to replace.
15
+ *
16
+ * Copying the schema into five files is how a governance contract drifts — one
17
+ * file gains a field, another keeps the old wording, and the gate silently
18
+ * differs by tool. So the schema, the description sentence, the refusal text
19
+ * and the preview renderer live here once, and the packs import them.
20
+ *
21
+ * This governs the *client*. It does not replace the server-side scope check:
22
+ * the API still rejects a token without the matching `write:*` scope with 403
23
+ * insufficient_scope.
24
+ *
25
+ * It lives in `governance/` rather than at the package root next to
26
+ * `governed-args`-style helpers because `package.json`'s `files:` array names
27
+ * root modules one by one and whole directories wholesale — `governance/` is
28
+ * already shipped, a new root file would not be, and packaging.test.js's "ships
29
+ * every source file" check would fail on a module that exists in the checkout
30
+ * and is missing from the tarball. `governance/money.js` is the same idea for
31
+ * money writes, so this is where a governance envelope belongs anyway.
32
+ */
33
+
34
+ import { z as defaultZod } from 'zod';
35
+
36
+ import { governedWrite } from '../api-bridge.js';
37
+ import { md, mdError } from '../output-formatter.js';
38
+
39
+ /**
40
+ * Coresignal charges 20 credits for a `collect/{employee_id}` — one person.
41
+ * See CLAUDE.md ("Coresignal credits are the other real bill") and
42
+ * `code/api/crates/integrations/src/coresignal/company_multi_source.rs`, whose
43
+ * `COMPANY_MULTI_SOURCE_CREDITS_PER_CALL` is likewise 20.
44
+ *
45
+ * This number is stated in the tool description AND repeated in the preview on
46
+ * purpose: a caller who only ever reads the preview must still see the bill
47
+ * before approving it.
48
+ */
49
+ export const CORESIGNAL_CREDITS_PER_PERSON = 20;
50
+ export const CORESIGNAL_CREDITS_PER_COMPANY = 20;
51
+
52
+ export const PERSON_ENRICH_COST_SENTENCE =
53
+ ` SPENDS VENDOR CREDITS: a Coresignal collect is ${CORESIGNAL_CREDITS_PER_PERSON} credits per person, charged to the workspace's shared credit pool.`;
54
+
55
+ export const COMPANY_ENRICH_COST_SENTENCE =
56
+ ` SPENDS VENDOR CREDITS: a Coresignal company multi-source call is ${CORESIGNAL_CREDITS_PER_COMPANY} credits per company, charged to the workspace's shared credit pool.`;
57
+
58
+ /** The cost line rendered inside an enrich preview. */
59
+ export function enrichCostLine(kind) {
60
+ return kind === 'company'
61
+ ? `${CORESIGNAL_CREDITS_PER_COMPANY} Coresignal credits per company (vendor spend, not refundable)`
62
+ : `${CORESIGNAL_CREDITS_PER_PERSON} Coresignal credits per person (vendor spend, not refundable)`;
63
+ }
64
+
65
+ /**
66
+ * Governance fields every governed write tool accepts.
67
+ *
68
+ * Takes the caller's `zod` so a pack that receives `z` through its dependency
69
+ * object (tools/email-tools.js) builds its schema with the same instance the
70
+ * rest of that tool's schema uses.
71
+ */
72
+ export function governedWriteArgs(zod = defaultZod) {
73
+ return {
74
+ dryRun: zod
75
+ .boolean()
76
+ .optional()
77
+ .describe('Defaults to true. Returns a preview of the exact call instead of performing it. Set false to execute.'),
78
+ approved: zod
79
+ .boolean()
80
+ .optional()
81
+ .describe('Required (true) for a live write. Records that the caller confirmed the mutation; it does not by itself grant scope.'),
82
+ reason: zod
83
+ .string()
84
+ .optional()
85
+ .describe('Required for a live write. Recorded as the audit reason (X-Adrata-Reason).'),
86
+ idempotencyKey: zod
87
+ .string()
88
+ .optional()
89
+ .describe('Required for a live write. Sent as Idempotency-Key so a retry cannot double-apply.'),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * The sentence appended to a governed tool's description.
95
+ *
96
+ * It names the scope because that is the one thing the caller cannot guess and
97
+ * the API will reject them for: a 403 `insufficient_scope` on `write:people`
98
+ * reads like a bug unless the tool already said which scope it needed. Pass
99
+ * `null` when the route genuinely requires no OAuth write scope.
100
+ */
101
+ export function governedWriteNote(scope) {
102
+ const base =
103
+ ' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey';
104
+ return scope
105
+ ? `${base}, and the connection must hold ${scope} (connect_workspace with writeAccess:true).`
106
+ : `${base}. This route currently requires no OAuth write scope, so approval and audit are the only gate.`;
107
+ }
108
+
109
+ /** True when the caller has explicitly asked for a live write. */
110
+ export function isLiveWrite(args = {}) {
111
+ return args?.dryRun === false;
112
+ }
113
+
114
+ /**
115
+ * Which authorization fields a live write is still missing.
116
+ *
117
+ * `validateApiBridgeRequest` throws on the first one it finds, which tells a
118
+ * caller to add a reason and then, one round trip later, to add an idempotency
119
+ * key. Listing them together is the same gate, said once.
120
+ */
121
+ export function missingLiveWriteFields(args = {}) {
122
+ const missing = [];
123
+ if (args.approved !== true) missing.push('approved:true');
124
+ if (!args.reason || !String(args.reason).trim()) missing.push('reason');
125
+ if (!args.idempotencyKey || !String(args.idempotencyKey).trim()) missing.push('idempotencyKey');
126
+ return missing;
127
+ }
128
+
129
+ /** The refusal payload for a live write that is missing authorization fields. */
130
+ export function liveWriteRefusal(args = {}, request = {}) {
131
+ const missing = missingLiveWriteFields(args);
132
+ return {
133
+ error: true,
134
+ refused: true,
135
+ missing,
136
+ message:
137
+ `live write refused: missing ${missing.join(', ')}. ` +
138
+ `Nothing was sent to ${request.method || 'the API'} ${request.path || ''}`.trimEnd() + '.',
139
+ wouldHaveCalled: { method: request.method, path: request.path },
140
+ };
141
+ }
142
+
143
+ const PREVIEW_LABELS = {
144
+ method: 'Method',
145
+ path: 'Path',
146
+ wouldSend: 'Would send',
147
+ requiredScope: 'Required scope',
148
+ scopeHeld: 'Scope held',
149
+ entity: 'Entity',
150
+ entityId: 'Entity ID',
151
+ operation: 'Operation',
152
+ cost: 'Cost if executed',
153
+ affects: 'Would affect',
154
+ cascade: 'Cascades to',
155
+ };
156
+
157
+ /**
158
+ * Render a governed-write preview as Markdown.
159
+ *
160
+ * The preview is not a courtesy — it IS the refusal, rendered as the exact call
161
+ * that was withheld plus what is missing to authorize it. So it always prints
162
+ * the method and path, never a bare "ok".
163
+ */
164
+ export function previewMarkdown(heading, preview = {}) {
165
+ const {
166
+ method,
167
+ path,
168
+ wouldSend,
169
+ requiredScope,
170
+ scopeHeld,
171
+ requiredForLiveWrite,
172
+ note,
173
+ body,
174
+ blocked,
175
+ fix,
176
+ reason,
177
+ ...extra
178
+ } = preview;
179
+
180
+ let text = `## ${heading} — Preview Only (nothing was written)\n\n`;
181
+ text += `- **Method:** ${method}\n`;
182
+ text += `- **Path:** ${path}\n`;
183
+ text += `- **Required scope:** ${requiredScope}\n`;
184
+ text += `- **Scope held:** ${scopeHeld}\n`;
185
+
186
+ for (const [key, value] of Object.entries(extra)) {
187
+ if (value === undefined || value === null) continue;
188
+ const label = PREVIEW_LABELS[key] || key;
189
+ const rendered =
190
+ typeof value === 'object' ? JSON.stringify(value) : String(value);
191
+ text += `- **${label}:** ${rendered}\n`;
192
+ }
193
+
194
+ if (blocked) {
195
+ text += `\n> **Blocked:** ${reason}\n>\n> Fix: ${fix}\n`;
196
+ }
197
+
198
+ if (body && Object.keys(body).length > 0) {
199
+ text += `\n### Body that would be sent\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\`\n`;
200
+ }
201
+
202
+ text += `\n### To execute\n\nRe-call with \`dryRun:false\`, \`approved:true\`, a \`reason\`, and an \`idempotencyKey\`.\n`;
203
+ if (note) text += `\n${note}\n`;
204
+ return text;
205
+ }
206
+
207
+ /**
208
+ * Run a Markdown-returning tool's write through the governed contract.
209
+ *
210
+ * @param api the pack's `api(method, path, opts)` callable
211
+ * @param args tool arguments carrying dryRun/approved/reason/idempotencyKey
212
+ * @param request {method, path, body, preview, heading}
213
+ * @param onSuccess (result) => Markdown string, rendered only on a live write
214
+ */
215
+ export async function runGovernedMarkdownWrite(api, args, request, onSuccess) {
216
+ const { heading = 'Governed write', ...call } = request;
217
+
218
+ if (isLiveWrite(args)) {
219
+ const missing = missingLiveWriteFields(args);
220
+ if (missing.length > 0) {
221
+ return mdError(
222
+ `Live write refused — missing ${missing.join(', ')}`,
223
+ `Nothing was sent to ${call.method} ${call.path}. Re-call with dryRun:false plus ${missing.join(', ')}.`,
224
+ );
225
+ }
226
+ }
227
+
228
+ try {
229
+ const outcome = await governedWrite(api, args, call);
230
+ if (outcome.dryRun) return md(previewMarkdown(heading, outcome.preview));
231
+ return md(onSuccess(outcome.result));
232
+ } catch (err) {
233
+ return mdError(`${heading} failed`, err.message);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Run a JSON-returning tool's write through the governed contract.
239
+ *
240
+ * @param api the pack's `api(method, path, opts)` callable
241
+ * @param args tool arguments carrying dryRun/approved/reason/idempotencyKey
242
+ * @param request {method, path, body, preview}
243
+ * @param ok the pack's `ok(data)` envelope helper
244
+ * @param onSuccess (result) => object, merged into the live-write envelope
245
+ */
246
+ export async function runGovernedJsonWrite(api, args, request, ok, onSuccess = (r) => ({ result: r })) {
247
+ if (isLiveWrite(args)) {
248
+ const missing = missingLiveWriteFields(args);
249
+ if (missing.length > 0) return ok(liveWriteRefusal(args, request));
250
+ }
251
+
252
+ try {
253
+ const outcome = await governedWrite(api, args, request);
254
+ if (outcome.dryRun) {
255
+ return ok({ dryRun: true, executed: false, preview: outcome.preview });
256
+ }
257
+ return ok({ dryRun: false, executed: true, ...onSuccess(outcome.result) });
258
+ } catch (err) {
259
+ return ok({ error: true, executed: false, message: err.message });
260
+ }
261
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.0",
4
- "description": "Adrata MCP Server connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. 80+ tools for companies, people, deals, actions, buyer groups, warm intros, webhooks, intelligence, and more.",
3
+ "version": "1.0.2",
4
+ "description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. 80+ tools for companies, people, deals, actions, buyer groups, warm intros, webhooks, intelligence, and more.",
5
5
  "type": "module",
6
6
  "main": "server.js",
7
7
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
- "test": "node --test server.test.js api-bridge.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js security.test.js toolsets.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js governance/money.test.js"
12
+ "test": "node --test server.test.js api-bridge.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js tool-annotations.test.js toolsets.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js scripts/local-dev-server.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/work-hub/audit.test.js tools/roadmap-tools.test.js governance/money.test.js"
13
13
  },
14
14
  "keywords": [
15
15
  "mcp",
@@ -32,7 +32,8 @@
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "https://gitlab.com/adratagit/speedrun"
35
+ "url": "https://github.com/adrata/adrata",
36
+ "directory": "code/mcp"
36
37
  },
37
38
  "homepage": "https://adrata.com",
38
39
  "engines": {
@@ -46,6 +47,7 @@
46
47
  "transport-http.js",
47
48
  "resources.js",
48
49
  "tool-annotations.js",
50
+ "product-profile.js",
49
51
  "output-formatter.js",
50
52
  "access/",
51
53
  "tools/",
@@ -62,7 +64,10 @@
62
64
  },
63
65
  "overrides": {
64
66
  "@hono/node-server": "2.0.12",
65
- "fast-uri": "3.1.4",
66
- "hono": "4.12.27"
67
+ "fast-uri": "3.1.5",
68
+ "hono": "4.12.34"
69
+ },
70
+ "bugs": {
71
+ "url": "https://github.com/adrata/adrata/issues"
67
72
  }
68
73
  }
@@ -0,0 +1,43 @@
1
+ /** Branded MCP launch profiles over one shared server implementation. */
2
+ export const PRODUCT_PROFILES = Object.freeze({
3
+ adrata: Object.freeze({ displayName: 'Adrata', domains: null }),
4
+ bounce: Object.freeze({
5
+ displayName: 'Bounce',
6
+ domains: Object.freeze([
7
+ 'actions', 'agent', 'bridge', 'calendar', 'email', 'infra',
8
+ 'meetings', 'sequences', 'webhooks',
9
+ ]),
10
+ }),
11
+ starfield: Object.freeze({
12
+ displayName: 'Starfield',
13
+ domains: Object.freeze([
14
+ 'agent', 'board', 'bridge', 'forms', 'surveys', 'webhooks', 'workflows',
15
+ ]),
16
+ }),
17
+ });
18
+
19
+ export function applyProductProfile(env = process.env) {
20
+ const product = env.ADRATA_MCP_PRODUCT?.trim().toLowerCase();
21
+ if (!product) return null;
22
+ const profile = PRODUCT_PROFILES[product];
23
+ if (!profile) {
24
+ throw new Error(
25
+ `Unknown ADRATA_MCP_PRODUCT "${product}". Expected ${Object.keys(PRODUCT_PROFILES).join(', ')}.`,
26
+ );
27
+ }
28
+ if (!env.ADRATA_MCP_SERVER_NAME?.trim()) env.ADRATA_MCP_SERVER_NAME = profile.displayName;
29
+ // Explicit operator policy wins. A profile only supplies a least-privilege default.
30
+ if (!env.ADRATA_MCP_ENABLED_DOMAINS?.trim() && profile.domains) {
31
+ env.ADRATA_MCP_ENABLED_DOMAINS = profile.domains.join(',');
32
+ }
33
+ return profile;
34
+ }
35
+
36
+ export function assertProductCapabilityRef(ref, product) {
37
+ const normalizedProduct = String(product ?? '').trim().toLowerCase();
38
+ if (!normalizedProduct) return ref;
39
+ if (typeof ref !== 'string' || !ref.startsWith(`/${normalizedProduct}/`)) {
40
+ throw new Error(`Capability ${String(ref)} is outside the /${normalizedProduct}/ product scope.`);
41
+ }
42
+ return ref;
43
+ }
package/resources.js CHANGED
@@ -225,18 +225,30 @@ Use connect_workspace to get started.`;
225
225
  * @param {Function} apiFn - The api() helper for server calls
226
226
  * @param {object} auth - AUTH context { tier, token, apiKey, authenticated }
227
227
  */
228
+ export function resourceNamesForProduct(product = process.env.ADRATA_MCP_PRODUCT) {
229
+ const normalized = String(product ?? '').trim().toLowerCase();
230
+ return !normalized || normalized === 'adrata'
231
+ ? ['recent-research', 'sales-playbook', 'workspace-summary']
232
+ : ['workspace-summary'];
233
+ }
234
+
228
235
  export function registerResources(server, apiFn, auth) {
229
- server.resource(
230
- 'recent-research',
231
- 'adrata://recent-research',
232
- buildRecentResearchHandler(apiFn, auth)
233
- );
236
+ const enabled = new Set(resourceNamesForProduct());
237
+ if (enabled.has('recent-research')) {
238
+ server.resource(
239
+ 'recent-research',
240
+ 'adrata://recent-research',
241
+ buildRecentResearchHandler(apiFn, auth)
242
+ );
243
+ }
234
244
 
235
- server.resource(
236
- 'sales-playbook',
237
- 'adrata://guide/sales-playbook',
238
- buildSalesPlaybookHandler()
239
- );
245
+ if (enabled.has('sales-playbook')) {
246
+ server.resource(
247
+ 'sales-playbook',
248
+ 'adrata://guide/sales-playbook',
249
+ buildSalesPlaybookHandler()
250
+ );
251
+ }
240
252
 
241
253
  server.resource(
242
254
  'workspace-summary',
package/security.js CHANGED
@@ -300,6 +300,16 @@ class AuditLogger {
300
300
  this.maxBufferedEntries = 1000;
301
301
  this.flushIntervalMs = 30_000;
302
302
  this.flushing = false;
303
+ // Consecutive failed flushes. Telemetry is best-effort, so a server that is
304
+ // permanently unable to accept it must not produce an unbounded stream of
305
+ // stderr on somebody's terminal: the MCP's own audit endpoint answered 500
306
+ // to every batch for the whole life of the 1.0.x line, and the visible
307
+ // result was "[audit] Failed to flush N audit entries" every 30 seconds,
308
+ // forever, in a client the user is trying to work in. After
309
+ // `maxConsecutiveFailures` the logger says so ONCE and stands down.
310
+ this.consecutiveFailures = 0;
311
+ this.maxConsecutiveFailures = 3;
312
+ this.disabled = false;
303
313
  this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
304
314
  if (this.flushTimer.unref) this.flushTimer.unref();
305
315
  }
@@ -309,7 +319,7 @@ class AuditLogger {
309
319
  * @param {{ tool: string, tier: string, action: string, args: object, userId: string|null, sessionKey: string, timestamp: string }} entry
310
320
  */
311
321
  log(entry) {
312
- if (!TELEMETRY_ENABLED) return;
322
+ if (!TELEMETRY_ENABLED || this.disabled) return;
313
323
 
314
324
  this.buffer.push({
315
325
  tool: entry.tool,
@@ -332,18 +342,36 @@ class AuditLogger {
332
342
  * Silently drops entries if the API is unavailable (non-blocking).
333
343
  */
334
344
  async flush() {
335
- if (this.buffer.length === 0 || this.flushing) return;
345
+ if (this.buffer.length === 0 || this.flushing || this.disabled) return;
336
346
  this.flushing = true;
337
347
  const entries = this.buffer.splice(0, this.buffer.length);
338
348
 
339
349
  if (this.apiFn) {
340
350
  try {
341
351
  await this.apiFn('POST', '/api/v1/mcp/audit', { body: { entries } });
352
+ // A success clears the streak; an intermittent outage must not count
353
+ // toward standing down.
354
+ this.consecutiveFailures = 0;
342
355
  } catch {
343
- // Retain a bounded retry buffer. Never retain arbitrary arguments: log()
344
- // already redacts and truncates them before they enter this buffer.
345
- this.buffer = [...entries, ...this.buffer].slice(-this.maxBufferedEntries);
346
- console.error(`[audit] Failed to flush ${entries.length} audit entries; retained for retry`);
356
+ this.consecutiveFailures += 1;
357
+ if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
358
+ // Give up for the life of the process, and drop what we were holding
359
+ // rather than carrying a doomed batch forever. Telemetry is not worth
360
+ // a permanent retry loop or a growing buffer in someone's editor.
361
+ this.disabled = true;
362
+ this.buffer = [];
363
+ clearInterval(this.flushTimer);
364
+ console.error(
365
+ `[audit] Audit telemetry disabled after ${this.consecutiveFailures} consecutive flush failures. `
366
+ + 'Tool calls still work; only local usage analytics stop. '
367
+ + 'Set ADRATA_TELEMETRY=off to disable this at startup.',
368
+ );
369
+ } else {
370
+ // Retain a bounded retry buffer. Never retain arbitrary arguments: log()
371
+ // already redacts and truncates them before they enter this buffer.
372
+ this.buffer = [...entries, ...this.buffer].slice(-this.maxBufferedEntries);
373
+ console.error(`[audit] Failed to flush ${entries.length} audit entries; retained for retry`);
374
+ }
347
375
  } finally {
348
376
  this.flushing = false;
349
377
  }