@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/toolsets/crm.js CHANGED
@@ -9,6 +9,23 @@ import { z } from 'zod';
9
9
  import {
10
10
  md, mdError, table, formatEntityTimeline, formatDate,
11
11
  } from '../output-formatter.js';
12
+ import {
13
+ governedWriteArgs, governedWriteNote, isLiveWrite, runGovernedMarkdownWrite,
14
+ enrichCostLine, COMPANY_ENRICH_COST_SENTENCE, PERSON_ENRICH_COST_SENTENCE,
15
+ } from '../governance/governed-args.js';
16
+
17
+ /**
18
+ * The `manage_*` tools are composites: one tool, one `action` argument, reads
19
+ * and writes side by side. Only the write actions are governed — a `get`,
20
+ * `list`, `search` or `timeline` must keep working exactly as before, with no
21
+ * new required arguments and no preview step.
22
+ *
23
+ * This is the list of actions that mutate. Everything not in it is a read.
24
+ */
25
+ const WRITE_ACTIONS = new Set([
26
+ 'create', 'update', 'delete', 'enrich', 'complete',
27
+ 'bulk_delete', 'add_member', 'update_member', 'remove_member',
28
+ ]);
12
29
 
13
30
  export function register(server, api, AUTH) {
14
31
 
@@ -17,7 +34,10 @@ export function register(server, api, AUTH) {
17
34
  // -----------------------------------------------------------------------
18
35
  server.tool(
19
36
  'manage_company',
20
- 'Full CRUD for companies: create, read, update, delete, enrich, and view activity timeline. One tool for all company operations.',
37
+ 'Full CRUD for companies: create, read, update, delete, enrich, and view activity timeline. One tool for all company operations.'
38
+ + ' Reads (get/search/timeline) run directly. create/update/delete/enrich are governed writes.'
39
+ + COMPANY_ENRICH_COST_SENTENCE
40
+ + governedWriteNote('write:companies'),
21
41
  {
22
42
  action: z.enum(['create', 'get', 'update', 'delete', 'enrich', 'timeline', 'search']).describe('Operation'),
23
43
  id: z.string().optional().describe('Company ID (for get/update/delete/enrich/timeline)'),
@@ -28,6 +48,7 @@ export function register(server, api, AUTH) {
28
48
  status: z.string().optional().describe('ACTIVE, PROSPECT, CUSTOMER, CHURNED'),
29
49
  customFields: z.record(z.unknown()).optional().describe('Custom fields (JSONB merged)'),
30
50
  query: z.string().optional().describe('Search query (for search action)'),
51
+ ...governedWriteArgs(z),
31
52
  },
32
53
  async (args) => {
33
54
  try {
@@ -40,9 +61,20 @@ export function register(server, api, AUTH) {
40
61
  if (args.website) body.website = args.website;
41
62
  if (args.status) body.status = args.status;
42
63
  if (args.customFields) body.customFields = args.customFields;
43
- const result = await api('POST', '/api/v1/companies', { body });
44
- const c = result?.data || result;
45
- return md(`## Company Created\n\n- **Name:** ${c.name || args.name}\n- **ID:** ${c.id || '\u2014'}\n- **Status:** ${c.status || 'PROSPECT'}\n`);
64
+ return runGovernedMarkdownWrite(
65
+ api, args,
66
+ {
67
+ method: 'POST',
68
+ path: '/api/v1/companies',
69
+ body,
70
+ heading: 'Create Company',
71
+ preview: { entity: 'company', operation: 'create', fields: Object.keys(body) },
72
+ },
73
+ (result) => {
74
+ const c = result?.data || result || {};
75
+ return `## Company Created\n\n- **Name:** ${c.name || args.name}\n- **ID:** ${c.id || '\u2014'}\n- **Status:** ${c.status || 'PROSPECT'}\n`;
76
+ },
77
+ );
46
78
  }
47
79
  case 'get': {
48
80
  const c = await api('GET', `/api/v1/companies/${args.id}`);
@@ -64,16 +96,46 @@ export function register(server, api, AUTH) {
64
96
  if (args.website) body.website = args.website;
65
97
  if (args.status) body.status = args.status;
66
98
  if (args.customFields) body.customFields = args.customFields;
67
- await api('PATCH', `/api/v1/companies/${args.id}`, { body });
68
- return md(`## Company Updated\n\nID: ${args.id}\nFields updated: ${Object.keys(body).join(', ')}\n`);
99
+ return runGovernedMarkdownWrite(
100
+ api, args,
101
+ {
102
+ method: 'PATCH',
103
+ path: `/api/v1/companies/${args.id}`,
104
+ body,
105
+ heading: 'Update Company',
106
+ preview: { entity: 'company', entityId: args.id, operation: 'update', fields: Object.keys(body) },
107
+ },
108
+ () => `## Company Updated\n\nID: ${args.id}\nFields updated: ${Object.keys(body).join(', ')}\n`,
109
+ );
69
110
  }
70
111
  case 'delete': {
71
- await api('DELETE', `/api/v1/companies/${args.id}`);
72
- return md(`## Company Deleted\n\nID: ${args.id}\n`);
112
+ return runGovernedMarkdownWrite(
113
+ api, args,
114
+ {
115
+ method: 'DELETE',
116
+ path: `/api/v1/companies/${args.id}`,
117
+ heading: 'Delete Company',
118
+ preview: { entity: 'company', entityId: args.id, operation: 'soft-delete' },
119
+ },
120
+ () => `## Company Deleted\n\nID: ${args.id}\n`,
121
+ );
73
122
  }
74
123
  case 'enrich': {
75
- await api('POST', `/api/v1/companies/${args.id}/enrich`);
76
- return md(`## Enrichment Triggered\n\nCompany ID: ${args.id}\nData will be available shortly.\n`);
124
+ return runGovernedMarkdownWrite(
125
+ api, args,
126
+ {
127
+ method: 'POST',
128
+ path: `/api/v1/companies/${args.id}/enrich`,
129
+ heading: 'Enrich Company',
130
+ preview: {
131
+ entity: 'company',
132
+ entityId: args.id,
133
+ operation: 'enrich',
134
+ cost: enrichCostLine('company'),
135
+ },
136
+ },
137
+ () => `## Enrichment Triggered\n\nCompany ID: ${args.id}\nCost: ${enrichCostLine('company')}\nData will be available shortly.\n`,
138
+ );
77
139
  }
78
140
  case 'timeline': {
79
141
  const [company, actions] = await Promise.all([
@@ -12,6 +12,89 @@ import {
12
12
  formatQualification, formatCompanyResearch, formatPersonResearch,
13
13
  formatSpeedrunList, formatNextContacts, table, signalEmoji, formatDate, daysSince,
14
14
  } from '../output-formatter.js';
15
+ import {
16
+ governedWriteArgs, governedWriteNote, isLiveWrite, missingLiveWriteFields,
17
+ previewMarkdown, enrichCostLine,
18
+ COMPANY_ENRICH_COST_SENTENCE, PERSON_ENRICH_COST_SENTENCE,
19
+ } from '../governance/governed-args.js';
20
+ import { governedWrite } from '../api-bridge.js';
21
+
22
+ /**
23
+ * Run the enrichment leg of a research tool through the governed-write
24
+ * contract and render the outcome as a Markdown section.
25
+ *
26
+ * `research_company` / `research_person` are read tools with one write inside
27
+ * them: a POST to `/enrich`, which spends real Coresignal credits. They used to
28
+ * fire that POST on every call with no preview, no audit reason and no
29
+ * idempotency key — while the IDENTICAL endpoint reached through
30
+ * `enrich_company` / `enrich_person` in server.js previewed and refused.
31
+ *
32
+ * So the reads still run and still produce the report. The enrich is withheld
33
+ * by default and reported as withheld, with the credit cost named, rather than
34
+ * being silently skipped — a research report that quietly lost its enrichment
35
+ * would be worse than one that spent the credits.
36
+ *
37
+ * @returns `{ enrichment, section }` — `enrichment` is the live result (or an
38
+ * empty object when nothing was executed) so the report renders
39
+ * identically either way.
40
+ */
41
+ async function governedEnrich(api, args, { method, path, kind, subjectId }) {
42
+ if (isLiveWrite(args)) {
43
+ const missing = missingLiveWriteFields(args);
44
+ if (missing.length > 0) {
45
+ return {
46
+ enrichment: {},
47
+ section:
48
+ `\n---\n\n## Enrichment refused — nothing was spent\n\n` +
49
+ `A live enrichment write is missing: ${missing.join(', ')}.\n\n` +
50
+ `- **Would have called:** ${method} ${path}\n` +
51
+ `- **Cost if executed:** ${enrichCostLine(kind)}\n\n` +
52
+ `Re-call with \`dryRun:false\` plus ${missing.join(', ')}.\n`,
53
+ };
54
+ }
55
+ }
56
+
57
+ try {
58
+ const outcome = await governedWrite(api, args, {
59
+ method,
60
+ path,
61
+ preview: {
62
+ entity: kind,
63
+ entityId: subjectId,
64
+ operation: 'enrich',
65
+ cost: enrichCostLine(kind),
66
+ },
67
+ });
68
+
69
+ if (outcome.dryRun) {
70
+ return {
71
+ enrichment: {},
72
+ section:
73
+ `\n---\n\n${previewMarkdown('Enrichment', outcome.preview)}` +
74
+ `\nThe research above was assembled from reads only. No vendor credits were spent.\n`,
75
+ };
76
+ }
77
+
78
+ return {
79
+ enrichment: outcome.result || {},
80
+ section:
81
+ `\n---\n\n## Enrichment executed\n\n` +
82
+ `- **Call:** ${method} ${path}\n` +
83
+ `- **Cost:** ${enrichCostLine(kind)}\n` +
84
+ `- **Reason:** ${args.reason}\n` +
85
+ `- **Idempotency key:** ${args.idempotencyKey}\n`,
86
+ };
87
+ } catch (err) {
88
+ return {
89
+ enrichment: {},
90
+ section:
91
+ `\n---\n\n## Enrichment not performed\n\n` +
92
+ `- **Attempted:** ${method} ${path}\n` +
93
+ `- **Error:** ${err.message}\n` +
94
+ `\nThe research above was assembled from reads only.\n`,
95
+ };
96
+ }
97
+ }
15
98
 
16
99
  export function register(server, api, AUTH) {
17
100
 
@@ -63,10 +146,14 @@ export function register(server, api, AUTH) {
63
146
  // -----------------------------------------------------------------------
64
147
  server.tool(
65
148
  'research_company',
66
- 'Deep research report on a company: firmographics, tech stack, funding, news, competitors, intent signals. Orchestrates 5+ API calls into a structured report.',
149
+ 'Deep research report on a company: firmographics, tech stack, funding, news, competitors, intent signals. Orchestrates 5+ API calls into a structured report.'
150
+ + ' The reads always run. The enrichment leg is a governed write:'
151
+ + COMPANY_ENRICH_COST_SENTENCE
152
+ + governedWriteNote('write:companies'),
67
153
  {
68
154
  companyId: z.string().optional().describe('Company ID'),
69
155
  name: z.string().optional().describe('Company name to search'),
156
+ ...governedWriteArgs(z),
70
157
  },
71
158
  async (args) => {
72
159
  try {
@@ -86,14 +173,22 @@ export function register(server, api, AUTH) {
86
173
  return mdError('Provide companyId or name');
87
174
  }
88
175
 
89
- // Parallel enrichment
90
- const [enrichment, signals, competitors] = await Promise.all([
91
- api('POST', `/api/v1/companies/${companyId}/enrich`).catch(() => ({})),
176
+ // Reads run unconditionally. The enrich POST is a governed write and
177
+ // previews by default, so a research call costs zero vendor credits
178
+ // unless the caller explicitly authorised the spend.
179
+ const [signals, competitors] = await Promise.all([
92
180
  api('GET', '/api/v1/intent-signals', { params: { companyId } }).catch(() => ({ data: [] })),
93
181
  api('GET', '/api/v1/competitors', { params: { companyId } }).catch(() => ({ data: [] })),
94
182
  ]);
95
183
 
96
- return md(formatCompanyResearch(company, enrichment, signals, competitors));
184
+ const { enrichment, section } = await governedEnrich(api, args, {
185
+ method: 'POST',
186
+ path: `/api/v1/companies/${companyId}/enrich`,
187
+ kind: 'company',
188
+ subjectId: companyId,
189
+ });
190
+
191
+ return md(formatCompanyResearch(company, enrichment, signals, competitors) + section);
97
192
  } catch (err) {
98
193
  return mdError('Research failed', err.message);
99
194
  }
@@ -105,11 +200,15 @@ export function register(server, api, AUTH) {
105
200
  // -----------------------------------------------------------------------
106
201
  server.tool(
107
202
  'research_person',
108
- 'Deep research on a person: professional profile, communication preferences, intro paths, recent activity. Orchestrates 3+ API calls.',
203
+ 'Deep research on a person: professional profile, communication preferences, intro paths, recent activity. Orchestrates 3+ API calls.'
204
+ + ' The reads always run. The enrichment leg is a governed write:'
205
+ + PERSON_ENRICH_COST_SENTENCE
206
+ + governedWriteNote('write:people'),
109
207
  {
110
208
  personId: z.string().optional().describe('Person ID'),
111
209
  name: z.string().optional().describe('Person name to search'),
112
210
  email: z.string().optional().describe('Email to search by'),
211
+ ...governedWriteArgs(z),
113
212
  },
114
213
  async (args) => {
115
214
  try {
@@ -132,13 +231,20 @@ export function register(server, api, AUTH) {
132
231
  return mdError('Provide personId, name, or email');
133
232
  }
134
233
 
135
- // Parallel: enrichment + actions
136
- const [enrichment, actions] = await Promise.all([
137
- api('POST', `/api/v1/people/${personId}/enrich`).catch(() => ({})),
138
- api('GET', '/api/v1/actions', { params: { personId, limit: 10, page: 1 } }).catch(() => ({ data: [] })),
139
- ]);
234
+ // Reads run unconditionally; the enrich POST is governed and previews
235
+ // by default. A Coresignal collect is 20 credits per person, so an
236
+ // ungoverned research call was a per-invocation vendor charge.
237
+ const actions = await api('GET', '/api/v1/actions', { params: { personId, limit: 10, page: 1 } })
238
+ .catch(() => ({ data: [] }));
239
+
240
+ const { enrichment, section } = await governedEnrich(api, args, {
241
+ method: 'POST',
242
+ path: `/api/v1/people/${personId}/enrich`,
243
+ kind: 'person',
244
+ subjectId: personId,
245
+ });
140
246
 
141
- return md(formatPersonResearch(person, enrichment, actions));
247
+ return md(formatPersonResearch(person, enrichment, actions) + section);
142
248
  } catch (err) {
143
249
  return mdError('Research failed', err.message);
144
250
  }