@adrata/adrata-mcp 1.0.43 → 1.0.46

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/api-bridge.js CHANGED
@@ -24,6 +24,17 @@ const BLOCKED_SURFACES = {
24
24
  webhooks: ['/api/v1/webhooks'],
25
25
  };
26
26
 
27
+ // Families the bridge may READ but must never WRITE, whatever the caller
28
+ // asserts. `/api/v1/ai-crm-tools` mounts the governed dispatcher (`/execute`),
29
+ // whose live writes need a server-issued single-use confirmation token that a
30
+ // machine principal cannot mint. It used to also mount ungated REST twins of
31
+ // the CRM write tools (`/bulk/delete`, `/company/delete`, ...), and a
32
+ // model-supplied `approved:true` here was the only thing between a
33
+ // prompt-injected agent and a 100-record soft delete. A client boolean is not
34
+ // authority for a destructive or bulk operation, so writes on this family are
35
+ // refused outright: use adrata_ai_tool_execute, which speaks the token flow.
36
+ const WRITE_BLOCKED_PREFIXES = ['/api/v1/ai-crm-tools'];
37
+
27
38
  const ALLOWED_PREFIXES = [
28
39
  '/api/v1/actions',
29
40
  '/api/v1/action-columns',
@@ -395,6 +406,18 @@ export function validateApiBridgeRequest({
395
406
  }
396
407
 
397
408
  const isWrite = WRITE_METHODS.has(normalizedMethod);
409
+ if (
410
+ isWrite
411
+ && WRITE_BLOCKED_PREFIXES.some((prefix) =>
412
+ matchesPathPrefix(toScopeClassificationPath(normalizedPath), prefix))
413
+ ) {
414
+ // Checked before the dry-run branch so a preview cannot report
415
+ // `wouldSend: true` for a write this bridge will never send.
416
+ throw new Error(
417
+ `writes to ${normalizedPath} are not available through adrata_api_request; `
418
+ + 'use adrata_ai_tool_execute, whose live writes require a server-issued confirmation token',
419
+ );
420
+ }
398
421
  if (isWrite && dryRun !== false) {
399
422
  // A dry-run used to answer `wouldSend: true` unconditionally, which is the
400
423
  // single most misleading thing this tool could say: it reported success for
@@ -588,6 +611,7 @@ export function apiBridgeCatalog() {
588
611
  aiToolDispatcher:
589
612
  '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.',
590
613
  blocked: BLOCKED_PREFIXES,
614
+ writeBlocked: WRITE_BLOCKED_PREFIXES,
591
615
  blockedSurfaces: BLOCKED_SURFACES,
592
616
  allowed: ALLOWED_PREFIXES,
593
617
  examples: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.43",
3
+ "version": "1.0.46",
4
4
  "description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. About 275 tools registered at startup for companies, people, deals, actions, buyer groups, warm intros, webhooks and intelligence, plus 65 more behind eight named toolsets you load with enable_toolset.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  getValidAgentToken,
41
41
  } from './access/auth.js';
42
42
  import { TIERS } from './access/tiers.js';
43
- import { findCompany, findPerson } from './tools/free-search.js';
43
+ import { findCompany, findPerson, findCompanyInWorkspace, findPersonInWorkspace } from './tools/free-search.js';
44
44
  import { applySecurityLayer } from './security.js';
45
45
  import { describeEdgeBlock } from './http/edge-block.js';
46
46
  import { createRateLimitRegistry } from './http/rate-limit.js';
@@ -634,15 +634,15 @@ server.tool('describe_capability',
634
634
  async (args) => ok({ capability: await resolveExactCapability(args.ref) }));
635
635
 
636
636
  server.tool('run_capability',
637
- 'Run one exact capability through the governed dispatcher. Defaults to dry-run. Live writes remain subject to server-bound approval, reason, spend, policy, and idempotency controls. Product profiles refuse references outside their namespace.',
637
+ 'Run one exact capability through the governed dispatcher. Defaults to dry-run. Reads, including paid provider reads and enrichment, run directly. Live writes remain subject to server-bound approval, reason, policy, and idempotency controls. Product profiles refuse references outside their namespace.',
638
638
  {
639
639
  ref: z.string().describe('Exact /product/domain/action reference returned by search/describe.'),
640
640
  arguments: z.record(z.unknown()).optional(),
641
641
  dryRun: z.boolean().optional().describe('Defaults true. Set false only after reviewing the exact schema and preview.'),
642
642
  reason: z.string().optional(),
643
643
  confirmationToken: z.string().optional().describe('Single-use server-bound token when the capability requires one.'),
644
- confirmSpend: z.boolean().optional(),
645
- idempotencyKey: z.string().optional().describe('Required by governed live writes/spend. Reuse the same key on retry.'),
644
+ confirmSpend: z.boolean().optional().describe('Deprecated no-op: paid reads no longer need a spend acknowledgment.'),
645
+ idempotencyKey: z.string().optional().describe('Required by governed live writes. Reuse the same key on retry.'),
646
646
  },
647
647
  async (args) => {
648
648
  const capability = await resolveExactCapability(args.ref);
@@ -741,16 +741,16 @@ server.tool('adrata_ai_tool_catalog',
741
741
  async () => ok(await api('GET', '/api/v1/ai-crm-tools/catalog')));
742
742
 
743
743
  server.tool('adrata_ai_tool_execute',
744
- 'Execute the same governed AI CRM ToolDispatcher path used by Adrata chat. Free reads run directly. Reads that can spend external data credits return a spend hold; execute one only with confirmSpend:true, a reason, and an idempotencyKey (reuse the same key on retry). The response includes the approved ceiling and an exact provider-reported receipt where available. Writes use a two-step, server-bound confirmationToken flow; a client approved boolean can never authorize a write. A non-interactive machine/OAuth principal (every MCP client) can preview writes but cannot complete one.',
744
+ 'Execute the same governed AI CRM ToolDispatcher path used by Adrata chat. Reads run directly, including paid provider reads and enrichment. Writes use a two-step, server-bound confirmationToken flow; a client approved boolean can never authorize a write. A non-interactive machine/OAuth principal (every MCP client) can preview writes but cannot complete one.',
745
745
  {
746
746
  toolName: z.string().describe('AI CRM tool name from adrata_ai_tool_catalog, e.g. list_channels or rank_companies_by_icp'),
747
747
  arguments: z.record(z.unknown()).optional(),
748
748
  dryRun: z.boolean().optional().describe('Defaults to true for write tools. Set false only after explicit approval.'),
749
749
  approved: z.boolean().optional().describe('Deprecated compatibility field; never authorizes a live write.'),
750
- reason: z.string().optional().describe('Required audit reason for a spend acknowledgment or when requesting a live-write confirmation token.'),
750
+ reason: z.string().optional().describe('Required audit reason when requesting a live-write confirmation token.'),
751
751
  confirmationToken: z.string().optional().describe('Single-use server token returned by the first live-write request; bound to the user, workspace, tool, and stored arguments.'),
752
- confirmSpend: z.boolean().optional().describe('Required true to execute a read held for external vendor spend. Never authorizes a workspace write.'),
753
- idempotencyKey: z.string().optional().describe('Required with confirmSpend:true. Reuse the same key on retry; the API replays the first response instead of calling the paid provider twice.'),
752
+ confirmSpend: z.boolean().optional().describe('Deprecated no-op: paid reads no longer need a spend acknowledgment. Never authorizes a workspace write.'),
753
+ idempotencyKey: z.string().optional().describe('Optional. Reuse the same key on retry.'),
754
754
  },
755
755
  async (args) => {
756
756
  try {
@@ -1123,17 +1123,30 @@ server.tool('move_pipeline_card',
1123
1123
  }
1124
1124
  });
1125
1125
 
1126
- // ===== FREE TIER: Claude-powered search (zero cost to Adrata) =====
1126
+ // ===== find_company / find_person =====
1127
+ //
1128
+ // A seat with a credential gets its WORKSPACE RECORD (found / not found / could
1129
+ // not check). Only a server with no credential at all falls back to the
1130
+ // training-data profile prompt, which carries no upsell. See tools/free-search.js.
1131
+
1132
+ function hasWorkspaceCredential() {
1133
+ const auth = currentAuth();
1134
+ return Boolean(auth?.token || auth?.apiKey);
1135
+ }
1127
1136
 
1128
1137
  server.tool('find_company',
1129
- 'Search for a company using Claude\'s training data. Returns a structured profile with industry, key people, estimated size, competitors, and sales angles. Free tier — no API key needed. For verified real-time data, upgrade to Pro.',
1130
- { name: z.string().describe('Company name to research (e.g. "Stripe", "Snowflake", "Datadog")') },
1131
- async (a) => findCompany(a.name));
1138
+ 'Find a company. Connected to a workspace, returns that workspace\'s company record (or says plainly that none matches, or that the workspace could not be checked). With no workspace connected, returns an unverified profile from Claude\'s training data.',
1139
+ { name: z.string().describe('Company name or domain (e.g. "Stripe", "stripe.com")') },
1140
+ async (a) => (hasWorkspaceCredential()
1141
+ ? findCompanyInWorkspace(a.name, (query) => api('GET', '/api/v1/companies', { params: { search: query, limit: 25, page: 1 } }))
1142
+ : findCompany(a.name)));
1132
1143
 
1133
1144
  server.tool('find_person',
1134
- 'Search for a professional using Claude\'s training data. Returns a structured profile with title, company, background, expertise, and sales context. Free tier no API key needed. For verified contact info and buyer signals, upgrade to Pro.',
1135
- { name: z.string().describe('Full name of the person to research (e.g. "Patrick Collison", "Jensen Huang")') },
1136
- async (a) => findPerson(a.name));
1145
+ 'Find a person. Connected to a workspace, returns that workspace\'s person record (or says plainly that none matches, or that the workspace could not be checked). With no workspace connected, returns an unverified profile from Claude\'s training data.',
1146
+ { name: z.string().describe('Full name or email of the person (e.g. "Patrick Collison")') },
1147
+ async (a) => (hasWorkspaceCredential()
1148
+ ? findPersonInWorkspace(a.name, (query) => api('GET', '/api/v1/people', { params: { search: query, limit: 25, page: 1 } }))
1149
+ : findPerson(a.name)));
1137
1150
 
1138
1151
  // ===== GOVERNED WRITES (shared) =====
1139
1152
  //
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "com.adrata/adrata-mcp",
4
4
  "description": "Adrata revenue-intelligence MCP server: companies, people, opportunities, actions, buyer groups, enrichment, email, and workspace operations for AI agents.",
5
5
  "status": "active",
6
- "version": "1.0.43",
6
+ "version": "1.0.46",
7
7
  "websiteUrl": "https://adrata.com/developers",
8
8
  "repository": {
9
9
  "url": "https://github.com/adrata/adrata",
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "@adrata/adrata-mcp",
18
- "version": "1.0.43",
18
+ "version": "1.0.46",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },
@@ -1,11 +1,20 @@
1
1
  /**
2
- * Free Tier Claude-Powered Search
2
+ * find_company / find_person
3
3
  *
4
- * These tools return structured prompts that guide Claude (the LLM running the
5
- * MCP client) to search its own training data for company and person profiles.
4
+ * Connected to a workspace, these return the WORKSPACE RECORD the thing a
5
+ * seller asking "find Acme" means. Measured on Colin's day-one audit
6
+ * (2026-09-12): a connected seat got back a raw model prompt ending "Upgrade to
7
+ * Adrata Pro for enriched intelligence" instead of the record sitting in its own
8
+ * CRM. Owner ruling 2026-09-11: no seat sees credits, prices, quotas or upsells.
6
9
  *
7
- * Cost to Adrata: $0 — Claude's own knowledge does the work.
8
- * No API key needed, no account needed, unlimited usage.
10
+ * Three states, never two:
11
+ * - found: the workspace record(s), labelled as such;
12
+ * - not found: said plainly — the workspace was checked and has no match;
13
+ * - could not look: said plainly — nothing was ruled out.
14
+ *
15
+ * With no credential at all there is no workspace to check, so the tool falls
16
+ * back to a structured training-data profile prompt, labelled unverified, with
17
+ * no upsell in it.
9
18
  */
10
19
 
11
20
  // ---------------------------------------------------------------------------
@@ -56,8 +65,7 @@ IMPORTANT: Respond with ONLY a valid JSON object matching this exact schema. No
56
65
  "competitors": ["Competitor 1", "Competitor 2", "Competitor 3"],
57
66
  "recent_highlights": "Notable recent developments, funding, acquisitions, or strategic moves from training data",
58
67
  "ideal_sales_angles": "2-3 sentence suggestion for how a seller might approach this company",
59
- "data_freshness": "Based on training data through early 2025. Some details may have changed.",
60
- "upgrade_prompt": "Want verified contacts, real-time headcount, funding rounds, hiring signals, and intent data? Upgrade to Adrata Pro for enriched intelligence."
68
+ "data_freshness": "Based on training data through early 2025. Some details may have changed."
61
69
  }
62
70
 
63
71
  Rules:
@@ -84,8 +92,7 @@ IMPORTANT: Respond with ONLY a valid JSON object matching this exact schema. No
84
92
  "notable_achievements": "Key accomplishments, board seats, speaking engagements, publications",
85
93
  "linkedin_likely": "Likely LinkedIn URL pattern (e.g. linkedin.com/in/firstname-lastname) — note: unverified",
86
94
  "sales_context": "2-3 sentence suggestion for how a seller might approach this person — what they likely care about",
87
- "data_freshness": "Based on training data through early 2025. Current role may have changed.",
88
- "upgrade_prompt": "Want verified email, phone, social profiles, recent activity, and buyer signals? Upgrade to Adrata Pro for enriched contact intelligence."
95
+ "data_freshness": "Based on training data through early 2025. Current role may have changed."
89
96
  }
90
97
 
91
98
  Rules:
@@ -158,3 +165,118 @@ export function findPerson(name) {
158
165
  cacheSet(cacheKey, result);
159
166
  return result;
160
167
  }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Workspace-record lookup (connected seats)
171
+ // ---------------------------------------------------------------------------
172
+
173
+ const WORKSPACE_MATCH_LIMIT = 5;
174
+
175
+ /** Pull the record array out of the API's list envelope, whatever its shape. */
176
+ export function recordsFromListResponse(response, collection) {
177
+ const candidates = [
178
+ response,
179
+ response?.data,
180
+ response?.data?.[collection],
181
+ response?.data?.items,
182
+ response?.data?.data,
183
+ response?.[collection],
184
+ response?.items,
185
+ ];
186
+ for (const candidate of candidates) {
187
+ if (Array.isArray(candidate)) return candidate;
188
+ }
189
+ return [];
190
+ }
191
+
192
+ function normalize(value) {
193
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
194
+ }
195
+
196
+ function recordName(record, kind) {
197
+ if (kind === 'person') {
198
+ return (
199
+ record?.fullName ||
200
+ record?.name ||
201
+ [record?.firstName, record?.lastName].filter(Boolean).join(' ')
202
+ );
203
+ }
204
+ return record?.name;
205
+ }
206
+
207
+ function exactMatches(records, query, kind) {
208
+ const wanted = normalize(query);
209
+ return records.filter((record) => {
210
+ if (normalize(recordName(record, kind)) === wanted) return true;
211
+ if (kind === 'company') {
212
+ return [record?.domain, record?.website].some((d) => normalize(d).replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '') === wanted);
213
+ }
214
+ return normalize(record?.email) === wanted;
215
+ });
216
+ }
217
+
218
+ function textResult(payload, isError = false) {
219
+ return {
220
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
221
+ ...(isError ? { isError: true } : {}),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Look the name up in the connected workspace and return what is there.
227
+ *
228
+ * `lookup(name)` performs the authenticated list read and resolves to the raw
229
+ * API response; it is injected so this stays testable without a server.
230
+ */
231
+ async function findInWorkspace(kind, name, lookup) {
232
+ const label = kind === 'company' ? 'company' : 'person';
233
+ const query = String(name ?? '').trim();
234
+ let response;
235
+ try {
236
+ response = await lookup(query);
237
+ } catch {
238
+ // The honest third state. Never an empty result, never "not found".
239
+ return textResult(
240
+ {
241
+ source: 'workspace',
242
+ status: 'could_not_check',
243
+ query,
244
+ message: `Couldn't check the workspace for a ${label} named "${query}" right now, so nothing was ruled out. Try again in a moment.`,
245
+ },
246
+ true,
247
+ );
248
+ }
249
+
250
+ const records = recordsFromListResponse(response, kind === 'company' ? 'companies' : 'people');
251
+ if (records.length === 0) {
252
+ return textResult({
253
+ source: 'workspace',
254
+ status: 'not_found',
255
+ query,
256
+ message: `No ${label} matching "${query}" is in this workspace.`,
257
+ });
258
+ }
259
+
260
+ const exact = exactMatches(records, query, kind);
261
+ const matches = (exact.length > 0 ? exact : records).slice(0, WORKSPACE_MATCH_LIMIT);
262
+ return textResult({
263
+ source: 'workspace',
264
+ status: exact.length > 0 ? 'found' : 'similar_found',
265
+ query,
266
+ message:
267
+ exact.length > 0
268
+ ? `Found ${exact.length === 1 ? 'the' : exact.length} workspace ${label} record${exact.length === 1 ? '' : 's'} for "${query}".`
269
+ : `No exact match for "${query}"; these workspace ${label} records are the closest.`,
270
+ [kind === 'company' ? 'companies' : 'people']: matches,
271
+ });
272
+ }
273
+
274
+ /** find_company for a seat with a credential: the workspace record, three-state. */
275
+ export function findCompanyInWorkspace(name, lookup) {
276
+ return findInWorkspace('company', name, lookup);
277
+ }
278
+
279
+ /** find_person for a seat with a credential: the workspace record, three-state. */
280
+ export function findPersonInWorkspace(name, lookup) {
281
+ return findInWorkspace('person', name, lookup);
282
+ }