@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/server.js
ADDED
|
@@ -0,0 +1,2139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @adrata/adrata-mcp
|
|
5
|
+
*
|
|
6
|
+
* Connect Claude Code, Cursor, Windsurf, VS Code, and Claude Desktop
|
|
7
|
+
* to your Adrata CRM workspace.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npx -y @adrata/adrata-mcp
|
|
11
|
+
*
|
|
12
|
+
* Environment:
|
|
13
|
+
* ADRATA_API_KEY — Your Adrata API key (enables pro tier)
|
|
14
|
+
* ADRATA_OAUTH_TOKEN — OAuth bearer token (enables enterprise tier)
|
|
15
|
+
* ADRATA_API_URL — API base URL (default: https://api.adrata.com)
|
|
16
|
+
* ADRATA_MCP_TRANSPORT — 'stdio' (default) or 'http' for Streamable HTTP
|
|
17
|
+
* ADRATA_MCP_PORT — HTTP transport port (default: 3100)
|
|
18
|
+
* ADRATA_MCP_CORS_ORIGINS — Comma-separated CORS origins (default: '*')
|
|
19
|
+
* ADRATA_MCP_SERVER_NAME — Display name reported in serverInfo
|
|
20
|
+
* (default: '@adrata/adrata-mcp'). The
|
|
21
|
+
* `@adrata/starfield-mcp` wrapper sets this to
|
|
22
|
+
* 'Starfield'.
|
|
23
|
+
*
|
|
24
|
+
* Tier model:
|
|
25
|
+
* - No credentials: free tier (search, email sync, demo scheduling)
|
|
26
|
+
* - ADRATA_API_KEY: pro tier (+ intelligence, analytics, enrichment)
|
|
27
|
+
* - ADRATA_OAUTH_TOKEN: enterprise (+ CRM CRUD, sequences, admin)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
31
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
32
|
+
import { z } from 'zod';
|
|
33
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
34
|
+
import { authenticate, reauthenticate, checkToolAccess, assertOAuthIssuerMatchesTarget } from './access/auth.js';
|
|
35
|
+
import { TIERS } from './access/tiers.js';
|
|
36
|
+
import { findCompany, findPerson } from './tools/free-search.js';
|
|
37
|
+
import { applySecurityLayer } from './security.js';
|
|
38
|
+
import { registerMemoryTools, wrapWithEventLogging, registerProfileResource } from './tools/memory.js';
|
|
39
|
+
import { registerBillingTools } from './tools/billing.js';
|
|
40
|
+
import { registerMorningBrief } from './tools/morning-brief.js';
|
|
41
|
+
import { registerSloanTools } from './toolsets/revenue/sloan-tools.js';
|
|
42
|
+
import { registerResources } from './resources.js';
|
|
43
|
+
import { connectWorkspace, disconnectWorkspace, getConnectionStatus, getValidToken, fetchWorkspaceCapabilities, loadTokens } from './access/oauth.js';
|
|
44
|
+
import { registerEnterpriseTools } from './tools/enterprise-tools.js';
|
|
45
|
+
import { registerEmailTools } from './tools/email-tools.js';
|
|
46
|
+
import { registerWorkBoardTools } from './tools/work-board-tools.js';
|
|
47
|
+
import { registerPaperTools } from './tools/paper-tools.js';
|
|
48
|
+
import { register as registerAlwaysLoadedTools } from './toolsets/revenue/always-loaded.js';
|
|
49
|
+
import { register as registerExtensibilityTools } from './toolsets/extensibility.js';
|
|
50
|
+
import { getDemoAvailability, scheduleDemo, scheduleMeeting } from './tools/scheduling.js';
|
|
51
|
+
import { registerAnalytics } from './analytics.js';
|
|
52
|
+
import {
|
|
53
|
+
apiBridgeCatalog,
|
|
54
|
+
buildMutationHeaders,
|
|
55
|
+
buildRealtimePayload,
|
|
56
|
+
governedWrite,
|
|
57
|
+
validateApiBridgeRequest,
|
|
58
|
+
} from './api-bridge.js';
|
|
59
|
+
import { buildToolAnnotations, checkDomainScope } from './tool-annotations.js';
|
|
60
|
+
import { executeMoneyWrite, capClientSide, CLIENT_PAGE_CAP } from './governance/money.js';
|
|
61
|
+
|
|
62
|
+
let AUTH = authenticate();
|
|
63
|
+
const API_BASE = AUTH.apiUrl || process.env.ADRATA_API_URL || 'https://api.adrata.com';
|
|
64
|
+
const authContextStore = new AsyncLocalStorage();
|
|
65
|
+
|
|
66
|
+
function currentAuth() {
|
|
67
|
+
return authContextStore.getStore() || AUTH;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function runWithAuthContext(auth, fn) {
|
|
71
|
+
return authContextStore.run(auth || AUTH, fn);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (AUTH.tier === 'free') {
|
|
75
|
+
console.error('Adrata MCP Server starting in FREE tier mode.');
|
|
76
|
+
console.error('Set ADRATA_API_KEY for pro features, or use connect_workspace for enterprise.');
|
|
77
|
+
} else if (AUTH.tier === 'pro') {
|
|
78
|
+
console.error(`Adrata MCP Server starting in PRO tier mode.`);
|
|
79
|
+
} else {
|
|
80
|
+
console.error(`Adrata MCP Server starting in ENTERPRISE tier mode (source: ${AUTH.source}).`);
|
|
81
|
+
// Pre-load workspace capabilities for enterprise connections
|
|
82
|
+
if (AUTH.token) {
|
|
83
|
+
fetchWorkspaceCapabilities(API_BASE, AUTH.token).catch(() => {
|
|
84
|
+
console.error('Warning: Could not fetch workspace capabilities at startup.');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// HTTP helper
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
// Translate a typed refresh failure from getValidToken into a clear,
|
|
94
|
+
// actionable tool error. A dead refresh token ("reconnect required") must not
|
|
95
|
+
// read like a transient blip, and a transient network blip must not tell the
|
|
96
|
+
// user to reconnect.
|
|
97
|
+
function refreshFailureError(err) {
|
|
98
|
+
if (err?.reconnectRequired) {
|
|
99
|
+
return new Error(
|
|
100
|
+
'Workspace OAuth session expired or was revoked. Run connect_workspace to reconnect before using governed tools.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (err?.transient) {
|
|
104
|
+
return new Error(
|
|
105
|
+
'Could not refresh the workspace session due to a temporary network issue. Please retry in a moment.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function api(method, path, { params, body, headers: extraHeaders } = {}) {
|
|
112
|
+
const url = new URL(path, API_BASE);
|
|
113
|
+
if (params) {
|
|
114
|
+
for (const [k, v] of Object.entries(params)) {
|
|
115
|
+
if (v != null && v !== '') url.searchParams.set(k, String(v));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// For stored OAuth tokens, auto-refresh if expired before the request
|
|
120
|
+
const auth = currentAuth();
|
|
121
|
+
assertOAuthIssuerMatchesTarget(auth, API_BASE);
|
|
122
|
+
let currentToken = auth.token;
|
|
123
|
+
if (auth.source === 'stored') {
|
|
124
|
+
let freshToken;
|
|
125
|
+
try {
|
|
126
|
+
freshToken = await getValidToken(API_BASE);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw refreshFailureError(err);
|
|
129
|
+
}
|
|
130
|
+
if (!freshToken) {
|
|
131
|
+
throw new Error('No workspace OAuth session found. Run connect_workspace before using governed tools.');
|
|
132
|
+
}
|
|
133
|
+
currentToken = freshToken;
|
|
134
|
+
auth.token = freshToken;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
138
|
+
if (currentToken) {
|
|
139
|
+
headers['Authorization'] = `Bearer ${currentToken}`;
|
|
140
|
+
}
|
|
141
|
+
if (auth.apiKey) {
|
|
142
|
+
headers['X-API-Key'] = auth.apiKey;
|
|
143
|
+
}
|
|
144
|
+
Object.assign(headers, extraHeaders || {});
|
|
145
|
+
|
|
146
|
+
const request = (token) => {
|
|
147
|
+
const requestHeaders = { ...headers };
|
|
148
|
+
if (token) requestHeaders.Authorization = `Bearer ${token}`;
|
|
149
|
+
return fetch(url.toString(), {
|
|
150
|
+
method,
|
|
151
|
+
headers: requestHeaders,
|
|
152
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
let res = await request(currentToken);
|
|
157
|
+
if (res.status === 401 && auth.source === 'stored') {
|
|
158
|
+
let refreshedToken;
|
|
159
|
+
try {
|
|
160
|
+
refreshedToken = await getValidToken(API_BASE, { forceRefresh: true });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw refreshFailureError(err);
|
|
163
|
+
}
|
|
164
|
+
if (!refreshedToken) {
|
|
165
|
+
throw new Error('No workspace OAuth session found. Run connect_workspace before using governed tools.');
|
|
166
|
+
}
|
|
167
|
+
currentToken = refreshedToken;
|
|
168
|
+
auth.token = refreshedToken;
|
|
169
|
+
res = await request(currentToken);
|
|
170
|
+
}
|
|
171
|
+
// The shared agent session (agent.json) has no refresh path inside the MCP —
|
|
172
|
+
// the @adrata agent owns rotation. An expired session needs a re-login, and
|
|
173
|
+
// that must not read like a generic API failure.
|
|
174
|
+
if (res.status === 401 && auth.source === 'agent_config') {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'The shared `adrata login` session was rejected (401) — it may have expired. Run `adrata login` (or `@adrata login`) to sign in again.',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const text = await res.text();
|
|
181
|
+
let data;
|
|
182
|
+
try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
183
|
+
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
throw new Error(`API ${method} ${path} → ${res.status}: ${JSON.stringify(data).slice(0, 200)}`);
|
|
186
|
+
}
|
|
187
|
+
return data;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function body(fields) {
|
|
191
|
+
const b = {};
|
|
192
|
+
for (const [k, v] of Object.entries(fields)) if (v !== undefined) b[k] = v;
|
|
193
|
+
return b;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function ok(data) {
|
|
197
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function executeAiCrmTool(toolName, args = {}, controls = {}) {
|
|
201
|
+
let headers;
|
|
202
|
+
if (controls.confirmSpend === true) {
|
|
203
|
+
if (!controls.reason || !String(controls.reason).trim()) {
|
|
204
|
+
throw new Error('confirmSpend requires a non-empty reason for the durable audit log');
|
|
205
|
+
}
|
|
206
|
+
if (!controls.idempotencyKey || !String(controls.idempotencyKey).trim()) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'confirmSpend requires an idempotencyKey. Reuse the SAME key on retry so the paid provider call cannot run twice.',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
headers = buildMutationHeaders({
|
|
212
|
+
reason: controls.reason,
|
|
213
|
+
idempotencyKey: controls.idempotencyKey,
|
|
214
|
+
approved: true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const payload = {
|
|
218
|
+
toolName,
|
|
219
|
+
arguments: args,
|
|
220
|
+
...controls,
|
|
221
|
+
};
|
|
222
|
+
return api('POST', '/api/v1/ai-crm-tools/execute', { body: payload, headers });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Shared schemas
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
const page = { limit: z.number().optional().describe('Results per page (default 25)'), page: z.number().optional().describe('Page number') };
|
|
230
|
+
|
|
231
|
+
// Governance envelope shared by MONEY writes (partner attribution/consumption).
|
|
232
|
+
// Contract implemented in governance/money.js: dry-run by default; live
|
|
233
|
+
// execution requires approved:true + auditReason + idempotencyKey.
|
|
234
|
+
const moneyWrite = {
|
|
235
|
+
dryRun: z.boolean().optional().describe('Defaults to TRUE: returns a preview of the exact write (payload + endpoint) without executing. Set false to execute live.'),
|
|
236
|
+
approved: z.boolean().optional().describe('Required true for live execution — explicit approval of this specific money write.'),
|
|
237
|
+
auditReason: z.string().optional().describe('Required (non-empty) for live execution: why this write is happening. Stored for audit.'),
|
|
238
|
+
idempotencyKey: z.string().optional().describe('Required for live execution: unique key (e.g. a ULID) sent as the Idempotency-Key header. Reuse the SAME key on retry so the write cannot double-book.'),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// MCP Server
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* What a client shows in its connection list.
|
|
247
|
+
*
|
|
248
|
+
* This is the ONE string a person actually reads — `claude mcp list` and Codex's
|
|
249
|
+
* `mcpServerStatus` both print it — and it was the npm package name, which tells
|
|
250
|
+
* a reader nothing about what they are connected to.
|
|
251
|
+
*
|
|
252
|
+
* It is overridable because one server serves several products. Somebody wiring
|
|
253
|
+
* a coding agent to their Starfield board should see Starfield in their client,
|
|
254
|
+
* not the name of the company that publishes it; `@adrata/starfield-mcp` sets
|
|
255
|
+
* this and imports this same file. There is exactly one implementation — the
|
|
256
|
+
* surfaces a seat may actually reach are decided server-side by `app_scope`, not
|
|
257
|
+
* by which name launched the process, so this cannot be used to widen access.
|
|
258
|
+
*
|
|
259
|
+
* Only the DISPLAY name moves. The tool prefix a client derives from its own
|
|
260
|
+
* config key (`mcp__adrata__*`) is the client's business and is untouched.
|
|
261
|
+
*
|
|
262
|
+
* Trimmed, because a config file is where whitespace comes from. A TOML or JSON
|
|
263
|
+
* value of `" "` is truthy, and a server whose name is a space renders as a
|
|
264
|
+
* blank row in the client's list — indistinguishable from a server that failed
|
|
265
|
+
* to start, which is the exact failure this whole surface keeps producing.
|
|
266
|
+
*/
|
|
267
|
+
const SERVER_NAME = process.env.ADRATA_MCP_SERVER_NAME?.trim() || '@adrata/adrata-mcp';
|
|
268
|
+
|
|
269
|
+
const server = new McpServer({ name: SERVER_NAME, version: '1.0.0' });
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Tier-gating wrapper
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Wrap server.tool so every handler is checked against the user's tier.
|
|
275
|
+
// If the user lacks access, the handler returns a teaser/prompt instead of
|
|
276
|
+
// calling the real implementation. This keeps all 67 tool registrations intact.
|
|
277
|
+
|
|
278
|
+
const _originalTool = server.tool.bind(server);
|
|
279
|
+
server.tool = function gatedTool(name, ...rest) {
|
|
280
|
+
// server.tool(name, [description], [schema], handler) — handler is always last.
|
|
281
|
+
const handler = rest[rest.length - 1];
|
|
282
|
+
const gatedHandler = async function gatedHandler(...handlerArgs) {
|
|
283
|
+
// Least-privilege scoped tool packs: when ADRATA_MCP_ENABLED_DOMAINS is
|
|
284
|
+
// set, tools outside the granted domains are refused at dispatch.
|
|
285
|
+
const scope = checkDomainScope(name);
|
|
286
|
+
if (!scope.allowed) {
|
|
287
|
+
return ok({
|
|
288
|
+
error: 'tool_out_of_scope',
|
|
289
|
+
tool: name,
|
|
290
|
+
domain: scope.domain,
|
|
291
|
+
enabledDomains: scope.enabled,
|
|
292
|
+
message: `The "${name}" tool belongs to the "${scope.domain}" pack, which is not in the granted ADRATA_MCP_ENABLED_DOMAINS scope.`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const access = checkToolAccess(name, currentAuth());
|
|
296
|
+
if (!access.allowed) {
|
|
297
|
+
return access.response;
|
|
298
|
+
}
|
|
299
|
+
return handler(...handlerArgs);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Inject honest MCP tool annotations (readOnly/destructive/idempotent/
|
|
303
|
+
// openWorld + domain). Annotation-carrying overloads require a params
|
|
304
|
+
// schema, so normalize the args into (name, description, schema,
|
|
305
|
+
// annotations, handler). Derived from real behaviour, never a description.
|
|
306
|
+
const annotations = buildToolAnnotations(name);
|
|
307
|
+
const head = rest.slice(0, -1);
|
|
308
|
+
let description = '';
|
|
309
|
+
let schema = {};
|
|
310
|
+
if (head.length === 1) {
|
|
311
|
+
if (typeof head[0] === 'string') description = head[0];
|
|
312
|
+
else schema = head[0];
|
|
313
|
+
} else if (head.length >= 2) {
|
|
314
|
+
description = head[0];
|
|
315
|
+
schema = head[1];
|
|
316
|
+
}
|
|
317
|
+
return _originalTool(name, description, schema, annotations, gatedHandler);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Automatic event logging wrapper
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Wraps every tool invocation to silently POST to /api/v1/mcp/events.
|
|
324
|
+
// Must be applied AFTER the tier-gating wrapper above.
|
|
325
|
+
wrapWithEventLogging(server, api, AUTH);
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Security layer: rate limiting, input validation, audit logging
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Must be applied AFTER tier-gating and event logging wrappers above.
|
|
331
|
+
applySecurityLayer(server, currentAuth, api);
|
|
332
|
+
|
|
333
|
+
// ===== WORKSPACE CONNECTION: OAuth 2.0 (available at all tiers) =====
|
|
334
|
+
|
|
335
|
+
server.tool('connect_workspace',
|
|
336
|
+
'Connect your Adrata workspace via OAuth 2.0. Opens a browser window for authorization. By default the connection is READ-ONLY (least privilege): search/read companies, people, opportunities, pipeline, buyer groups, signals and analytics, plus the governed AI dispatcher for reads. Pass writeAccess:true to additionally request the CRM write scopes (companies, people, buyer groups, opportunities, actions, tasks) so create/update/delete work from this client; you will see them on the consent screen and can decline. Workspace administration and enrichment spend are never requested and always require a signed-in human session. The capability summary returned is derived from the scope the server actually granted, not from what was requested.',
|
|
337
|
+
{
|
|
338
|
+
writeAccess: z.boolean().optional().describe('Request CRM write scopes in addition to read. Defaults to false (read-only).'),
|
|
339
|
+
},
|
|
340
|
+
async (args = {}) => {
|
|
341
|
+
try {
|
|
342
|
+
const result = await connectWorkspace(API_BASE, { writeAccess: args.writeAccess === true });
|
|
343
|
+
// Re-authenticate to pick up the new stored tokens
|
|
344
|
+
AUTH = reauthenticate();
|
|
345
|
+
return ok(result);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return ok({ error: true, message: err.message, hint: 'Make sure your browser is accessible and try again.' });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
server.tool('disconnect_workspace',
|
|
352
|
+
'Disconnect your Adrata workspace. Revokes OAuth tokens and clears local credentials. Enterprise tools will no longer be available after disconnecting.',
|
|
353
|
+
{},
|
|
354
|
+
async () => {
|
|
355
|
+
try {
|
|
356
|
+
const result = await disconnectWorkspace(API_BASE);
|
|
357
|
+
// Re-authenticate to drop enterprise tier
|
|
358
|
+
AUTH = reauthenticate();
|
|
359
|
+
return ok(result);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
return ok({ error: true, message: err.message });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
server.tool('workspace_status',
|
|
366
|
+
'Check which credential this MCP server is actually running on, what tier it grants, which workspace it is acting in, and whether that credential still works. Run this first when a tool returns 401 or 403 — it names the credential in play and what to do about it.',
|
|
367
|
+
{},
|
|
368
|
+
async () => {
|
|
369
|
+
// `getConnectionStatus` reports on the STORED session (~/.adrata/tokens.json)
|
|
370
|
+
// and nothing else. That is the right question only when the stored session
|
|
371
|
+
// is the credential in play — and it is one of five (auth.js). With
|
|
372
|
+
// ADRATA_OAUTH_TOKEN, a shared agent session, or an API key, this tool used
|
|
373
|
+
// to report "No workspace connected" and `tier: free` while the server was
|
|
374
|
+
// running enterprise and every tool worked. That is the worst possible
|
|
375
|
+
// answer from a diagnostic: it sends someone to reconnect a connection that
|
|
376
|
+
// was never the problem, and it is the tool a connect flow tells you to run
|
|
377
|
+
// to confirm the setup worked.
|
|
378
|
+
const usingStoredSession = AUTH.source === 'stored';
|
|
379
|
+
const stored = await getConnectionStatus(API_BASE);
|
|
380
|
+
|
|
381
|
+
// AUTH.workspaceId is only populated when the workspace was pinned via env
|
|
382
|
+
// or CLI config. An OAuth connection leaves it null, which used to make
|
|
383
|
+
// this tool report `workspaceId: null` while every data tool was happily
|
|
384
|
+
// reading from a real workspace. Resolve it from the authenticated user so
|
|
385
|
+
// the system of record can always name the workspace it is acting on.
|
|
386
|
+
//
|
|
387
|
+
// Which credential decides "live" differs: for the stored session it is the
|
|
388
|
+
// session's own status, for any other credential it is simply that one is
|
|
389
|
+
// configured — `getConnectionStatus` only ever reports on the stored session.
|
|
390
|
+
const credentialLive = usingStoredSession ? stored.connected : AUTH.authenticated;
|
|
391
|
+
let workspaceId = AUTH.workspaceId || null;
|
|
392
|
+
let workspaceName = null;
|
|
393
|
+
if (!workspaceId && credentialLive) {
|
|
394
|
+
try {
|
|
395
|
+
const me = await api('GET', '/api/v1/users/me');
|
|
396
|
+
const u = me?.data || me;
|
|
397
|
+
workspaceId = u?.activeWorkspaceId || u?.workspaceId || null;
|
|
398
|
+
} catch {
|
|
399
|
+
// Leave null rather than inventing an ID; the caller can still see
|
|
400
|
+
// `connected` and act on it.
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (credentialLive) {
|
|
404
|
+
try {
|
|
405
|
+
// GET /api/v1/workspace resolves the caller's current workspace.
|
|
406
|
+
const ws = await api('GET', '/api/v1/workspace');
|
|
407
|
+
const w = ws?.data || ws;
|
|
408
|
+
workspaceName = w?.name || null;
|
|
409
|
+
workspaceId = workspaceId || w?.id || null;
|
|
410
|
+
} catch { /* name is a nicety, not a contract */ }
|
|
411
|
+
}
|
|
412
|
+
const workspaceIdSource = AUTH.workspaceId ? 'pinned' : (workspaceId ? 'resolved-from-user' : 'unknown');
|
|
413
|
+
|
|
414
|
+
if (usingStoredSession) {
|
|
415
|
+
return ok({
|
|
416
|
+
...stored,
|
|
417
|
+
tier: stored.connected ? AUTH.tier : TIERS.FREE,
|
|
418
|
+
credential: 'stored OAuth session (~/.adrata/tokens.json)',
|
|
419
|
+
authSource: AUTH.source,
|
|
420
|
+
apiBase: API_BASE,
|
|
421
|
+
workspaceId,
|
|
422
|
+
workspaceName,
|
|
423
|
+
workspaceIdSource,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Some other credential is in play. Say which, and report the stored
|
|
428
|
+
// session — if there even is one — as the side note it actually is.
|
|
429
|
+
const CREDENTIALS = {
|
|
430
|
+
env: 'ADRATA_OAUTH_TOKEN (environment)',
|
|
431
|
+
agent_config: 'shared agent session (~/.config/adrata/agent.json, from `adrata login`)',
|
|
432
|
+
api_key: 'ADRATA_API_KEY (environment)',
|
|
433
|
+
cli_config: 'legacy CLI token (~/.config/adrata/cli.json)',
|
|
434
|
+
none: 'none',
|
|
435
|
+
};
|
|
436
|
+
return ok({
|
|
437
|
+
connected: AUTH.authenticated,
|
|
438
|
+
tier: AUTH.tier,
|
|
439
|
+
credential: CREDENTIALS[AUTH.source] ?? AUTH.source,
|
|
440
|
+
authSource: AUTH.source,
|
|
441
|
+
apiBase: API_BASE,
|
|
442
|
+
workspaceId,
|
|
443
|
+
workspaceName,
|
|
444
|
+
workspaceIdSource,
|
|
445
|
+
// A bearer token this server did not mint cannot be introspected from
|
|
446
|
+
// here, so "connected" means "a credential is configured", not "the API
|
|
447
|
+
// accepted it". Say so rather than implying a liveness check happened.
|
|
448
|
+
note: AUTH.authenticated
|
|
449
|
+
? 'A credential is configured and every tool will be sent with it. This is not a liveness check — if calls still fail, the token is expired or lacks the scope the route requires (403 insufficient_scope names which).'
|
|
450
|
+
: 'No credential configured. Run connect_workspace, or set ADRATA_OAUTH_TOKEN / ADRATA_API_KEY.',
|
|
451
|
+
storedSession: stored.connected
|
|
452
|
+
? { present: true, note: 'A stored OAuth session also exists but is NOT the credential in use.' }
|
|
453
|
+
: { present: Boolean(stored.storedCredentials), note: stored.message },
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
server.tool('list_workspaces',
|
|
458
|
+
'List the workspaces the authenticated user is actually a member of, with their role in each. Use before switch_workspace when a user belongs to more than one workspace.',
|
|
459
|
+
{},
|
|
460
|
+
// `/api/v1/workspace/mine` — the signed-in user's own memberships. Deliberately
|
|
461
|
+
// NOT `/api/v1/workspace/list`: that is the administrative endpoint, and it
|
|
462
|
+
// expands a super_admin into EVERY customer tenant (see
|
|
463
|
+
// routes/v1/entities/org/workspace/handlers.rs). Listing those here would
|
|
464
|
+
// offer an agent a set of workspace IDs it could hand straight to
|
|
465
|
+
// switch_workspace, turning a convenience tool into a cross-tenant hop.
|
|
466
|
+
async () => ok(await api('GET', '/api/v1/workspace/mine')));
|
|
467
|
+
|
|
468
|
+
server.tool('switch_workspace',
|
|
469
|
+
'Switch the active workspace for the authenticated user. Subsequent tool calls read and write in the new workspace. Use list_workspaces first to get valid IDs.',
|
|
470
|
+
{ workspaceId: z.string().describe('Target workspace ID from list_workspaces') },
|
|
471
|
+
async (a) => ok(await api('POST', '/api/v1/workspace/switch', { body: { workspaceId: a.workspaceId } })));
|
|
472
|
+
|
|
473
|
+
// ===== ADRATA AGENT API BRIDGE =====
|
|
474
|
+
|
|
475
|
+
server.tool('adrata_api_catalog',
|
|
476
|
+
'Describe the governed Adrata API bridge for Claude Code/Codex. Use this to discover how to call any allowlisted platform API safely from the agent layer.',
|
|
477
|
+
{},
|
|
478
|
+
async () => ok(apiBridgeCatalog()));
|
|
479
|
+
|
|
480
|
+
server.tool('adrata_api_request',
|
|
481
|
+
'Governed low-level Adrata API request. Read calls run directly except sensitive denied surfaces. Writes default to dry-run; a live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey, and additionally requires the connection to hold the matching write:* scope (connect_workspace with writeAccess:true). Without that scope the API rejects the call with 403 insufficient_scope. Use only after a higher-level tool is not available.',
|
|
482
|
+
{
|
|
483
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('GET'),
|
|
484
|
+
path: z.string().describe('Relative Adrata API path, e.g. /api/v1/opportunities/opp_123'),
|
|
485
|
+
params: z.record(z.unknown()).optional(),
|
|
486
|
+
body: z.record(z.unknown()).optional(),
|
|
487
|
+
dryRun: z.boolean().optional().describe('Defaults to true for write methods. Set false to execute a live write; the connection must also hold the matching write:* scope.'),
|
|
488
|
+
approved: z.boolean().optional().describe('Required (true) for a live write. Records that the caller confirmed the mutation; it does not by itself grant scope.'),
|
|
489
|
+
reason: z.string().optional().describe('Required for a live write. Recorded as the audit reason (X-Adrata-Reason).'),
|
|
490
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Sent as Idempotency-Key so a retry cannot double-apply.'),
|
|
491
|
+
realtimePublish: z.object({
|
|
492
|
+
workspaceId: z.string(),
|
|
493
|
+
userId: z.string().optional(),
|
|
494
|
+
entityType: z.enum(['person', 'company', 'opportunity']).optional(),
|
|
495
|
+
entityId: z.string().optional(),
|
|
496
|
+
entityName: z.string().optional(),
|
|
497
|
+
changes: z.record(z.unknown()).optional(),
|
|
498
|
+
type: z.enum(['record_updated', 'pipeline_stage_changed']).optional(),
|
|
499
|
+
}).optional().describe('Optional realtime event to publish after a successful live write.'),
|
|
500
|
+
},
|
|
501
|
+
async (args) => {
|
|
502
|
+
try {
|
|
503
|
+
// Pass the scope the stored connection actually holds so a dry-run can
|
|
504
|
+
// say whether the live write would be rejected, instead of always
|
|
505
|
+
// answering wouldSend:true.
|
|
506
|
+
let grantedScope;
|
|
507
|
+
try {
|
|
508
|
+
grantedScope = loadTokens()?.scope;
|
|
509
|
+
} catch { /* no stored connection; dry-run reports "not checked" */ }
|
|
510
|
+
const validation = validateApiBridgeRequest({ ...args, grantedScope });
|
|
511
|
+
if (validation.dryRun) {
|
|
512
|
+
return ok({
|
|
513
|
+
...validation,
|
|
514
|
+
params: args.params || {},
|
|
515
|
+
body: args.body || {},
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const result = await api(validation.method, validation.path, {
|
|
520
|
+
params: args.params,
|
|
521
|
+
body: args.body,
|
|
522
|
+
headers: buildMutationHeaders(args),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
let realtime = null;
|
|
526
|
+
if (args.realtimePublish && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(validation.method)) {
|
|
527
|
+
const payload = buildRealtimePayload({
|
|
528
|
+
...args.realtimePublish,
|
|
529
|
+
source: 'agent',
|
|
530
|
+
});
|
|
531
|
+
realtime = await api('POST', '/api/v1/realtime/publish', { body: payload });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return ok({
|
|
535
|
+
ok: true,
|
|
536
|
+
method: validation.method,
|
|
537
|
+
path: validation.path,
|
|
538
|
+
result,
|
|
539
|
+
realtime,
|
|
540
|
+
});
|
|
541
|
+
} catch (err) {
|
|
542
|
+
return ok({ error: true, message: err.message });
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
server.tool('adrata_ai_tool_catalog',
|
|
547
|
+
'List the AI CRM tools Adrata chat can use, including Channels, external source catalogs, ICP ranking, batch import progress, and automation tools. Use this to audit parity before falling back to adrata_api_request.',
|
|
548
|
+
{},
|
|
549
|
+
async () => ok(await api('GET', '/api/v1/ai-crm-tools/catalog')));
|
|
550
|
+
|
|
551
|
+
server.tool('adrata_ai_tool_execute',
|
|
552
|
+
'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.',
|
|
553
|
+
{
|
|
554
|
+
toolName: z.string().describe('AI CRM tool name from adrata_ai_tool_catalog, e.g. list_channels or rank_companies_by_icp'),
|
|
555
|
+
arguments: z.record(z.unknown()).optional(),
|
|
556
|
+
dryRun: z.boolean().optional().describe('Defaults to true for write tools. Set false only after explicit approval.'),
|
|
557
|
+
approved: z.boolean().optional().describe('Deprecated compatibility field; never authorizes a live write.'),
|
|
558
|
+
reason: z.string().optional().describe('Required audit reason for a spend acknowledgment or when requesting a live-write confirmation token.'),
|
|
559
|
+
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.'),
|
|
560
|
+
confirmSpend: z.boolean().optional().describe('Required true to execute a read held for external vendor spend. Never authorizes a workspace write.'),
|
|
561
|
+
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.'),
|
|
562
|
+
},
|
|
563
|
+
async (args) => {
|
|
564
|
+
try {
|
|
565
|
+
return ok(await executeAiCrmTool(args.toolName, args.arguments || {}, {
|
|
566
|
+
dryRun: args.dryRun,
|
|
567
|
+
approved: args.approved,
|
|
568
|
+
reason: args.reason,
|
|
569
|
+
confirmationToken: args.confirmationToken,
|
|
570
|
+
confirmSpend: args.confirmSpend,
|
|
571
|
+
idempotencyKey: args.idempotencyKey,
|
|
572
|
+
}));
|
|
573
|
+
} catch (err) {
|
|
574
|
+
return ok({ error: true, message: err.message });
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// ===== PAPER DESKTOP APP TOOLS =====
|
|
579
|
+
|
|
580
|
+
registerPaperTools(server, {
|
|
581
|
+
api,
|
|
582
|
+
ok,
|
|
583
|
+
buildMutationHeaders,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
server.tool('get_account_read',
|
|
587
|
+
'Get Adrata chat’s grounded no-loss account/deal read through the governed ToolDispatcher. Returns Known/Read/Ask, evidence, confidence, why-now, best move, alternatives, falsification criteria, and observable-evidence triangulation guardrails. Never infer IQ, age, college-as-IQ, or protected classes.',
|
|
588
|
+
{
|
|
589
|
+
opportunityId: z.string().describe('Opportunity/deal ID. Required until company-level account reads are integrated.'),
|
|
590
|
+
companyId: z.string().optional().describe('Optional company/account ID for future account-read backend alignment.'),
|
|
591
|
+
includeEvidence: z.boolean().optional().describe('Whether to include evidence objects, default true.'),
|
|
592
|
+
},
|
|
593
|
+
async (args) => {
|
|
594
|
+
try {
|
|
595
|
+
return ok(await executeAiCrmTool('get_account_read', args));
|
|
596
|
+
} catch (err) {
|
|
597
|
+
return ok({ error: true, message: err.message });
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
server.tool('build_pursuit_command_center',
|
|
602
|
+
'Build or refresh Adrata’s persistent, evidence-linked Pursuit command center for one enterprise account. Composes the active opportunity, structured MEDDPICC, buyer room and path to power, deal health and forecast risk, triggers, one recommended Give/Get move, and reviewable email/cadence proposals. Preview is non-mutating by default. Persisting the artifact or cadence requires the server-issued confirmation flow, an audit reason, and idempotent governed execution.',
|
|
603
|
+
{
|
|
604
|
+
companyId: z.string().optional().describe('Company/account ID. Supply this or companyName.'),
|
|
605
|
+
companyName: z.string().optional().describe('Company/account name when the ID is not known.'),
|
|
606
|
+
opportunityId: z.string().optional().describe('Optional opportunity/deal ID; otherwise Adrata selects the newest active account opportunity.'),
|
|
607
|
+
buyerGroupId: z.string().optional().describe('Optional buyer-room/group ID.'),
|
|
608
|
+
persistArtifact: z.boolean().optional().describe('Persist the versioned Pursuit artifact after explicit approval. Defaults to false.'),
|
|
609
|
+
persistSequence: z.boolean().optional().describe('Persist the proposed cadence after explicit approval; never enrolls recipients. Defaults to false.'),
|
|
610
|
+
reason: z.string().optional().describe('Required audit reason when requesting an approved persistence operation.'),
|
|
611
|
+
confirmationToken: z.string().optional().describe('Single-use server token returned by the first persistence request.'),
|
|
612
|
+
},
|
|
613
|
+
async (args) => {
|
|
614
|
+
try {
|
|
615
|
+
const persistArtifact = args.persistArtifact ?? false;
|
|
616
|
+
const persistSequence = args.persistSequence ?? false;
|
|
617
|
+
return ok(await executeAiCrmTool('build_pursuit_command_center', {
|
|
618
|
+
companyId: args.companyId,
|
|
619
|
+
companyName: args.companyName,
|
|
620
|
+
opportunityId: args.opportunityId,
|
|
621
|
+
buyerGroupId: args.buyerGroupId,
|
|
622
|
+
dryRun: !persistArtifact && !persistSequence,
|
|
623
|
+
persistArtifact,
|
|
624
|
+
persistSequence,
|
|
625
|
+
}, {
|
|
626
|
+
dryRun: !persistArtifact && !persistSequence,
|
|
627
|
+
reason: args.reason,
|
|
628
|
+
confirmationToken: args.confirmationToken,
|
|
629
|
+
}));
|
|
630
|
+
} catch (err) {
|
|
631
|
+
return ok({ error: true, message: err.message });
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
server.tool('rank_paths_to_power',
|
|
636
|
+
'Rank existing warm introduction and network paths to a target account or decision maker through the governed ToolDispatcher. Does not create synthetic paths. Never infer IQ, age, college-as-IQ, or protected classes.',
|
|
637
|
+
{
|
|
638
|
+
targetPersonId: z.string().optional().describe('Known decision-maker/person ID.'),
|
|
639
|
+
targetPersonName: z.string().optional().describe('Decision-maker name if ID is unknown.'),
|
|
640
|
+
companyId: z.string().optional().describe('Target company/account ID.'),
|
|
641
|
+
companyName: z.string().optional().describe('Target company/account name if ID is unknown.'),
|
|
642
|
+
opportunityId: z.string().optional().describe('Optional opportunity/deal ID for deal context.'),
|
|
643
|
+
},
|
|
644
|
+
async (args) => {
|
|
645
|
+
try {
|
|
646
|
+
return ok(await executeAiCrmTool('rank_paths_to_power', args));
|
|
647
|
+
} catch (err) {
|
|
648
|
+
return ok({ error: true, message: err.message });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
server.tool('recommend_deal_move',
|
|
653
|
+
'Recommend Adrata chat’s next no-loss deal move through the governed ToolDispatcher. Read-only: use write tools separately after the seller chooses an action. Supports competitor wedge inputs and uses observable-evidence triangulation guardrails; never infers IQ, age, college-as-IQ, proxy personality, or protected classes.',
|
|
654
|
+
{
|
|
655
|
+
opportunityId: z.string().describe('Opportunity/deal ID for recommendation context.'),
|
|
656
|
+
personId: z.string().optional().describe('Optional person/contact ID for contact-level context.'),
|
|
657
|
+
companyId: z.string().optional().describe('Optional company/account ID for account context.'),
|
|
658
|
+
competitorPresent: z.boolean().optional().describe('Whether a competitor is known to be active in the deal.'),
|
|
659
|
+
competitorFavored: z.boolean().optional().describe('Whether evidence suggests the competitor is currently favored.'),
|
|
660
|
+
competitorStrengths: z.array(z.string()).optional().describe('Evidence-backed competitor strengths in this account.'),
|
|
661
|
+
competitorWeaknesses: z.array(z.string()).optional().describe('Evidence-backed competitor vulnerabilities or gaps in this account.'),
|
|
662
|
+
decisionCriteria: z.array(z.string()).optional().describe('Known buyer decision criteria from CRM, meetings, docs, or research.'),
|
|
663
|
+
},
|
|
664
|
+
async (args) => {
|
|
665
|
+
try {
|
|
666
|
+
return ok(await executeAiCrmTool('recommend_deal_move', args));
|
|
667
|
+
} catch (err) {
|
|
668
|
+
return ok({ error: true, message: err.message });
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
server.tool('list_external_pipelines',
|
|
673
|
+
'List Adrata Channels: refreshable external/internal pipelines such as YC, a16z Speedrun, Techstars, CSV imports, LinkedIn searches, sequences, and other source adapters.',
|
|
674
|
+
{
|
|
675
|
+
kind: z.enum(['company', 'person', 'opportunity']).optional(),
|
|
676
|
+
adapter: z.string().optional(),
|
|
677
|
+
tag: z.string().optional(),
|
|
678
|
+
globalOnly: z.boolean().optional(),
|
|
679
|
+
limit: z.number().optional(),
|
|
680
|
+
},
|
|
681
|
+
async (args) => {
|
|
682
|
+
try {
|
|
683
|
+
return ok(await executeAiCrmTool('list_channels', {
|
|
684
|
+
kind: args.kind,
|
|
685
|
+
adapter: args.adapter,
|
|
686
|
+
tag: args.tag,
|
|
687
|
+
global_only: args.globalOnly,
|
|
688
|
+
limit: args.limit,
|
|
689
|
+
}));
|
|
690
|
+
} catch (err) {
|
|
691
|
+
return ok({ error: true, message: err.message });
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
server.tool('list_external_pipeline_members',
|
|
696
|
+
'List members of an external pipeline/channel before import. Supports batch/country/status filters and can flag records for ICP ranking.',
|
|
697
|
+
{
|
|
698
|
+
channelId: z.string(),
|
|
699
|
+
limit: z.number().optional(),
|
|
700
|
+
filter: z.record(z.unknown()).optional(),
|
|
701
|
+
rankByIcp: z.boolean().optional(),
|
|
702
|
+
},
|
|
703
|
+
async (args) => {
|
|
704
|
+
try {
|
|
705
|
+
return ok(await executeAiCrmTool('list_channel_members', {
|
|
706
|
+
channel_id: args.channelId,
|
|
707
|
+
limit: args.limit,
|
|
708
|
+
filter: args.filter,
|
|
709
|
+
rank_by_icp: args.rankByIcp,
|
|
710
|
+
}));
|
|
711
|
+
} catch (err) {
|
|
712
|
+
return ok({ error: true, message: err.message });
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
server.tool('import_external_pipeline_members',
|
|
717
|
+
'Import members from an external pipeline/channel into the workspace through Adrata bulk import. Defaults to dry-run; live import requires explicit approval and reason.',
|
|
718
|
+
{
|
|
719
|
+
channelId: z.string(),
|
|
720
|
+
memberIds: z.array(z.string()).optional(),
|
|
721
|
+
topNByIcp: z.number().optional(),
|
|
722
|
+
dryRun: z.boolean().optional(),
|
|
723
|
+
approved: z.boolean().optional(),
|
|
724
|
+
reason: z.string().optional(),
|
|
725
|
+
},
|
|
726
|
+
async (args) => {
|
|
727
|
+
try {
|
|
728
|
+
return ok(await executeAiCrmTool('import_channel_members', {
|
|
729
|
+
channel_id: args.channelId,
|
|
730
|
+
member_ids: args.memberIds,
|
|
731
|
+
top_n_by_icp: args.topNByIcp,
|
|
732
|
+
}, {
|
|
733
|
+
dryRun: args.dryRun,
|
|
734
|
+
approved: args.approved,
|
|
735
|
+
reason: args.reason,
|
|
736
|
+
}));
|
|
737
|
+
} catch (err) {
|
|
738
|
+
return ok({ error: true, message: err.message });
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
server.tool('list_external_companies',
|
|
743
|
+
'Query Adrata nightly-refreshed external company catalogs directly, such as portfolio, cohort, or source-specific company lists. Prefer list_external_pipelines for group-shaped searches.',
|
|
744
|
+
{
|
|
745
|
+
source: z.string().optional(),
|
|
746
|
+
batch: z.string().optional(),
|
|
747
|
+
cohort: z.string().optional(),
|
|
748
|
+
hqCountry: z.string().optional(),
|
|
749
|
+
status: z.string().optional(),
|
|
750
|
+
limit: z.number().optional(),
|
|
751
|
+
},
|
|
752
|
+
async (args) => {
|
|
753
|
+
try {
|
|
754
|
+
return ok(await executeAiCrmTool('list_external_companies', {
|
|
755
|
+
source: args.source,
|
|
756
|
+
batch: args.batch,
|
|
757
|
+
cohort: args.cohort,
|
|
758
|
+
hq_country: args.hqCountry,
|
|
759
|
+
status: args.status,
|
|
760
|
+
limit: args.limit,
|
|
761
|
+
}));
|
|
762
|
+
} catch (err) {
|
|
763
|
+
return ok({ error: true, message: err.message });
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
server.tool('import_external_companies',
|
|
768
|
+
'Import selected external catalog companies into the workspace through Adrata bulk import, then use check_batch_import_status and rank_companies_by_icp for best-fit ordering.',
|
|
769
|
+
{
|
|
770
|
+
ids: z.array(z.string()).describe('Adrata internal external_source_companies ids from list_external_companies'),
|
|
771
|
+
dryRun: z.boolean().optional(),
|
|
772
|
+
approved: z.boolean().optional(),
|
|
773
|
+
reason: z.string().optional(),
|
|
774
|
+
},
|
|
775
|
+
async (args) => {
|
|
776
|
+
try {
|
|
777
|
+
return ok(await executeAiCrmTool('import_external_to_workspace', {
|
|
778
|
+
external_ids: args.ids,
|
|
779
|
+
}, {
|
|
780
|
+
dryRun: args.dryRun,
|
|
781
|
+
approved: args.approved,
|
|
782
|
+
reason: args.reason,
|
|
783
|
+
}));
|
|
784
|
+
} catch (err) {
|
|
785
|
+
return ok({ error: true, message: err.message });
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
server.tool('rank_companies_by_icp',
|
|
790
|
+
'Rank imported companies by similarity to the workspace ICP/closed-won profile. Use immediately after external or bulk imports to surface the best-fit accounts first.',
|
|
791
|
+
{
|
|
792
|
+
companyIds: z.array(z.string()),
|
|
793
|
+
topN: z.number().optional(),
|
|
794
|
+
},
|
|
795
|
+
async (args) => {
|
|
796
|
+
try {
|
|
797
|
+
return ok(await executeAiCrmTool('rank_companies_by_icp', {
|
|
798
|
+
company_ids: args.companyIds,
|
|
799
|
+
top_n: args.topN,
|
|
800
|
+
}));
|
|
801
|
+
} catch (err) {
|
|
802
|
+
return ok({ error: true, message: err.message });
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
server.tool('rank_people_by_icp',
|
|
807
|
+
'Rank a set of people by fit to the workspace ICP profile (title/persona match blended with company firmographics). Use after importing a list of people (e.g. LinkedIn post engagers) to surface the best-fit contacts first.',
|
|
808
|
+
{
|
|
809
|
+
personIds: z.array(z.string()),
|
|
810
|
+
topN: z.number().optional(),
|
|
811
|
+
minScore: z.number().optional(),
|
|
812
|
+
},
|
|
813
|
+
async (args) => {
|
|
814
|
+
try {
|
|
815
|
+
return ok(await executeAiCrmTool('rank_people_by_icp', {
|
|
816
|
+
person_ids: args.personIds,
|
|
817
|
+
top_n: args.topN,
|
|
818
|
+
min_score: args.minScore,
|
|
819
|
+
}));
|
|
820
|
+
} catch (err) {
|
|
821
|
+
return ok({ error: true, message: err.message });
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
server.tool('check_batch_import_status',
|
|
826
|
+
'Check progress for Adrata bulk imports created by external catalog, Channel, CSV, or bulk create flows.',
|
|
827
|
+
{
|
|
828
|
+
batchId: z.string(),
|
|
829
|
+
},
|
|
830
|
+
async (args) => {
|
|
831
|
+
try {
|
|
832
|
+
return ok(await executeAiCrmTool('check_batch_import_status', {
|
|
833
|
+
batch_id: args.batchId,
|
|
834
|
+
}));
|
|
835
|
+
} catch (err) {
|
|
836
|
+
return ok({ error: true, message: err.message });
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
server.tool('move_pipeline_card',
|
|
841
|
+
'Move a person/company/opportunity card on a pipeline board and publish a realtime event so the web app can update. Requires explicit approval, reason, and idempotencyKey.',
|
|
842
|
+
{
|
|
843
|
+
entityType: z.enum(['person', 'company', 'opportunity']),
|
|
844
|
+
entityId: z.string(),
|
|
845
|
+
targetStage: z.string(),
|
|
846
|
+
targetRank: z.number().optional(),
|
|
847
|
+
previousStage: z.string().optional(),
|
|
848
|
+
entityName: z.string().optional(),
|
|
849
|
+
workspaceId: z.string().optional().describe('Defaults to ADRATA_WORKSPACE_ID or CLI config workspace. Required for realtime fan-out.'),
|
|
850
|
+
userId: z.string().optional().describe('User id for realtime attribution. Defaults to agent.'),
|
|
851
|
+
approved: z.boolean().optional(),
|
|
852
|
+
reason: z.string().optional(),
|
|
853
|
+
idempotencyKey: z.string().optional(),
|
|
854
|
+
},
|
|
855
|
+
async (args) => {
|
|
856
|
+
try {
|
|
857
|
+
const workspaceId = args.workspaceId || currentAuth().workspaceId;
|
|
858
|
+
if (!workspaceId) {
|
|
859
|
+
throw new Error('workspaceId is required for realtime board updates');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const endpointByType = {
|
|
863
|
+
person: 'people',
|
|
864
|
+
company: 'companies',
|
|
865
|
+
opportunity: 'opportunities',
|
|
866
|
+
};
|
|
867
|
+
const endpoint = endpointByType[args.entityType];
|
|
868
|
+
const customFields =
|
|
869
|
+
args.entityType === 'opportunity'
|
|
870
|
+
? {
|
|
871
|
+
opportunityRankByStage: {
|
|
872
|
+
[args.targetStage]: args.targetRank,
|
|
873
|
+
},
|
|
874
|
+
}
|
|
875
|
+
: {
|
|
876
|
+
pipelineStage: args.targetStage,
|
|
877
|
+
...(args.targetRank !== undefined ? { pipelineRank: args.targetRank } : {}),
|
|
878
|
+
};
|
|
879
|
+
const updateBody =
|
|
880
|
+
args.entityType === 'opportunity'
|
|
881
|
+
? {
|
|
882
|
+
stage: args.targetStage,
|
|
883
|
+
customFields,
|
|
884
|
+
}
|
|
885
|
+
: {
|
|
886
|
+
stage: args.targetStage,
|
|
887
|
+
pipelineStage: args.targetStage,
|
|
888
|
+
customFields,
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
validateApiBridgeRequest({
|
|
892
|
+
method: 'PATCH',
|
|
893
|
+
path: `/api/v1/${endpoint}/${args.entityId}`,
|
|
894
|
+
dryRun: false,
|
|
895
|
+
approved: args.approved,
|
|
896
|
+
reason: args.reason,
|
|
897
|
+
idempotencyKey: args.idempotencyKey,
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const result = await api('PATCH', `/api/v1/${endpoint}/${args.entityId}`, {
|
|
901
|
+
body: updateBody,
|
|
902
|
+
headers: buildMutationHeaders(args),
|
|
903
|
+
});
|
|
904
|
+
const realtimePayload = buildRealtimePayload({
|
|
905
|
+
workspaceId,
|
|
906
|
+
userId: args.userId,
|
|
907
|
+
entityType: args.entityType,
|
|
908
|
+
entityId: args.entityId,
|
|
909
|
+
entityName: args.entityName,
|
|
910
|
+
type: 'pipeline_stage_changed',
|
|
911
|
+
source: 'agent',
|
|
912
|
+
changes: {
|
|
913
|
+
previousStage: args.previousStage,
|
|
914
|
+
newStage: args.targetStage,
|
|
915
|
+
targetRank: args.targetRank,
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
const realtime = await api('POST', '/api/v1/realtime/publish', { body: realtimePayload });
|
|
919
|
+
|
|
920
|
+
return ok({
|
|
921
|
+
moved: true,
|
|
922
|
+
entityType: args.entityType,
|
|
923
|
+
entityId: args.entityId,
|
|
924
|
+
targetStage: args.targetStage,
|
|
925
|
+
targetRank: args.targetRank,
|
|
926
|
+
result,
|
|
927
|
+
realtime,
|
|
928
|
+
});
|
|
929
|
+
} catch (err) {
|
|
930
|
+
return ok({ error: true, message: err.message });
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// ===== FREE TIER: Claude-powered search (zero cost to Adrata) =====
|
|
935
|
+
|
|
936
|
+
server.tool('find_company',
|
|
937
|
+
'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.',
|
|
938
|
+
{ name: z.string().describe('Company name to research (e.g. "Stripe", "Snowflake", "Datadog")') },
|
|
939
|
+
async (a) => findCompany(a.name));
|
|
940
|
+
|
|
941
|
+
server.tool('find_person',
|
|
942
|
+
'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.',
|
|
943
|
+
{ name: z.string().describe('Full name of the person to research (e.g. "Patrick Collison", "Jensen Huang")') },
|
|
944
|
+
async (a) => findPerson(a.name));
|
|
945
|
+
|
|
946
|
+
// ===== COMPANIES =====
|
|
947
|
+
|
|
948
|
+
server.tool('search_companies', 'Search companies by name, domain, industry, or status. Returns customFields.',
|
|
949
|
+
{ query: z.string().optional(), industry: z.string().optional(), status: z.string().optional().describe('ACTIVE, PROSPECT, CUSTOMER, CHURNED'), ...page },
|
|
950
|
+
async (a) => ok(await api('GET', '/api/v1/companies', { params: { search: a.query, industry: a.industry, status: a.status, limit: a.limit || 25, page: a.page || 1 } })));
|
|
951
|
+
|
|
952
|
+
server.tool('get_company', 'Get full company details including customFields, lastAction, intelligence data.',
|
|
953
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/companies/${a.id}`)));
|
|
954
|
+
|
|
955
|
+
server.tool('create_company', 'Create a new company.',
|
|
956
|
+
{ name: z.string(), domain: z.string().optional(), industry: z.string().optional(), website: z.string().optional(), status: z.string().optional(), customFields: z.record(z.unknown()).optional() },
|
|
957
|
+
async (a) => ok(await api('POST', '/api/v1/companies', { body: a })));
|
|
958
|
+
|
|
959
|
+
server.tool('update_company', 'Update company fields. customFields are JSONB-merged.',
|
|
960
|
+
{ id: z.string(), name: z.string().optional(), industry: z.string().optional(), website: z.string().optional(), status: z.string().optional(), customFields: z.record(z.unknown()).optional() },
|
|
961
|
+
async ({ id, ...f }) => ok(await api('PATCH', `/api/v1/companies/${id}`, { body: body(f) })));
|
|
962
|
+
|
|
963
|
+
server.tool('delete_company', 'Soft-delete a company.',
|
|
964
|
+
{ id: z.string() }, async (a) => { await api('DELETE', `/api/v1/companies/${a.id}`); return ok({ deleted: a.id }); });
|
|
965
|
+
|
|
966
|
+
server.tool('get_company_people', 'List all people at a company.',
|
|
967
|
+
{ companyId: z.string(), ...page },
|
|
968
|
+
async (a) => ok(await api('GET', `/api/v1/companies/${a.companyId}/people`, { params: { limit: a.limit || 50, page: a.page || 1 } })));
|
|
969
|
+
|
|
970
|
+
server.tool('get_company_opportunities', 'List all opportunities for a company.',
|
|
971
|
+
{ companyId: z.string(), ...page },
|
|
972
|
+
async (a) => ok(await api('GET', `/api/v1/companies/${a.companyId}/opportunities`, { params: { limit: a.limit || 25, page: a.page || 1 } })));
|
|
973
|
+
|
|
974
|
+
server.tool('get_company_actions', 'List all actions on a company.',
|
|
975
|
+
{ companyId: z.string(), ...page },
|
|
976
|
+
async (a) => ok(await api('GET', `/api/v1/companies/${a.companyId}/actions`, { params: { limit: a.limit || 25, page: a.page || 1 } })));
|
|
977
|
+
|
|
978
|
+
// ===== PEOPLE / CONTACTS =====
|
|
979
|
+
|
|
980
|
+
server.tool('search_people', 'Search people by name, email, company, title. Returns all statuses. Filter by contactCategory to separate real targets (Lead/Prospect) from known-but-not-a-target relationships (Introducer). Use viewMode:"leader" to read the whole team\'s records instead of only your own.',
|
|
981
|
+
{
|
|
982
|
+
query: z.string().optional(),
|
|
983
|
+
companyId: z.string().optional(),
|
|
984
|
+
status: z.string().optional().describe('LEAD, PROSPECT, CUSTOMER, ACTIVE'),
|
|
985
|
+
contactCategory: z.enum(['Lead', 'Prospect', 'Introducer']).optional()
|
|
986
|
+
.describe('Lead/Prospect appear in pipeline; Introducer is a known relationship that is deliberately excluded from pipeline.'),
|
|
987
|
+
viewMode: z.enum(['seller', 'leader']).optional()
|
|
988
|
+
.describe('seller = your own records (default). leader = the whole team\'s records; requires workspace admin.'),
|
|
989
|
+
...page,
|
|
990
|
+
},
|
|
991
|
+
async (a) => ok(await api('GET', '/api/v1/people', { params: { search: a.query, companyId: a.companyId, status: a.status, contactCategory: a.contactCategory, viewMode: a.viewMode, limit: a.limit || 25, page: a.page || 1 } })));
|
|
992
|
+
|
|
993
|
+
server.tool('get_person', 'Get full person details including customFields, lastAction, companyName.',
|
|
994
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/people/${a.id}`)));
|
|
995
|
+
|
|
996
|
+
server.tool('create_person', 'Create a new person/contact. Set contactCategory:"Introducer" for someone you know who is NOT a sales target — they stay out of pipeline. Set customFields.researchOnly:true to suppress buyer-room and pursuit side effects on import.',
|
|
997
|
+
{
|
|
998
|
+
name: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(),
|
|
999
|
+
email: z.string().optional(), phone: z.string().optional(), title: z.string().optional(),
|
|
1000
|
+
companyId: z.string().optional(),
|
|
1001
|
+
status: z.string().optional().describe('LEAD, PROSPECT, CUSTOMER'),
|
|
1002
|
+
contactCategory: z.enum(['Lead', 'Prospect', 'Introducer']).optional()
|
|
1003
|
+
.describe('Lead/Prospect enter pipeline; Introducer is a known relationship kept out of pipeline. Default to Introducer for bulk relationship imports.'),
|
|
1004
|
+
linkedinUrl: z.string().optional(),
|
|
1005
|
+
customFields: z.record(z.unknown()).optional()
|
|
1006
|
+
.describe('Set researchOnly:true to suppress automatic Room creation and pursuit side effects.'),
|
|
1007
|
+
},
|
|
1008
|
+
async (a) => ok(await api('POST', '/api/v1/people', { body: a })));
|
|
1009
|
+
|
|
1010
|
+
server.tool('update_person', 'Update person fields. customFields are JSONB-merged. Works for leads too. Change contactCategory to move someone between pipeline (Lead/Prospect) and known-relationship-only (Introducer).',
|
|
1011
|
+
{ id: z.string(), name: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(), email: z.string().optional(), title: z.string().optional(), phone: z.string().optional(), status: z.string().optional(), contactCategory: z.enum(['Lead', 'Prospect', 'Introducer']).optional().describe('Lead/Prospect enter pipeline; Introducer is kept out of pipeline.'), linkedinUrl: z.string().optional(), customFields: z.record(z.unknown()).optional() },
|
|
1012
|
+
async ({ id, ...f }) => ok(await api('PATCH', `/api/v1/people/${id}`, { body: body(f) })));
|
|
1013
|
+
|
|
1014
|
+
server.tool('delete_person', 'Soft-delete a person.',
|
|
1015
|
+
{ id: z.string() }, async (a) => { await api('DELETE', `/api/v1/people/${a.id}`); return ok({ deleted: a.id }); });
|
|
1016
|
+
|
|
1017
|
+
// ===== OPPORTUNITIES =====
|
|
1018
|
+
|
|
1019
|
+
server.tool('search_opportunities', 'Search deals by name, stage, company, or owner.',
|
|
1020
|
+
{ query: z.string().optional(), companyId: z.string().optional(), stage: z.string().optional().describe('Discovery, Qualification, Proposal, Negotiation, Closed Won, Closed Lost'), ownerId: z.string().optional(), ...page },
|
|
1021
|
+
async (a) => ok(await api('GET', '/api/v1/opportunities', { params: { search: a.query, companyId: a.companyId, stage: a.stage, ownerId: a.ownerId, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1022
|
+
|
|
1023
|
+
server.tool('get_opportunity', 'Get full opportunity details including stage, amount, customFields.',
|
|
1024
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/opportunities/${a.id}`)));
|
|
1025
|
+
|
|
1026
|
+
server.tool('create_opportunity', 'Create a new deal.',
|
|
1027
|
+
{ name: z.string(), companyId: z.string().optional(), amount: z.number().optional(), stage: z.string().optional(), probability: z.number().optional(), closeDate: z.string().optional(), ownerId: z.string().optional(), customFields: z.record(z.unknown()).optional() },
|
|
1028
|
+
async (a) => ok(await api('POST', '/api/v1/opportunities', { body: a })));
|
|
1029
|
+
|
|
1030
|
+
server.tool('update_opportunity', 'Update deal fields. customFields are JSONB-merged.',
|
|
1031
|
+
{ id: z.string(), name: z.string().optional(), amount: z.number().optional(), stage: z.string().optional(), probability: z.number().optional(), closeDate: z.string().optional(), customFields: z.record(z.unknown()).optional() },
|
|
1032
|
+
async ({ id, ...f }) => ok(await api('PATCH', `/api/v1/opportunities/${id}`, { body: body(f) })));
|
|
1033
|
+
|
|
1034
|
+
server.tool('delete_opportunity', 'Delete an opportunity.',
|
|
1035
|
+
{ id: z.string() }, async (a) => { await api('DELETE', `/api/v1/opportunities/${a.id}`); return ok({ deleted: a.id }); });
|
|
1036
|
+
|
|
1037
|
+
// ===== ACTIONS =====
|
|
1038
|
+
|
|
1039
|
+
server.tool('create_action', 'Log an action (outreach, call, meeting, note) against a person or company. Metadata is JSONB for type-specific data.',
|
|
1040
|
+
{ title: z.string(), type: z.string().optional().describe('email, call, meeting, note, linkedin_connection_request, linkedin_message, task'), personId: z.string().optional(), companyId: z.string().optional(), status: z.string().optional().describe('PLANNED, IN_PROGRESS, COMPLETED'), priority: z.string().optional().describe('LOW, NORMAL, HIGH, URGENT'), dueDate: z.string().optional(), outcome: z.string().optional(), metadata: z.record(z.unknown()).optional(), assignedTo: z.string().optional() },
|
|
1041
|
+
async (a) => ok(await api('POST', '/api/v1/actions', { body: a })));
|
|
1042
|
+
|
|
1043
|
+
server.tool('list_actions', 'List actions with filters. Use to answer "what did X do this week?" Use viewMode:"leader" to see the whole team\'s activity rather than only your own.',
|
|
1044
|
+
{ status: z.string().optional(), type: z.string().optional(), personId: z.string().optional(), companyId: z.string().optional(), assignedTo: z.string().optional(), priority: z.string().optional(), sortBy: z.string().optional(), sortOrder: z.string().optional(), viewMode: z.enum(['seller', 'leader']).optional().describe('seller = your own actions (default). leader = the whole team\'s actions; requires workspace admin.'), ...page },
|
|
1045
|
+
async ({ sortBy, sortOrder, assignedTo, viewMode, ...r }) => ok(await api('GET', '/api/v1/actions', { params: { ...r, assigned_to: assignedTo, viewMode, sort_by: sortBy, sort_order: sortOrder, limit: r.limit || 25, page: r.page || 1 } })));
|
|
1046
|
+
|
|
1047
|
+
server.tool('get_action', 'Get full action details including metadata.',
|
|
1048
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/actions/${a.id}`)));
|
|
1049
|
+
|
|
1050
|
+
server.tool('update_action', 'Update an action. Metadata is JSONB-merged.',
|
|
1051
|
+
{ id: z.string(), title: z.string().optional(), type: z.string().optional(), status: z.string().optional(), priority: z.string().optional(), dueDate: z.string().optional(), outcome: z.string().optional(), metadata: z.record(z.unknown()).optional() },
|
|
1052
|
+
async ({ id, ...f }) => ok(await api('PUT', `/api/v1/actions/${id}`, { body: body(f) })));
|
|
1053
|
+
|
|
1054
|
+
server.tool('complete_action', 'Mark action as completed with optional outcome.',
|
|
1055
|
+
{ id: z.string(), outcome: z.string().optional() },
|
|
1056
|
+
async ({ id, outcome }) => { if (outcome) await api('PUT', `/api/v1/actions/${id}`, { body: { outcome } }); return ok(await api('POST', `/api/v1/actions/${id}/complete`)); });
|
|
1057
|
+
|
|
1058
|
+
server.tool('delete_action', 'Soft-delete an action.',
|
|
1059
|
+
{ id: z.string() }, async (a) => { await api('DELETE', `/api/v1/actions/${a.id}`); return ok({ deleted: a.id }); });
|
|
1060
|
+
|
|
1061
|
+
server.tool('list_overdue_actions', 'List actions past their due date.',
|
|
1062
|
+
page, async (a) => ok(await api('GET', '/api/v1/actions/overdue', { params: { limit: a.limit || 25, page: a.page || 1 } })));
|
|
1063
|
+
|
|
1064
|
+
server.tool('list_today_actions', 'List actions scheduled for today.',
|
|
1065
|
+
page, async (a) => ok(await api('GET', '/api/v1/actions/today', { params: { limit: a.limit || 25, page: a.page || 1 } })));
|
|
1066
|
+
|
|
1067
|
+
// ===== NOTES =====
|
|
1068
|
+
|
|
1069
|
+
// The notes column is `content` (renamed from `body` by
|
|
1070
|
+
// 20260227_fix_schema_mismatches.sql). These tools kept sending `body`, which
|
|
1071
|
+
// the API bound nowhere: create failed validation and update was a silent
|
|
1072
|
+
// no-op that still answered 200. Send `content` and keep accepting `body` as
|
|
1073
|
+
// the tool-facing argument name so existing agent prompts keep working.
|
|
1074
|
+
server.tool('create_note', 'Add a note to a person, company, or opportunity.',
|
|
1075
|
+
{ body: z.string().describe('The note text.'), companyId: z.string().optional(), personId: z.string().optional(), opportunityId: z.string().optional() },
|
|
1076
|
+
async ({ body: text, ...rest }) => ok(await api('POST', '/api/v1/notes', { body: { content: text, ...rest } })));
|
|
1077
|
+
|
|
1078
|
+
server.tool('list_notes', 'List notes, filterable by entity.',
|
|
1079
|
+
{ companyId: z.string().optional(), personId: z.string().optional(), opportunityId: z.string().optional(), search: z.string().optional(), ...page },
|
|
1080
|
+
async (a) => ok(await api('GET', '/api/v1/notes', { params: { ...a, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1081
|
+
|
|
1082
|
+
server.tool('update_note', 'Update note content.',
|
|
1083
|
+
{ id: z.string(), body: z.string().describe('The new note text.') },
|
|
1084
|
+
async (a) => ok(await api('PUT', `/api/v1/notes/${a.id}`, { body: { content: a.body } })));
|
|
1085
|
+
|
|
1086
|
+
server.tool('delete_note', 'Delete a note.',
|
|
1087
|
+
{ id: z.string() }, async (a) => { await api('DELETE', `/api/v1/notes/${a.id}`); return ok({ deleted: a.id }); });
|
|
1088
|
+
|
|
1089
|
+
// ===== BUYER GROUPS =====
|
|
1090
|
+
//
|
|
1091
|
+
// Buyer groups ARE buyer rooms: the room record cascades to every member seat.
|
|
1092
|
+
// Reads below are unchanged. Every WRITE goes through `governedWrite`, the
|
|
1093
|
+
// same dryRun/approved/reason/idempotencyKey contract `adrata_api_request`
|
|
1094
|
+
// enforces — before this, the typed tools bypassed it entirely and were the
|
|
1095
|
+
// least governed path to the most destructive operation in the toolset.
|
|
1096
|
+
|
|
1097
|
+
/** Governance fields every buyer-group write tool accepts. */
|
|
1098
|
+
const governedWriteArgs = {
|
|
1099
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Returns a preview of the exact call instead of performing it. Set false to execute.'),
|
|
1100
|
+
approved: z.boolean().optional().describe('Required (true) for a live write. Records that the caller confirmed the mutation; it does not by itself grant scope.'),
|
|
1101
|
+
reason: z.string().optional().describe('Required for a live write. Recorded as the audit reason (X-Adrata-Reason).'),
|
|
1102
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Sent as Idempotency-Key so a retry cannot double-apply.'),
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
const GOVERNED_WRITE_NOTE =
|
|
1106
|
+
' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey, and the connection must hold write:buyer-groups (connect_workspace with writeAccess:true).';
|
|
1107
|
+
|
|
1108
|
+
/** Shared handler shape: preview object on a dry run, tool payload on a live write. */
|
|
1109
|
+
async function runGovernedBuyerGroupWrite(args, request, onSuccess) {
|
|
1110
|
+
try {
|
|
1111
|
+
const outcome = await governedWrite(api, args, request);
|
|
1112
|
+
if (outcome.dryRun) return ok(outcome.preview);
|
|
1113
|
+
return ok(onSuccess(outcome.result));
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
return ok({ error: true, message: err.message });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
server.tool('list_buyer_groups', 'List buyer groups — the decision-making units at companies.',
|
|
1120
|
+
{ companyId: z.string().optional(), ...page },
|
|
1121
|
+
async (a) => ok(await api('GET', '/api/v1/buyer-group', { params: { companyId: a.companyId, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1122
|
+
|
|
1123
|
+
server.tool('get_buyer_group', 'Get buyer group details including members and roles.',
|
|
1124
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/buyer-group/${a.id}`)));
|
|
1125
|
+
|
|
1126
|
+
server.tool('create_buyer_group', `Create a buyer group for a company.${GOVERNED_WRITE_NOTE}`,
|
|
1127
|
+
{ name: z.string(), companyId: z.string(), ...governedWriteArgs },
|
|
1128
|
+
async (a) => runGovernedBuyerGroupWrite(
|
|
1129
|
+
a,
|
|
1130
|
+
{ method: 'POST', path: '/api/v1/buyer-group', body: { name: a.name, companyId: a.companyId } },
|
|
1131
|
+
(result) => result,
|
|
1132
|
+
));
|
|
1133
|
+
|
|
1134
|
+
server.tool('add_buyer_group_member', `Add a person to a buyer group.${GOVERNED_WRITE_NOTE}`,
|
|
1135
|
+
{
|
|
1136
|
+
buyerGroupId: z.string(),
|
|
1137
|
+
personId: z.string(),
|
|
1138
|
+
role: z.string().optional().describe('Champion, Decision Maker, Influencer, Blocker, End User'),
|
|
1139
|
+
...governedWriteArgs,
|
|
1140
|
+
},
|
|
1141
|
+
async (a) => runGovernedBuyerGroupWrite(
|
|
1142
|
+
a,
|
|
1143
|
+
{
|
|
1144
|
+
method: 'POST',
|
|
1145
|
+
path: `/api/v1/buyer-group/${a.buyerGroupId}/members`,
|
|
1146
|
+
body: { personId: a.personId, role: a.role },
|
|
1147
|
+
},
|
|
1148
|
+
(result) => result,
|
|
1149
|
+
));
|
|
1150
|
+
|
|
1151
|
+
server.tool('get_buyer_group_members', 'List members of a buyer group with roles.',
|
|
1152
|
+
{ buyerGroupId: z.string() },
|
|
1153
|
+
async (a) => ok(await api('GET', `/api/v1/buyer-group/${a.buyerGroupId}/members`)));
|
|
1154
|
+
|
|
1155
|
+
server.tool('update_buyer_group', `Update a buyer group’s name or the company it belongs to.${GOVERNED_WRITE_NOTE}`,
|
|
1156
|
+
{ id: z.string(), name: z.string().optional(), companyId: z.string().optional(), ...governedWriteArgs },
|
|
1157
|
+
async (a) => runGovernedBuyerGroupWrite(
|
|
1158
|
+
a,
|
|
1159
|
+
{ method: 'PUT', path: `/api/v1/buyer-group/${a.id}`, body: body({ name: a.name, companyId: a.companyId }) },
|
|
1160
|
+
(result) => result,
|
|
1161
|
+
));
|
|
1162
|
+
|
|
1163
|
+
server.tool('update_buyer_group_member', `Update a member’s role or influence inside a buyer group.${GOVERNED_WRITE_NOTE}`,
|
|
1164
|
+
{
|
|
1165
|
+
buyerGroupId: z.string(),
|
|
1166
|
+
memberId: z.string(),
|
|
1167
|
+
role: z.string().optional().describe('Champion, Decision Maker, Influencer, Blocker, End User'),
|
|
1168
|
+
influenceScore: z.number().optional(),
|
|
1169
|
+
personId: z.string().optional().describe('Link the seat to an existing person record'),
|
|
1170
|
+
...governedWriteArgs,
|
|
1171
|
+
},
|
|
1172
|
+
async (a) => runGovernedBuyerGroupWrite(
|
|
1173
|
+
a,
|
|
1174
|
+
{
|
|
1175
|
+
method: 'PUT',
|
|
1176
|
+
path: `/api/v1/buyer-group/${a.buyerGroupId}/members/${a.memberId}`,
|
|
1177
|
+
body: body({ role: a.role, influenceScore: a.influenceScore, personId: a.personId }),
|
|
1178
|
+
},
|
|
1179
|
+
(result) => result,
|
|
1180
|
+
));
|
|
1181
|
+
|
|
1182
|
+
server.tool('remove_buyer_group_member',
|
|
1183
|
+
`Remove one person from a buyer group. Does NOT delete the underlying person record. To remove the whole room, use delete_buyer_group instead — it cascades.${GOVERNED_WRITE_NOTE}`,
|
|
1184
|
+
{ buyerGroupId: z.string(), memberId: z.string(), ...governedWriteArgs },
|
|
1185
|
+
async (a) => runGovernedBuyerGroupWrite(
|
|
1186
|
+
a,
|
|
1187
|
+
{ method: 'DELETE', path: `/api/v1/buyer-group/${a.buyerGroupId}/members/${a.memberId}` },
|
|
1188
|
+
() => ({ removed: a.memberId, buyerGroupId: a.buyerGroupId }),
|
|
1189
|
+
));
|
|
1190
|
+
|
|
1191
|
+
server.tool('delete_buyer_group',
|
|
1192
|
+
`Soft-delete an entire buyer group (buyer room). CASCADES to every member seat in the room, so you do not need to empty it first. Does NOT delete the underlying people or the company.${GOVERNED_WRITE_NOTE}`,
|
|
1193
|
+
{ id: z.string(), ...governedWriteArgs },
|
|
1194
|
+
async (a) => runGovernedBuyerGroupWrite(
|
|
1195
|
+
a,
|
|
1196
|
+
{ method: 'DELETE', path: `/api/v1/buyer-group/${a.id}`, preview: { cascade: 'members' } },
|
|
1197
|
+
() => ({ deleted: a.id, cascade: 'members' }),
|
|
1198
|
+
));
|
|
1199
|
+
|
|
1200
|
+
server.tool('bulk_delete_buyer_groups',
|
|
1201
|
+
'Soft-delete up to 50 buyer groups in ONE call, each cascading to its members. Use this instead of looping delete_buyer_group when cleaning many rooms out of a workspace.'
|
|
1202
|
+
+ ' STRICTEST GATE IN THIS TOOLSET: on top of the governed-write contract (dryRun:false, approved:true, reason, idempotencyKey) a live call must pass confirmCount equal to the number of distinct ids being deleted, so a widened list cannot ride along on an approval given for a smaller one.',
|
|
1203
|
+
{
|
|
1204
|
+
ids: z.array(z.string()).min(1).max(50).describe('Buyer group IDs (max 50 per call)'),
|
|
1205
|
+
confirmCount: z.number().int().optional().describe('Required for a live write. Must equal the number of DISTINCT ids in this call.'),
|
|
1206
|
+
...governedWriteArgs,
|
|
1207
|
+
},
|
|
1208
|
+
async (a) => {
|
|
1209
|
+
// Dedupe first: the count the caller confirms must be the count of rooms
|
|
1210
|
+
// that actually get deleted, not the length of a list with repeats.
|
|
1211
|
+
const ids = [...new Set(a.ids)];
|
|
1212
|
+
const isLiveWrite = a.dryRun === false;
|
|
1213
|
+
if (isLiveWrite && a.confirmCount !== ids.length) {
|
|
1214
|
+
return ok({
|
|
1215
|
+
error: true,
|
|
1216
|
+
message: `bulk delete refused: confirmCount must equal the ${ids.length} distinct id(s) in this call, got ${a.confirmCount === undefined ? 'nothing' : a.confirmCount}`,
|
|
1217
|
+
distinctIds: ids.length,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
return runGovernedBuyerGroupWrite(
|
|
1221
|
+
a,
|
|
1222
|
+
{
|
|
1223
|
+
method: 'DELETE',
|
|
1224
|
+
path: '/api/v1/buyer-group/bulk',
|
|
1225
|
+
body: { ids },
|
|
1226
|
+
preview: { distinctIds: ids.length, cascade: 'members', requiresConfirmCount: ids.length },
|
|
1227
|
+
},
|
|
1228
|
+
(result) => result,
|
|
1229
|
+
);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
server.tool('get_access_score', 'Get the deterministic Access Score for a buyer group, including sub-scores, risk level, top gaps, and provenance.',
|
|
1233
|
+
{ buyerGroupId: z.string() },
|
|
1234
|
+
async (a) => ok(await api('GET', `/api/v1/buyer-group/${a.buyerGroupId}/access-score`)));
|
|
1235
|
+
|
|
1236
|
+
server.tool('get_access_graph_projection', 'Inspect the access graph for a buyer group: buyer group coverage plus intro group routes and touchpoints.',
|
|
1237
|
+
{ buyerGroupId: z.string().describe('Buyer group ID') },
|
|
1238
|
+
async (a) => ok(await api('GET', `/api/v1/network-graph/projection/buyer-group/${a.buyerGroupId}`)));
|
|
1239
|
+
|
|
1240
|
+
server.tool('get_path_to_power', 'Rank safe, reviewable routes to decision authority for a buyer group, including rationale, next actions, and drafts.',
|
|
1241
|
+
{ buyerGroupId: z.string() },
|
|
1242
|
+
async (a) => ok(await api('GET', `/api/v1/buyer-group/${a.buyerGroupId}/path-to-power`)));
|
|
1243
|
+
|
|
1244
|
+
// ===== COMPANY LISTS / SAVED ACCOUNT LISTS =====
|
|
1245
|
+
|
|
1246
|
+
server.tool('list_company_lists', 'List saved company/account lists in Adrata. Use before adding companies to an existing target list.',
|
|
1247
|
+
{ type: z.string().optional().describe('Optional list type filter'), ...page },
|
|
1248
|
+
async (a) => ok(await api('GET', '/api/v1/company-lists', { params: { type: a.type, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1249
|
+
|
|
1250
|
+
server.tool('create_company_list', 'Create and save a company/account list in Adrata, optionally with company IDs from search or research results.',
|
|
1251
|
+
{ name: z.string(), description: z.string().optional(), type: z.string().optional(), companyIds: z.array(z.string()).optional() },
|
|
1252
|
+
async ({ companyIds, ...list }) => {
|
|
1253
|
+
const created = await api('POST', '/api/v1/company-lists', { body: list });
|
|
1254
|
+
const listId = created?.data?.id || created?.id;
|
|
1255
|
+
if (listId && Array.isArray(companyIds) && companyIds.length > 0) {
|
|
1256
|
+
await api('POST', `/api/v1/company-lists/${listId}/add`, { body: { companyIds } });
|
|
1257
|
+
}
|
|
1258
|
+
return ok({ ...created, companyIds: companyIds || [] });
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
server.tool('get_company_list', 'Get a saved company/account list with its companies.',
|
|
1262
|
+
{ companyListId: z.string() },
|
|
1263
|
+
async (a) => ok(await api('GET', `/api/v1/company-lists/${a.companyListId}`)));
|
|
1264
|
+
|
|
1265
|
+
server.tool('add_to_company_list', 'Add company IDs to an existing saved company/account list.',
|
|
1266
|
+
{ companyListId: z.string(), companyIds: z.array(z.string()) },
|
|
1267
|
+
async (a) => ok(await api('POST', `/api/v1/company-lists/${a.companyListId}/add`, { body: { companyIds: a.companyIds } })));
|
|
1268
|
+
|
|
1269
|
+
// ===== PARTNER ECOSYSTEM =====
|
|
1270
|
+
// Partners are typed relationships (ISV/tech, AI lab, hyperscaler, GSI/SI,
|
|
1271
|
+
// reseller, marketplace) across motions (co-sell, resell, sell-to, build-with,
|
|
1272
|
+
// refer). Attribution splits STRATEGIC (incremental revenue you would not get
|
|
1273
|
+
// without the partner) from INFLUENCED (facilitation). "Channel" = the resell
|
|
1274
|
+
// motion only.
|
|
1275
|
+
|
|
1276
|
+
server.tool('list_partners', 'List partners in the workspace. Filter by partnerType (ISV_TECH, AI_LAB, HYPERSCALER, GSI_SI, RESELLER, MARKETPLACE, ADVISORY, REFERRAL, COMMUNITY) or status (PROSPECT, ONBOARDING, ACTIVE, DORMANT, CHURNED).',
|
|
1277
|
+
{ partnerType: z.string().optional(), status: z.string().optional(), ...page },
|
|
1278
|
+
async (a) => ok(await api('GET', '/api/v1/partnerships', { params: { partnerType: a.partnerType, status: a.status, limit: a.limit || 50, offset: ((a.page || 1) - 1) * (a.limit || 50) } })));
|
|
1279
|
+
|
|
1280
|
+
server.tool('get_partner', 'Get a single partner with its full profile (type, tier, motions, commitment, target incremental ARR).',
|
|
1281
|
+
{ id: z.string() },
|
|
1282
|
+
async (a) => ok(await api('GET', `/api/v1/partnerships/${a.id}`)));
|
|
1283
|
+
|
|
1284
|
+
server.tool('create_partner', 'Create a partner relationship. partnerType is required (ISV_TECH, AI_LAB, HYPERSCALER, GSI_SI, RESELLER, MARKETPLACE, ADVISORY, REFERRAL, COMMUNITY). The authenticated user becomes owner unless ownerId is explicitly supplied. motions is an array of CO_SELL, RESELL, SELL_TO, BUILD_WITH, REFER.',
|
|
1285
|
+
{ name: z.string(), partnerType: z.string(), companyId: z.string().optional(), tier: z.string().optional().describe('STRATEGIC, SELECT, MANAGED, LONGTAIL'), status: z.string().optional(), motions: z.array(z.string()).optional(), ownerId: z.string().optional(), regions: z.array(z.string()).optional(), commitmentAmount: z.number().optional(), targetIncrementalArr: z.number().optional(), website: z.string().optional(), industry: z.string().optional(), description: z.string().optional() },
|
|
1286
|
+
async (a) => ok(await api('POST', '/api/v1/partnerships', { body: a })));
|
|
1287
|
+
|
|
1288
|
+
server.tool('update_partner', 'Update partner fields (type, tier, status, motions, owner, commitment, target ARR).',
|
|
1289
|
+
{ id: z.string(), name: z.string().optional(), partnerType: z.string().optional(), tier: z.string().optional(), status: z.string().optional(), motions: z.array(z.string()).optional(), ownerId: z.string().optional(), regions: z.array(z.string()).optional(), commitmentAmount: z.number().optional(), targetIncrementalArr: z.number().optional(), website: z.string().optional(), industry: z.string().optional(), description: z.string().optional() },
|
|
1290
|
+
async ({ id, ...f }) => ok(await api('PUT', `/api/v1/partnerships/${id}`, { body: body(f) })));
|
|
1291
|
+
|
|
1292
|
+
server.tool('delete_partner', 'Soft-delete a partner relationship.',
|
|
1293
|
+
{ id: z.string() }, async (a) => { await api('DELETE', `/api/v1/partnerships/${a.id}`); return ok({ deleted: a.id }); });
|
|
1294
|
+
|
|
1295
|
+
server.tool('rank_partners_for_account', 'Rank the partners best positioned to break into a target account. Returns an explainable break-in probability (0..1) per partner with reasons, blending relationship strength, tier, industry fit, co-sell/referral motions, committed-spend leverage, and prior strategic wins. Use to answer "which partner gets me into / strategic NAR on this account".',
|
|
1296
|
+
{ accountId: z.string().describe('The target company/account id to break into'), limit: z.number().optional() },
|
|
1297
|
+
async (a) => ok(await api('GET', '/api/v1/partnerships/rank', { params: { accountId: a.accountId, limit: a.limit || 10 } })));
|
|
1298
|
+
|
|
1299
|
+
server.tool('get_partner_revenue', 'Get a partner\'s strategic-revenue rollup: strategic vs influenced incremental ARR (weighted) and deal counts. This is the "without partner X, with partner X + 25%" number.',
|
|
1300
|
+
{ id: z.string() },
|
|
1301
|
+
async (a) => ok(await api('GET', `/api/v1/partnerships/${a.id}/revenue`)));
|
|
1302
|
+
|
|
1303
|
+
server.tool('attribute_partner_to_deal', 'Credit a partner on an opportunity. attributionType is STRATEGIC (incremental revenue not won without the partner) or INFLUENCED (facilitation). motion: co_sell, joint_solution, services_delivery, funding, intro, information_sharing, marketplace, reference. MONEY WRITE: dry-run by default (returns a preview); live execution requires approved:true, a non-empty auditReason, and an idempotencyKey.',
|
|
1304
|
+
{ opportunityId: z.string(), partnerId: z.string(), attributionType: z.enum(['strategic', 'influenced']), motion: z.string().optional(), incrementalAmount: z.number().optional(), evidence: z.record(z.unknown()).optional(), confidence: z.number().optional(), ...moneyWrite },
|
|
1305
|
+
async ({ dryRun, approved, auditReason, idempotencyKey, ...f }) => ok(await executeMoneyWrite(api, {
|
|
1306
|
+
tool: 'attribute_partner_to_deal',
|
|
1307
|
+
path: '/api/v1/partnerships/attributions',
|
|
1308
|
+
payload: body({ ...f, auditReason }),
|
|
1309
|
+
dryRun, approved, auditReason, idempotencyKey,
|
|
1310
|
+
})));
|
|
1311
|
+
|
|
1312
|
+
server.tool('list_partner_attributions', `List every partner attribution on an opportunity (who drove strategic vs influenced revenue). The API route has no pagination; limit/offset are applied client-side by this tool (default cap ${CLIENT_PAGE_CAP} rows).`,
|
|
1313
|
+
{ opportunityId: z.string(), limit: z.number().optional().describe(`Max rows returned (client-side cap, default ${CLIENT_PAGE_CAP})`), offset: z.number().optional().describe('Rows to skip (client-side)') },
|
|
1314
|
+
async (a) => {
|
|
1315
|
+
const res = await api('GET', '/api/v1/partnerships/attributions', { params: { opportunityId: a.opportunityId } });
|
|
1316
|
+
const { rows, page: pageInfo } = capClientSide(res?.data, a);
|
|
1317
|
+
return ok(pageInfo ? { ...res, data: rows, page: pageInfo } : res);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
server.tool('approve_partner_attribution', 'Approve a partner attribution (finance/ops sign-off). Writes an immutable audit row so strategic revenue stays defensible. MONEY WRITE: dry-run by default (returns a preview); live execution requires approved:true, a non-empty auditReason, and an idempotencyKey.',
|
|
1321
|
+
{ attributionId: z.string(), ...moneyWrite },
|
|
1322
|
+
async ({ attributionId, dryRun, approved, auditReason, idempotencyKey }) => ok(await executeMoneyWrite(api, {
|
|
1323
|
+
tool: 'approve_partner_attribution',
|
|
1324
|
+
path: `/api/v1/partnerships/attributions/${attributionId}/approve`,
|
|
1325
|
+
dryRun, approved, auditReason, idempotencyKey,
|
|
1326
|
+
})));
|
|
1327
|
+
|
|
1328
|
+
server.tool('create_strategic_work_stream', 'Create a programmatic joint initiative with a partner ("revenue while you sleep"). streamType: JOINT_SOLUTION, PARTNER_PROGRAM, MARKETPLACE_LISTING, CO_SELL_MOTION, CENTER_OF_EXCELLENCE.',
|
|
1329
|
+
{ partnerId: z.string(), name: z.string(), streamType: z.string(), description: z.string().optional(), industry: z.string().optional(), useCase: z.string().optional(), region: z.string().optional(), targetIncrementalArr: z.number().optional(), leadsPerQuarter: z.number().optional(), fundingAmount: z.number().optional() },
|
|
1330
|
+
async ({ partnerId, ...f }) => ok(await api('POST', `/api/v1/partnerships/${partnerId}/work-streams`, { body: f })));
|
|
1331
|
+
|
|
1332
|
+
server.tool('list_strategic_work_streams', `List a partner's strategic work streams (joint solutions, programs, marketplace listings) with target vs generated ARR. The API route has no pagination; limit/offset are applied client-side by this tool (default cap ${CLIENT_PAGE_CAP} rows).`,
|
|
1333
|
+
{ partnerId: z.string(), limit: z.number().optional().describe(`Max rows returned (client-side cap, default ${CLIENT_PAGE_CAP})`), offset: z.number().optional().describe('Rows to skip (client-side)') },
|
|
1334
|
+
async (a) => {
|
|
1335
|
+
const res = await api('GET', `/api/v1/partnerships/${a.partnerId}/work-streams`);
|
|
1336
|
+
const { rows, page: pageInfo } = capClientSide(res?.data, a);
|
|
1337
|
+
return ok(pageInfo ? { ...res, data: rows, page: pageInfo } : res);
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
server.tool('create_executive_partner_meeting', 'Log an Executive Partner Meeting (EPM) — the partner analog of a new-business meeting. Capture the mutual engagement that should lead to strategic revenue.',
|
|
1341
|
+
{ partnerId: z.string(), title: z.string().optional(), scheduledAt: z.string().optional().describe('RFC3339'), executiveName: z.string().optional(), executiveTitle: z.string().optional(), mutualEngagement: z.string().optional(), expectedStrategicArr: z.number().optional(), notes: z.string().optional() },
|
|
1342
|
+
async ({ partnerId, ...f }) => ok(await api('POST', `/api/v1/partnerships/${partnerId}/epms`, { body: f })));
|
|
1343
|
+
|
|
1344
|
+
server.tool('list_executive_partner_meetings', `List a partner's Executive Partner Meetings (EPMs). The API route has no pagination; limit/offset are applied client-side by this tool (default cap ${CLIENT_PAGE_CAP} rows).`,
|
|
1345
|
+
{ partnerId: z.string(), limit: z.number().optional().describe(`Max rows returned (client-side cap, default ${CLIENT_PAGE_CAP})`), offset: z.number().optional().describe('Rows to skip (client-side)') },
|
|
1346
|
+
async (a) => {
|
|
1347
|
+
const res = await api('GET', `/api/v1/partnerships/${a.partnerId}/epms`);
|
|
1348
|
+
const { rows, page: pageInfo } = capClientSide(res?.data, a);
|
|
1349
|
+
return ok(pageInfo ? { ...res, data: rows, page: pageInfo } : res);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
server.tool('record_partner_consumption', 'Record committed vs consumed revenue for a partner over a period. Signature != revenue: in a consumption business revenue is realized as usage grows, and partners drive it. driver: services_delivery, marketplace, self_serve, center_of_excellence. MONEY WRITE: dry-run by default (returns a preview); live execution requires approved:true, a non-empty auditReason, and an idempotencyKey.',
|
|
1353
|
+
{ partnerId: z.string(), opportunityId: z.string().optional(), companyId: z.string().optional(), periodLabel: z.string().optional().describe('e.g. 2026-Q3, FY26'), committedAmount: z.number().optional(), consumedAmount: z.number().optional(), driver: z.string().optional(), notes: z.string().optional(), ...moneyWrite },
|
|
1354
|
+
async ({ partnerId, dryRun, approved, auditReason, idempotencyKey, ...f }) => ok(await executeMoneyWrite(api, {
|
|
1355
|
+
tool: 'record_partner_consumption',
|
|
1356
|
+
path: `/api/v1/partnerships/${partnerId}/consumption`,
|
|
1357
|
+
payload: body(f),
|
|
1358
|
+
dryRun, approved, auditReason, idempotencyKey,
|
|
1359
|
+
})));
|
|
1360
|
+
|
|
1361
|
+
server.tool('list_partner_consumption', `List a partner's consumption records (committed vs consumed per period). The API route has no pagination; limit/offset are applied client-side by this tool (default cap ${CLIENT_PAGE_CAP} rows).`,
|
|
1362
|
+
{ partnerId: z.string(), limit: z.number().optional().describe(`Max rows returned (client-side cap, default ${CLIENT_PAGE_CAP})`), offset: z.number().optional().describe('Rows to skip (client-side)') },
|
|
1363
|
+
async (a) => {
|
|
1364
|
+
const res = await api('GET', `/api/v1/partnerships/${a.partnerId}/consumption`);
|
|
1365
|
+
const { rows, page: pageInfo } = capClientSide(res?.data, a);
|
|
1366
|
+
return ok(pageInfo ? { ...res, data: rows, page: pageInfo } : res);
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
server.tool('get_partner_consumption_rollup', 'Get a partner\'s committed-vs-consumed rollup with realization rate — the realized-revenue view (vs bookings).',
|
|
1370
|
+
{ partnerId: z.string() },
|
|
1371
|
+
async (a) => ok(await api('GET', `/api/v1/partnerships/${a.partnerId}/consumption/rollup`)));
|
|
1372
|
+
|
|
1373
|
+
// ===== NETWORK / WARM INTROS =====
|
|
1374
|
+
|
|
1375
|
+
server.tool('list_intro_requests', 'List tracked warm introduction requests by status or direction.',
|
|
1376
|
+
{ status: z.string().optional().describe('pending, accepted, declined, completed'), direction: z.string().optional().describe('sent, received, all'), ...page },
|
|
1377
|
+
async (a) => ok(await api('GET', '/api/v1/network/intro-requests', { params: { status: a.status, direction: a.direction, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1378
|
+
|
|
1379
|
+
server.tool('get_intro_pipeline', 'Get the warm intro pipeline organized by stage. Use for managing intro workflow across connectors and targets.',
|
|
1380
|
+
{},
|
|
1381
|
+
async () => ok(await api('GET', '/api/v1/network/intro-pipeline')));
|
|
1382
|
+
|
|
1383
|
+
server.tool('create_intro_request', 'Create a tracked warm introduction request between a connector and target.',
|
|
1384
|
+
{
|
|
1385
|
+
introducerUserId: z.string().describe('User ID for the connector who can make the introduction'),
|
|
1386
|
+
targetPersonId: z.string().describe('Person ID for the target buyer or prospect'),
|
|
1387
|
+
reason: z.string().describe('Why the intro is valuable and what context the connector should know'),
|
|
1388
|
+
opportunityId: z.string().optional(),
|
|
1389
|
+
companyId: z.string().optional(),
|
|
1390
|
+
metadata: z.record(z.unknown()).optional(),
|
|
1391
|
+
},
|
|
1392
|
+
async (a) => ok(await api('POST', '/api/v1/network/intro-request', { body: a })));
|
|
1393
|
+
|
|
1394
|
+
server.tool('update_intro_request', 'Update the status or connector notes on a warm introduction request.',
|
|
1395
|
+
{
|
|
1396
|
+
id: z.string().describe('Intro request ID'),
|
|
1397
|
+
status: z.string().optional().describe('accepted, declined, completed, cancelled'),
|
|
1398
|
+
connectorNotes: z.string().optional(),
|
|
1399
|
+
meetingLink: z.string().optional(),
|
|
1400
|
+
metadata: z.record(z.unknown()).optional(),
|
|
1401
|
+
},
|
|
1402
|
+
async ({ id, ...f }) => ok(await api('PATCH', `/api/v1/network/intro-request/${id}`, { body: body(f) })));
|
|
1403
|
+
|
|
1404
|
+
server.tool('find_intro_path', 'Find the relationship path between two people for warm intro planning.',
|
|
1405
|
+
{ source: z.string().describe('Source person ID'), target: z.string().describe('Target person ID') },
|
|
1406
|
+
async (a) => ok(await api('GET', `/api/v1/network/path/${a.source}/${a.target}`)));
|
|
1407
|
+
|
|
1408
|
+
server.tool('get_network_stats', 'Get network coverage, relationship strength, and warm intro health metrics.',
|
|
1409
|
+
{},
|
|
1410
|
+
async () => ok(await api('GET', '/api/v1/network/stats')));
|
|
1411
|
+
|
|
1412
|
+
// ===== AGENT TASKS =====
|
|
1413
|
+
|
|
1414
|
+
server.tool('list_agent_tasks', 'List long-running agent/background tasks with status and progress.',
|
|
1415
|
+
{ status: z.string().optional().describe('queued, running, completed, failed, cancelled'), type: z.string().optional(), ...page },
|
|
1416
|
+
async (a) => ok(await api('GET', '/api/v1/agent-tasks', { params: { status: a.status, type: a.type, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1417
|
+
|
|
1418
|
+
server.tool('create_agent_task', 'Create a long-running agent/background task for research, enrichment, auditing, or custom workflow automation.',
|
|
1419
|
+
{
|
|
1420
|
+
type: z.string().describe('Task type such as account_research, lead_enrichment, sequence_optimization, pipeline_review, or custom'),
|
|
1421
|
+
title: z.string().optional(),
|
|
1422
|
+
description: z.string().optional(),
|
|
1423
|
+
config: z.record(z.unknown()).optional(),
|
|
1424
|
+
},
|
|
1425
|
+
async (a) => ok(await api('POST', '/api/v1/agent-tasks', { body: a })));
|
|
1426
|
+
|
|
1427
|
+
server.tool('get_agent_task', 'Get detail, progress, and result for a long-running agent/background task.',
|
|
1428
|
+
{ id: z.string() },
|
|
1429
|
+
async (a) => ok(await api('GET', `/api/v1/agent-tasks/${a.id}`)));
|
|
1430
|
+
|
|
1431
|
+
server.tool('cancel_agent_task', 'Cancel a queued or running agent/background task.',
|
|
1432
|
+
{ id: z.string() },
|
|
1433
|
+
async (a) => ok(await api('DELETE', `/api/v1/agent-tasks/${a.id}`)));
|
|
1434
|
+
|
|
1435
|
+
// ===== WEBHOOKS =====
|
|
1436
|
+
|
|
1437
|
+
server.tool('list_webhook_events', 'List supported outbound webhook event types, payload schemas, and examples.',
|
|
1438
|
+
{},
|
|
1439
|
+
async () => ok(await api('GET', '/api/v1/webhooks/events')));
|
|
1440
|
+
|
|
1441
|
+
server.tool('list_webhooks', 'List outbound webhook subscriptions for the workspace.',
|
|
1442
|
+
{ event: z.string().optional(), active: z.boolean().optional(), ...page },
|
|
1443
|
+
async (a) => ok(await api('GET', '/api/v1/webhooks', { params: { event: a.event, active: a.active, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1444
|
+
|
|
1445
|
+
server.tool('create_webhook', 'Create an outbound webhook subscription. Returns the signing secret once; store it securely.',
|
|
1446
|
+
{
|
|
1447
|
+
url: z.string().describe('Public HTTPS endpoint URL'),
|
|
1448
|
+
events: z.array(z.string()).describe('Webhook event types from list_webhook_events'),
|
|
1449
|
+
active: z.boolean().optional(),
|
|
1450
|
+
description: z.string().optional(),
|
|
1451
|
+
filterExpression: z.record(z.unknown()).optional(),
|
|
1452
|
+
},
|
|
1453
|
+
async ({ filterExpression, ...f }) => ok(await api('POST', '/api/v1/webhooks', { body: body({ ...f, filter_expression: filterExpression }) })));
|
|
1454
|
+
|
|
1455
|
+
server.tool('update_webhook', 'Update an outbound webhook subscription URL, events, active flag, description, or filter expression.',
|
|
1456
|
+
{
|
|
1457
|
+
id: z.string().describe('Webhook subscription ID'),
|
|
1458
|
+
url: z.string().optional(),
|
|
1459
|
+
events: z.array(z.string()).optional(),
|
|
1460
|
+
active: z.boolean().optional(),
|
|
1461
|
+
description: z.string().optional(),
|
|
1462
|
+
filterExpression: z.record(z.unknown()).optional(),
|
|
1463
|
+
},
|
|
1464
|
+
async ({ id, filterExpression, ...f }) => ok(await api('PUT', `/api/v1/webhooks/${id}`, { body: body({ ...f, filter_expression: filterExpression }) })));
|
|
1465
|
+
|
|
1466
|
+
server.tool('delete_webhook', 'Delete an outbound webhook subscription.',
|
|
1467
|
+
{ id: z.string().describe('Webhook subscription ID') },
|
|
1468
|
+
async (a) => ok(await api('DELETE', `/api/v1/webhooks/${a.id}`)));
|
|
1469
|
+
|
|
1470
|
+
server.tool('test_webhook', 'Queue a signed webhook.test delivery to validate a subscription endpoint.',
|
|
1471
|
+
{ id: z.string().describe('Webhook subscription ID') },
|
|
1472
|
+
async (a) => ok(await api('POST', `/api/v1/webhooks/${a.id}/test`)));
|
|
1473
|
+
|
|
1474
|
+
server.tool('list_webhook_deliveries', 'List recent delivery attempts for a webhook subscription.',
|
|
1475
|
+
{ id: z.string().describe('Webhook subscription ID'), ...page },
|
|
1476
|
+
async (a) => ok(await api('GET', `/api/v1/webhooks/${a.id}/deliveries`, { params: { limit: a.limit || 25, page: a.page || 1 } })));
|
|
1477
|
+
|
|
1478
|
+
server.tool('get_webhook_delivery', 'Get status, response body, and retry metadata for a webhook delivery attempt.',
|
|
1479
|
+
{ id: z.string().describe('Webhook subscription ID'), deliveryId: z.string().describe('Webhook delivery ID') },
|
|
1480
|
+
async (a) => ok(await api('GET', `/api/v1/webhooks/${a.id}/deliveries/${a.deliveryId}`)));
|
|
1481
|
+
|
|
1482
|
+
server.tool('replay_webhook_delivery', 'Replay a failed or previous webhook delivery.',
|
|
1483
|
+
{ id: z.string().describe('Webhook subscription ID'), deliveryId: z.string().describe('Webhook delivery ID') },
|
|
1484
|
+
async (a) => ok(await api('POST', `/api/v1/webhooks/${a.id}/deliveries/${a.deliveryId}/replay`)));
|
|
1485
|
+
|
|
1486
|
+
// ===== LEADS =====
|
|
1487
|
+
|
|
1488
|
+
server.tool('search_leads', 'Search leads (people with status=LEAD). To update, use update_person.',
|
|
1489
|
+
{ query: z.string().optional(), source: z.string().optional(), ...page },
|
|
1490
|
+
async (a) => ok(await api('GET', '/api/v1/leads', { params: { search: a.query, source: a.source, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1491
|
+
|
|
1492
|
+
// ===== EMAILS =====
|
|
1493
|
+
|
|
1494
|
+
server.tool('search_emails', 'Search emails by subject, sender, recipient, or folder.',
|
|
1495
|
+
{ query: z.string().optional(), folder: z.string().optional().describe('inbox, sent, drafts, trash'), personId: z.string().optional(), companyId: z.string().optional(), ...page },
|
|
1496
|
+
async (a) => ok(await api('GET', '/api/v1/emails', { params: { search: a.query, folder: a.folder, personId: a.personId, companyId: a.companyId, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1497
|
+
|
|
1498
|
+
server.tool('get_email', 'Get full email content by ID.',
|
|
1499
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/emails/${a.id}`)));
|
|
1500
|
+
|
|
1501
|
+
server.tool('count_emails', 'Count emails by folder or status.',
|
|
1502
|
+
{ folder: z.string().optional() },
|
|
1503
|
+
async (a) => ok(await api('GET', '/api/v1/emails/count', { params: { folder: a.folder } })));
|
|
1504
|
+
|
|
1505
|
+
// ===== MEETINGS =====
|
|
1506
|
+
|
|
1507
|
+
// Meetings are read from the calendar `events` store, not the AI meeting-copilot
|
|
1508
|
+
// `meetings`/transcript tables (which only fill in once a call is recorded and
|
|
1509
|
+
// transcribed). "List my meetings" / "who is in my meetings" must return the
|
|
1510
|
+
// caller's real calendar events with titles, times, and attendee rosters, so
|
|
1511
|
+
// these point at `/api/v1/events` (rosters are backfilled from event_attendees
|
|
1512
|
+
// by the events read path). The copilot summary/action-item tools below stay on
|
|
1513
|
+
// `/api/v1/meetings` since those artifacts only exist for recorded meetings.
|
|
1514
|
+
server.tool('list_meetings', 'List your meetings (calendar events) with optional filters.',
|
|
1515
|
+
{ upcoming: z.boolean().optional(), personId: z.string().optional(), companyId: z.string().optional(), ...page },
|
|
1516
|
+
async (a) => ok(await api('GET', '/api/v1/events', { params: { start_date: a.upcoming ? new Date().toISOString() : undefined, person_id: a.personId, company_id: a.companyId, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1517
|
+
|
|
1518
|
+
server.tool('get_meeting', 'Get meeting (calendar event) details, including the attendee roster.',
|
|
1519
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/events/${a.id}`)));
|
|
1520
|
+
|
|
1521
|
+
server.tool('get_meeting_summary', 'Get AI summary of a meeting.',
|
|
1522
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/meetings/${a.id}/summary`)));
|
|
1523
|
+
|
|
1524
|
+
server.tool('get_meeting_action_items', 'Get action items from a meeting.',
|
|
1525
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/meetings/${a.id}/action-items`)));
|
|
1526
|
+
|
|
1527
|
+
// ===== INTELLIGENCE =====
|
|
1528
|
+
|
|
1529
|
+
server.tool('enrich_company', 'Trigger enrichment for a company — firmographic, news, competitors.',
|
|
1530
|
+
{ companyId: z.string() },
|
|
1531
|
+
async (a) => ok(await api('POST', `/api/v1/companies/${a.companyId}/enrich`)));
|
|
1532
|
+
|
|
1533
|
+
server.tool('get_company_firmographics', 'Get employee count and revenue metadata for a company, including value/range, source, confidence, freshness, and missing fields.',
|
|
1534
|
+
{ companyId: z.string() },
|
|
1535
|
+
async (a) => ok(await api('GET', `/api/v1/companies/${a.companyId}/firmographics`)));
|
|
1536
|
+
|
|
1537
|
+
server.tool('enrich_person', 'Trigger enrichment for a person — professional info, social profiles.',
|
|
1538
|
+
{ personId: z.string() },
|
|
1539
|
+
async (a) => ok(await api('POST', `/api/v1/people/${a.personId}/enrich`)));
|
|
1540
|
+
|
|
1541
|
+
server.tool('get_intent_signals', 'Get buying intent signals for a company.',
|
|
1542
|
+
{ companyId: z.string() },
|
|
1543
|
+
async (a) => ok(await api('GET', `/api/v1/intent-signals`, { params: { companyId: a.companyId } })));
|
|
1544
|
+
|
|
1545
|
+
// First-party signal store — the canonical customer_signals table the live
|
|
1546
|
+
// plays actually fire from (webhook ingest, manual logs, BYOK adapters). This
|
|
1547
|
+
// is distinct from get_intent_signals, which returns third-party intent. Reads
|
|
1548
|
+
// are workspace-scoped from the token; filter by subject (a company or person),
|
|
1549
|
+
// source, signal type, or minimum score, and page with the returned cursor.
|
|
1550
|
+
server.tool('list_customer_signals',
|
|
1551
|
+
'List first-party customer signals (the store live plays fire from) for the workspace, or scope to one company/person via subjectType + subjectId. Distinct from get_intent_signals (third-party intent).',
|
|
1552
|
+
{
|
|
1553
|
+
subjectType: z.enum(['company', 'person']).optional().describe('Scope to a subject kind; pair with subjectId'),
|
|
1554
|
+
subjectId: z.string().optional().describe('Company or person ID to scope signals to'),
|
|
1555
|
+
source: z.string().optional().describe('Filter by ingest source, e.g. linkedin_engager'),
|
|
1556
|
+
signalType: z.string().optional().describe('Filter by signal type'),
|
|
1557
|
+
minScore: z.number().optional().describe('Only signals at or above this score'),
|
|
1558
|
+
since: z.string().optional().describe('ISO timestamp lower bound on observed_at'),
|
|
1559
|
+
until: z.string().optional().describe('ISO timestamp upper bound on observed_at'),
|
|
1560
|
+
limit: z.number().optional().describe('Page size (default 50, max 500)'),
|
|
1561
|
+
cursor: z.string().optional().describe('Opaque cursor from a previous page'),
|
|
1562
|
+
},
|
|
1563
|
+
async (a) => ok(await api('GET', '/api/v1/customer-signals', { params: {
|
|
1564
|
+
subjectType: a.subjectType, subjectId: a.subjectId, source: a.source,
|
|
1565
|
+
signalType: a.signalType, minScore: a.minScore, since: a.since,
|
|
1566
|
+
until: a.until, limit: a.limit || 50, cursor: a.cursor,
|
|
1567
|
+
} })));
|
|
1568
|
+
|
|
1569
|
+
server.tool('get_deal_authority', 'Get authority mapping and stakeholder analysis for a deal.',
|
|
1570
|
+
{ opportunityId: z.string() },
|
|
1571
|
+
// Deal authority is keyed by an `opportunityId` query param, not a path segment.
|
|
1572
|
+
async (a) => ok(await api('GET', '/api/v1/deal-authority/buyer-intelligence', {
|
|
1573
|
+
params: { opportunityId: a.opportunityId },
|
|
1574
|
+
})));
|
|
1575
|
+
|
|
1576
|
+
server.tool('get_competitor_intel', 'Get competitive intelligence for a company.',
|
|
1577
|
+
{ companyId: z.string() },
|
|
1578
|
+
async (a) => ok(await api('GET', `/api/v1/competitors`, { params: { companyId: a.companyId } })));
|
|
1579
|
+
|
|
1580
|
+
// ===== ANALYTICS =====
|
|
1581
|
+
|
|
1582
|
+
server.tool('get_pipeline_metrics', 'Pipeline metrics: total value, deal count, win rate, stage distribution. Returns an object; when there are no deals it says so explicitly rather than returning a bare empty list.',
|
|
1583
|
+
{ period: z.string().optional().describe('7d, 30d, 90d, ytd') },
|
|
1584
|
+
async (a) => {
|
|
1585
|
+
const res = await api('GET', '/api/v1/analytics/pipeline', { params: { period: a.period } });
|
|
1586
|
+
const stages = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : null);
|
|
1587
|
+
|
|
1588
|
+
// The tool describes an object with totals and a stage distribution, but
|
|
1589
|
+
// the endpoint answers with a bare array of stage rows — an empty one when
|
|
1590
|
+
// the workspace has no opportunities. Passing `[]` straight through made
|
|
1591
|
+
// "no deals" indistinguishable from "metrics unavailable", and gave this
|
|
1592
|
+
// tool a different empty shape from every neighbouring tool.
|
|
1593
|
+
if (stages === null) return ok(res);
|
|
1594
|
+
|
|
1595
|
+
let opportunitiesInWorkspace = null;
|
|
1596
|
+
try {
|
|
1597
|
+
const counts = await api('GET', '/api/v1/data/counts');
|
|
1598
|
+
opportunitiesInWorkspace = (counts?.data ?? counts)?.opportunities ?? null;
|
|
1599
|
+
} catch { /* contextual, not required */ }
|
|
1600
|
+
|
|
1601
|
+
return ok({
|
|
1602
|
+
period: a.period || 'all',
|
|
1603
|
+
stageDistribution: stages,
|
|
1604
|
+
isEmpty: stages.length === 0,
|
|
1605
|
+
opportunitiesInWorkspace,
|
|
1606
|
+
note: stages.length === 0
|
|
1607
|
+
? (opportunitiesInWorkspace === 0
|
|
1608
|
+
? 'No pipeline metrics: the workspace has no opportunities yet.'
|
|
1609
|
+
: 'No pipeline metrics for this period, though the workspace does have opportunities — try a wider period.')
|
|
1610
|
+
: null,
|
|
1611
|
+
});
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
server.tool('get_forecast_data', 'Sales forecast with predicted revenue by period.',
|
|
1615
|
+
{ period: z.string().optional() },
|
|
1616
|
+
async (a) => ok(await api('GET', '/api/v1/forecast', { params: { period: a.period } })));
|
|
1617
|
+
|
|
1618
|
+
server.tool('get_activity_summary', 'Aggregated activity metrics (calls, emails, meetings) over time.',
|
|
1619
|
+
{ userId: z.string().optional(), days: z.number().optional().describe('Lookback in days (default 7)') },
|
|
1620
|
+
async (a) => ok(await api('GET', '/api/v1/actions/summary', { params: { userId: a.userId, days: a.days || 7 } })));
|
|
1621
|
+
|
|
1622
|
+
// ===== FINANCE: INSTALLED BASE (customer's own ERP invoices) =====
|
|
1623
|
+
// Read-only, workspace-scoped. Source data is the customer's OWN accounting
|
|
1624
|
+
// invoices, which arrive via a Codat connector. That connector is not built yet,
|
|
1625
|
+
// so a correctly-functioning workspace can legitimately return zero invoices —
|
|
1626
|
+
// every response carries a `coverage` block and a `note` saying so. Report that
|
|
1627
|
+
// note as-is; never present an empty installed base as "this account bought
|
|
1628
|
+
// nothing", and never estimate an amount.
|
|
1629
|
+
|
|
1630
|
+
server.tool('get_installed_base',
|
|
1631
|
+
'Finance/expansion installed base: which accounts bought which SKUs (spend, invoice count, first/last purchase) from the customer\'s own ERP invoices, plus the accounts with no invoice at all. Read-only and scoped to your workspace. Always relay the returned `coverage` and `note` — zero invoices means nothing has been ingested yet, NOT zero spend.',
|
|
1632
|
+
{
|
|
1633
|
+
company_id: z.string().optional().describe('Restrict to one account'),
|
|
1634
|
+
sku: z.string().optional().describe('Restrict to one vendor SKU'),
|
|
1635
|
+
include_never_bought: z.boolean().optional().describe('Include accounts with no ingested invoice (default true)'),
|
|
1636
|
+
limit: z.number().optional().describe('Rows per page (default 100, max 500)'),
|
|
1637
|
+
offset: z.number().optional(),
|
|
1638
|
+
},
|
|
1639
|
+
async (a) => ok(await api('GET', '/api/v1/expansion/installed-base', {
|
|
1640
|
+
params: {
|
|
1641
|
+
company_id: a.company_id,
|
|
1642
|
+
sku: a.sku,
|
|
1643
|
+
include_never_bought: a.include_never_bought,
|
|
1644
|
+
limit: a.limit,
|
|
1645
|
+
offset: a.offset,
|
|
1646
|
+
},
|
|
1647
|
+
})));
|
|
1648
|
+
|
|
1649
|
+
server.tool('get_company_invoices',
|
|
1650
|
+
`One account's ingested ERP invoices (latest snapshot per invoice) and the catalog products they resolve to. Read-only and workspace-scoped. An account with no ingested invoices returns an empty list — that is the honest answer, not a zero total. The API route has no pagination; limit/offset are applied client-side to the invoices array (default cap ${CLIENT_PAGE_CAP} rows).`,
|
|
1651
|
+
{ company_id: z.string().describe('Adrata company id'), limit: z.number().optional().describe(`Max invoices returned (client-side cap, default ${CLIENT_PAGE_CAP})`), offset: z.number().optional().describe('Invoices to skip (client-side)') },
|
|
1652
|
+
async (a) => {
|
|
1653
|
+
const res = await api('GET', `/api/v1/expansion/installed-base/${a.company_id}/invoices`);
|
|
1654
|
+
const d = res?.data;
|
|
1655
|
+
if (d && Array.isArray(d.invoices)) {
|
|
1656
|
+
const { rows, page: pageInfo } = capClientSide(d.invoices, a);
|
|
1657
|
+
return ok({ ...res, data: { ...d, invoices: rows }, page: pageInfo });
|
|
1658
|
+
}
|
|
1659
|
+
return ok(res);
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
// ===== ADRATA CLOUD (bitemporal record substrate) =====
|
|
1663
|
+
|
|
1664
|
+
server.tool('get_cloud_records',
|
|
1665
|
+
'Query the Adrata Cloud — the canonical, source-attributed, bitemporal record substrate — as one surface. Filter by entity, field, source, and classification, and read AS OF a point in time: `as_of` is when a fact was TRUE, `as_of_recorded` is what Adrata KNEW at that moment. Read-only and scoped to your workspace. Values are returned exactly as asserted, with their source and confidence; a missing confidence is null, never assumed.',
|
|
1666
|
+
{
|
|
1667
|
+
entity_type: z.string().optional().describe('company | person | opportunity | pursuit | room | buyer_group | action | custom'),
|
|
1668
|
+
entity_id: z.string().optional(),
|
|
1669
|
+
field_name: z.string().optional().describe('Machine name of the field'),
|
|
1670
|
+
field_definition_id: z.string().optional(),
|
|
1671
|
+
source_type: z.string().optional(),
|
|
1672
|
+
source_id: z.string().optional(),
|
|
1673
|
+
classification: z.string().optional().describe('public | internal | confidential | highly_confidential | restricted'),
|
|
1674
|
+
as_of: z.string().optional().describe('RFC 3339 instant — valid time (defaults to now)'),
|
|
1675
|
+
as_of_recorded: z.string().optional().describe('RFC 3339 instant — knowledge time (defaults to now)'),
|
|
1676
|
+
limit: z.number().optional().describe('Rows per page (default 100, max 500)'),
|
|
1677
|
+
offset: z.number().optional(),
|
|
1678
|
+
},
|
|
1679
|
+
async (a) => ok(await api('GET', '/api/v1/cloud/records', {
|
|
1680
|
+
params: {
|
|
1681
|
+
entity_type: a.entity_type,
|
|
1682
|
+
entity_id: a.entity_id,
|
|
1683
|
+
field_name: a.field_name,
|
|
1684
|
+
field_definition_id: a.field_definition_id,
|
|
1685
|
+
source_type: a.source_type,
|
|
1686
|
+
source_id: a.source_id,
|
|
1687
|
+
classification: a.classification,
|
|
1688
|
+
as_of: a.as_of,
|
|
1689
|
+
as_of_recorded: a.as_of_recorded,
|
|
1690
|
+
limit: a.limit,
|
|
1691
|
+
offset: a.offset,
|
|
1692
|
+
},
|
|
1693
|
+
})));
|
|
1694
|
+
|
|
1695
|
+
// ===== CAMPAIGNS & SEQUENCES =====
|
|
1696
|
+
|
|
1697
|
+
server.tool('list_campaigns', 'List campaigns with optional status filter.',
|
|
1698
|
+
{ status: z.string().optional().describe('draft, active, paused, completed'), ...page },
|
|
1699
|
+
async (a) => ok(await api('GET', '/api/v1/campaigns', { params: { status: a.status, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1700
|
+
|
|
1701
|
+
server.tool('get_campaign', 'Get campaign details and performance metrics.',
|
|
1702
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/campaigns/${a.id}`)));
|
|
1703
|
+
|
|
1704
|
+
server.tool('list_sequences', 'List email sequences.',
|
|
1705
|
+
page, async (a) => ok(await api('GET', '/api/v1/sequences', { params: { limit: a.limit || 25, page: a.page || 1 } })));
|
|
1706
|
+
|
|
1707
|
+
server.tool('get_sequence', 'Get sequence details including steps.',
|
|
1708
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/sequences/${a.id}`)));
|
|
1709
|
+
|
|
1710
|
+
// ===== PRIORITY PURSUITS =====
|
|
1711
|
+
|
|
1712
|
+
server.tool('get_priority_pursuits', 'Get Priority Pursuits — your evidence-ranked daily account and next-move list.',
|
|
1713
|
+
page, async (a) => ok(await api('GET', '/api/v1/speedrun', { params: { limit: a.limit || 25, page: a.page || 1 } })));
|
|
1714
|
+
|
|
1715
|
+
// Compatibility alias for existing clients. Keep the transport route stable
|
|
1716
|
+
// while removing the retired seller-facing product language.
|
|
1717
|
+
server.tool('get_speedrun_list', 'Legacy alias for get_priority_pursuits.',
|
|
1718
|
+
page, async (a) => ok(await api('GET', '/api/v1/speedrun', { params: { limit: a.limit || 25, page: a.page || 1 } })));
|
|
1719
|
+
|
|
1720
|
+
// ===== USERS =====
|
|
1721
|
+
|
|
1722
|
+
server.tool('list_users', 'List workspace users/team members.',
|
|
1723
|
+
page, async (a) => ok(await api('GET', '/api/v1/users', { params: { limit: a.limit || 50, page: a.page || 1 } })));
|
|
1724
|
+
|
|
1725
|
+
server.tool('get_user', 'Get user details.',
|
|
1726
|
+
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/users/${a.id}`)));
|
|
1727
|
+
|
|
1728
|
+
server.tool('get_current_user', 'Get the currently authenticated user.',
|
|
1729
|
+
{}, async () => ok(await api('GET', '/api/v1/users/me')));
|
|
1730
|
+
|
|
1731
|
+
// ===== RECORD COUNTS =====
|
|
1732
|
+
|
|
1733
|
+
server.tool('count_records', 'Get total counts of companies, people, opportunities.',
|
|
1734
|
+
{}, async () => ok(await api('GET', '/api/v1/data/counts')));
|
|
1735
|
+
|
|
1736
|
+
// ===== ICP =====
|
|
1737
|
+
|
|
1738
|
+
server.tool('list_icp_profiles', 'List the workspace ICP profiles. A workspace selling into several motions keeps one active profile per segment; use this to see them and to get a profileId for segment-specific scoring.',
|
|
1739
|
+
{ activeOnly: z.boolean().optional().describe('Default true. Set false to include archived profiles.') },
|
|
1740
|
+
// Profiles live at /api/v1/icp-profiles, NOT under /icp-scoring.
|
|
1741
|
+
async (a) => ok(await api('GET', '/api/v1/icp-profiles', {
|
|
1742
|
+
params: { status: a.activeOnly === false ? undefined : 'active' },
|
|
1743
|
+
})));
|
|
1744
|
+
|
|
1745
|
+
server.tool('score_company_icp', 'Score how well a company matches your Ideal Customer Profile. When several profiles are active this scores the company against EVERY segment and returns one score per profile plus the best fit. Pass profileId to score against a single segment.',
|
|
1746
|
+
{
|
|
1747
|
+
companyId: z.string(),
|
|
1748
|
+
profileId: z.string().optional().describe('Score against this ICP profile only. Omit to score against every active profile, one score per segment.'),
|
|
1749
|
+
},
|
|
1750
|
+
// Scoring is a POST-only route; GET on the same path returns 405.
|
|
1751
|
+
async (a) => ok(await api('POST', `/api/v1/icp-scoring/score-company/${encodeURIComponent(a.companyId)}`, {
|
|
1752
|
+
body: a.profileId ? { profile_id: a.profileId } : {},
|
|
1753
|
+
})));
|
|
1754
|
+
|
|
1755
|
+
server.tool('get_icp_distribution', 'Get ICP tier distribution (A/B/C/D breakdown) for one ICP profile, plus coverage showing how many companies have been scored at all. Pass profileId to read a specific segment; omit for the workspace default.',
|
|
1756
|
+
{ profileId: z.string().optional().describe('Read the distribution for this ICP profile. Omit for the workspace default.') },
|
|
1757
|
+
async (a) => ok(await api('GET', '/api/v1/icp-scoring/distribution', {
|
|
1758
|
+
params: a.profileId ? { profile_id: a.profileId } : {},
|
|
1759
|
+
})));
|
|
1760
|
+
|
|
1761
|
+
// ===== WORKFLOW TOOLS =====
|
|
1762
|
+
// High-level tools for common sales workflows — find-or-create, batch logging.
|
|
1763
|
+
|
|
1764
|
+
server.tool('find_or_create_person',
|
|
1765
|
+
'Find a person by email or name+company, or create them if they don\'t exist. Returns the person record either way. Use this when logging interactions where you\'re not sure if the person exists yet.',
|
|
1766
|
+
{
|
|
1767
|
+
email: z.string().optional().describe('Email to search by (most reliable)'),
|
|
1768
|
+
name: z.string().optional().describe('Full name to search by'),
|
|
1769
|
+
firstName: z.string().optional(),
|
|
1770
|
+
lastName: z.string().optional(),
|
|
1771
|
+
companyName: z.string().optional().describe('Company name — used to find matching company'),
|
|
1772
|
+
companyId: z.string().optional().describe('Company ID if known'),
|
|
1773
|
+
title: z.string().optional(),
|
|
1774
|
+
linkedinUrl: z.string().optional(),
|
|
1775
|
+
phone: z.string().optional(),
|
|
1776
|
+
},
|
|
1777
|
+
async (args) => {
|
|
1778
|
+
// Try to find by email first
|
|
1779
|
+
if (args.email) {
|
|
1780
|
+
const search = await api('GET', '/api/v1/people', { params: { search: args.email, limit: 5, page: 1 } });
|
|
1781
|
+
const people = search?.data || [];
|
|
1782
|
+
const match = people.find(p => p.email?.toLowerCase() === args.email.toLowerCase());
|
|
1783
|
+
if (match) return ok({ found: true, person: match });
|
|
1784
|
+
}
|
|
1785
|
+
// Try by name
|
|
1786
|
+
if (args.name || (args.firstName && args.lastName)) {
|
|
1787
|
+
const q = args.name || `${args.firstName} ${args.lastName}`;
|
|
1788
|
+
const search = await api('GET', '/api/v1/people', { params: { search: q, limit: 10, page: 1 } });
|
|
1789
|
+
const people = search?.data || [];
|
|
1790
|
+
if (people.length === 1) return ok({ found: true, person: people[0] });
|
|
1791
|
+
if (people.length > 1 && args.companyId) {
|
|
1792
|
+
const match = people.find(p => p.companyId === args.companyId);
|
|
1793
|
+
if (match) return ok({ found: true, person: match });
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
// Not found — create
|
|
1797
|
+
const createBody = {};
|
|
1798
|
+
if (args.name) createBody.name = args.name;
|
|
1799
|
+
if (args.firstName) createBody.firstName = args.firstName;
|
|
1800
|
+
if (args.lastName) createBody.lastName = args.lastName;
|
|
1801
|
+
if (args.email) createBody.email = args.email;
|
|
1802
|
+
if (args.title) createBody.title = args.title;
|
|
1803
|
+
if (args.phone) createBody.phone = args.phone;
|
|
1804
|
+
if (args.linkedinUrl) createBody.linkedinUrl = args.linkedinUrl;
|
|
1805
|
+
if (args.companyId) createBody.companyId = args.companyId;
|
|
1806
|
+
createBody.status = 'LEAD';
|
|
1807
|
+
const created = await api('POST', '/api/v1/people', { body: createBody });
|
|
1808
|
+
return ok({ found: false, created: true, person: created?.data || created });
|
|
1809
|
+
}
|
|
1810
|
+
);
|
|
1811
|
+
|
|
1812
|
+
server.tool('find_or_create_company',
|
|
1813
|
+
'Find a company by name or domain, or create it if it doesn\'t exist. Returns the company record either way.',
|
|
1814
|
+
{
|
|
1815
|
+
name: z.string().optional().describe('Company name to search by'),
|
|
1816
|
+
domain: z.string().optional().describe('Company domain (e.g. acme.com)'),
|
|
1817
|
+
industry: z.string().optional(),
|
|
1818
|
+
website: z.string().optional(),
|
|
1819
|
+
},
|
|
1820
|
+
async (args) => {
|
|
1821
|
+
const q = args.domain || args.name;
|
|
1822
|
+
if (q) {
|
|
1823
|
+
const search = await api('GET', '/api/v1/companies', { params: { search: q, limit: 5, page: 1 } });
|
|
1824
|
+
const companies = search?.data || [];
|
|
1825
|
+
if (args.domain) {
|
|
1826
|
+
const match = companies.find(c => c.domain?.toLowerCase() === args.domain.toLowerCase());
|
|
1827
|
+
if (match) return ok({ found: true, company: match });
|
|
1828
|
+
}
|
|
1829
|
+
if (companies.length === 1) return ok({ found: true, company: companies[0] });
|
|
1830
|
+
}
|
|
1831
|
+
// Create
|
|
1832
|
+
const createBody = {};
|
|
1833
|
+
if (args.name) createBody.name = args.name;
|
|
1834
|
+
if (args.domain) createBody.domain = args.domain;
|
|
1835
|
+
if (args.industry) createBody.industry = args.industry;
|
|
1836
|
+
if (args.website) createBody.website = args.website;
|
|
1837
|
+
createBody.status = 'PROSPECT';
|
|
1838
|
+
const created = await api('POST', '/api/v1/companies', { body: createBody });
|
|
1839
|
+
return ok({ found: false, created: true, company: created?.data || created });
|
|
1840
|
+
}
|
|
1841
|
+
);
|
|
1842
|
+
|
|
1843
|
+
server.tool('log_interaction',
|
|
1844
|
+
'Log a sales interaction — finds the person (or creates them), creates an action with metadata, and optionally updates the person\'s customFields. This is the primary tool for recording outreach activity. One call does everything.',
|
|
1845
|
+
{
|
|
1846
|
+
personEmail: z.string().optional().describe('Email of the person (used to find/create)'),
|
|
1847
|
+
personName: z.string().optional().describe('Name of the person'),
|
|
1848
|
+
personLinkedin: z.string().optional().describe('LinkedIn URL'),
|
|
1849
|
+
companyName: z.string().optional().describe('Company name'),
|
|
1850
|
+
type: z.string().describe('Interaction type: linkedin_connection_request, linkedin_message, email, call, meeting, note'),
|
|
1851
|
+
title: z.string().describe('What happened, e.g. "Sent LinkedIn connection request"'),
|
|
1852
|
+
outcome: z.string().optional().describe('Result: accepted, pending, replied, no_response, meeting_booked'),
|
|
1853
|
+
message: z.string().optional().describe('The message sent (stored in action metadata)'),
|
|
1854
|
+
status: z.string().optional().describe('Action status: COMPLETED (default), PLANNED, IN_PROGRESS'),
|
|
1855
|
+
customFields: z.record(z.unknown()).optional().describe('Additional fields to set on the person record'),
|
|
1856
|
+
},
|
|
1857
|
+
async (args) => {
|
|
1858
|
+
// 1. Find or create the person
|
|
1859
|
+
let personId = null;
|
|
1860
|
+
let companyId = null;
|
|
1861
|
+
|
|
1862
|
+
// Find company first if provided
|
|
1863
|
+
if (args.companyName) {
|
|
1864
|
+
const compSearch = await api('GET', '/api/v1/companies', { params: { search: args.companyName, limit: 3, page: 1 } });
|
|
1865
|
+
const companies = compSearch?.data || [];
|
|
1866
|
+
if (companies.length > 0) companyId = companies[0].id;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// Find person
|
|
1870
|
+
const searchQ = args.personEmail || args.personName;
|
|
1871
|
+
if (searchQ) {
|
|
1872
|
+
const personSearch = await api('GET', '/api/v1/people', { params: { search: searchQ, limit: 5, page: 1 } });
|
|
1873
|
+
const people = personSearch?.data || [];
|
|
1874
|
+
const match = args.personEmail
|
|
1875
|
+
? people.find(p => p.email?.toLowerCase() === args.personEmail.toLowerCase())
|
|
1876
|
+
: people.length === 1 ? people[0] : null;
|
|
1877
|
+
if (match) {
|
|
1878
|
+
personId = match.id;
|
|
1879
|
+
if (!companyId && match.companyId) companyId = match.companyId;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// Create person if not found
|
|
1884
|
+
if (!personId && (args.personEmail || args.personName)) {
|
|
1885
|
+
const createBody = { status: 'LEAD' };
|
|
1886
|
+
if (args.personName) createBody.name = args.personName;
|
|
1887
|
+
if (args.personEmail) createBody.email = args.personEmail;
|
|
1888
|
+
if (args.personLinkedin) createBody.linkedinUrl = args.personLinkedin;
|
|
1889
|
+
if (companyId) createBody.companyId = companyId;
|
|
1890
|
+
const created = await api('POST', '/api/v1/people', { body: createBody });
|
|
1891
|
+
personId = created?.data?.id || created?.id;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// 2. Create the action
|
|
1895
|
+
const actionBody = {
|
|
1896
|
+
title: args.title,
|
|
1897
|
+
type: args.type,
|
|
1898
|
+
status: args.status || 'COMPLETED',
|
|
1899
|
+
metadata: {},
|
|
1900
|
+
};
|
|
1901
|
+
if (personId) actionBody.personId = personId;
|
|
1902
|
+
if (companyId) actionBody.companyId = companyId;
|
|
1903
|
+
if (args.outcome) actionBody.outcome = args.outcome;
|
|
1904
|
+
if (args.message) actionBody.metadata.message = args.message;
|
|
1905
|
+
if (args.personLinkedin) actionBody.metadata.profileUrl = args.personLinkedin;
|
|
1906
|
+
|
|
1907
|
+
const action = await api('POST', '/api/v1/actions', { body: actionBody });
|
|
1908
|
+
|
|
1909
|
+
// 3. Update person customFields if provided
|
|
1910
|
+
if (personId && args.customFields) {
|
|
1911
|
+
await api('PATCH', `/api/v1/people/${personId}`, { body: { customFields: args.customFields } });
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
return ok({
|
|
1915
|
+
logged: true,
|
|
1916
|
+
personId,
|
|
1917
|
+
companyId,
|
|
1918
|
+
actionId: action?.data?.id || action?.id,
|
|
1919
|
+
type: args.type,
|
|
1920
|
+
title: args.title,
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
);
|
|
1924
|
+
|
|
1925
|
+
// ===== SCHEMA INTROSPECTION =====
|
|
1926
|
+
// These tools let agents discover available fields dynamically — the key
|
|
1927
|
+
// differentiator vs competitors (Attio has list-attribute-definitions).
|
|
1928
|
+
|
|
1929
|
+
const ENTITY_SCHEMAS = {
|
|
1930
|
+
company: {
|
|
1931
|
+
standard: ['id', 'name', 'domain', 'website', 'industry', 'sector', 'size', 'revenue', 'employeeCount', 'status', 'priority', 'description', 'email', 'phone', 'address', 'city', 'state', 'country', 'postalCode', 'linkedinUrl', 'twitterUrl', 'foundedYear', 'annualRevenue', 'tags', 'lastAction', 'lastActionDate', 'nextAction', 'nextActionDate', 'globalRank', 'ownerId', 'customFields', 'createdAt', 'updatedAt'],
|
|
1932
|
+
statuses: ['ACTIVE', 'INACTIVE', 'PROSPECT', 'CUSTOMER', 'CHURNED', 'ARCHIVED'],
|
|
1933
|
+
priorities: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'],
|
|
1934
|
+
customFieldsType: 'JSONB — merged on update, stores arbitrary key-value pairs',
|
|
1935
|
+
},
|
|
1936
|
+
person: {
|
|
1937
|
+
standard: ['id', 'name', 'firstName', 'lastName', 'email', 'phone', 'title', 'currentRole', 'department', 'seniority', 'companyId', 'companyName', 'linkedinUrl', 'status', 'priority', 'source', 'engagementScore', 'globalRank', 'lastAction', 'lastActionDate', 'nextAction', 'nextActionDate', 'customFields', 'createdAt', 'updatedAt'],
|
|
1938
|
+
statuses: ['LEAD', 'PROSPECT', 'CUSTOMER', 'CHURNED', 'ARCHIVED', 'ACTIVE', 'INACTIVE'],
|
|
1939
|
+
priorities: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'],
|
|
1940
|
+
customFieldsType: 'JSONB — merged on update, stores arbitrary key-value pairs',
|
|
1941
|
+
},
|
|
1942
|
+
opportunity: {
|
|
1943
|
+
standard: ['id', 'name', 'description', 'amount', 'stage', 'probability', 'status', 'companyId', 'ownerId', 'revenueType', 'dealType', 'forecastCategory', 'budgetAmount', 'expectedCloseDate', 'closeDate', 'actualCloseDate', 'lostReason', 'customFields', 'createdAt', 'updatedAt'],
|
|
1944
|
+
stages: ['Discovery', 'Qualification', 'Proposal', 'Negotiation', 'Closed Won', 'Closed Lost'],
|
|
1945
|
+
dealTypes: ['NEW_BUSINESS', 'EXPANSION', 'RENEWAL', 'UPSELL', 'CROSS_SELL'],
|
|
1946
|
+
forecastCategories: ['PIPELINE', 'BEST_CASE', 'COMMIT', 'CLOSED_WON', 'OMITTED'],
|
|
1947
|
+
customFieldsType: 'JSONB — merged on update, stores arbitrary key-value pairs',
|
|
1948
|
+
},
|
|
1949
|
+
action: {
|
|
1950
|
+
standard: ['id', 'title', 'description', 'type', 'status', 'priority', 'dueDate', 'completedAt', 'companyId', 'personId', 'assignedTo', 'outcome', 'metadata', 'createdAt', 'updatedAt'],
|
|
1951
|
+
statuses: ['PLANNED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'OVERDUE'],
|
|
1952
|
+
priorities: ['LOW', 'NORMAL', 'HIGH', 'URGENT'],
|
|
1953
|
+
types: ['email', 'call', 'meeting', 'note', 'task', 'linkedin_connection_request', 'linkedin_message', 'iMessage', 'follow-up'],
|
|
1954
|
+
metadataType: 'JSONB — merged on update, stores type-specific data (message copy, call notes, etc.)',
|
|
1955
|
+
},
|
|
1956
|
+
note: {
|
|
1957
|
+
standard: ['id', 'body', 'companyId', 'personId', 'opportunityId', 'createdAt', 'updatedAt'],
|
|
1958
|
+
},
|
|
1959
|
+
buyer_group: {
|
|
1960
|
+
standard: ['id', 'name', 'companyId', 'createdAt', 'updatedAt'],
|
|
1961
|
+
memberRoles: ['Champion', 'Decision Maker', 'Influencer', 'Blocker', 'End User', 'Technical Evaluator', 'Budget Holder'],
|
|
1962
|
+
},
|
|
1963
|
+
};
|
|
1964
|
+
|
|
1965
|
+
server.tool('describe_fields',
|
|
1966
|
+
'List all available fields for an entity type, including enums, statuses, and custom field support. Use this to discover what data you can read/write before creating or updating records.',
|
|
1967
|
+
{ entity: z.enum(['company', 'person', 'opportunity', 'action', 'note', 'buyer_group']).describe('Entity type to describe') },
|
|
1968
|
+
async ({ entity }) => ok(ENTITY_SCHEMAS[entity]));
|
|
1969
|
+
|
|
1970
|
+
server.tool('list_custom_fields',
|
|
1971
|
+
'Discover what custom fields are set across records of a given type. Samples up to 100 records and returns all unique customFields keys found. Use this to understand what custom data has been added.',
|
|
1972
|
+
{ entity: z.enum(['company', 'person', 'opportunity']).describe('Entity type to inspect') },
|
|
1973
|
+
async ({ entity }) => {
|
|
1974
|
+
const endpoint = entity === 'company' ? 'companies' : entity === 'person' ? 'people' : 'opportunities';
|
|
1975
|
+
const data = await api('GET', `/api/v1/${endpoint}`, { params: { limit: 100, page: 1 } });
|
|
1976
|
+
const records = data?.data || [];
|
|
1977
|
+
const keySet = new Set();
|
|
1978
|
+
for (const record of records) {
|
|
1979
|
+
const cf = record.customFields;
|
|
1980
|
+
if (cf && typeof cf === 'object') {
|
|
1981
|
+
for (const key of Object.keys(cf)) keySet.add(key);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
return ok({
|
|
1985
|
+
entity,
|
|
1986
|
+
recordsSampled: records.length,
|
|
1987
|
+
customFieldKeys: [...keySet].sort(),
|
|
1988
|
+
note: 'To add a new custom field, just include it in the customFields object when creating or updating a record. No schema migration needed.',
|
|
1989
|
+
});
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
// ===== RESOURCES =====
|
|
1993
|
+
|
|
1994
|
+
server.resource('entity-guide', 'adrata://guide/entities', async () => ({
|
|
1995
|
+
contents: [{ uri: 'adrata://guide/entities', mimeType: 'text/plain', text: `Adrata Entity Relationships:
|
|
1996
|
+
|
|
1997
|
+
Companies → have People (via companyId) → have Opportunities (via companyId)
|
|
1998
|
+
People → status determines type: LEAD, PROSPECT, CUSTOMER, ACTIVE
|
|
1999
|
+
Opportunities → stages: Discovery → Qualification → Proposal → Negotiation → Closed Won/Lost
|
|
2000
|
+
Actions → linked to People and/or Companies, auto-update Last Touch and Next Move fields
|
|
2001
|
+
Notes → attached to Companies, People, or Opportunities
|
|
2002
|
+
Buyer Groups → belong to Companies, contain People with roles (Champion, Decision Maker, etc.)
|
|
2003
|
+
|
|
2004
|
+
Key behaviors:
|
|
2005
|
+
- customFields on Companies, People, Opportunities = JSONB-merged on update
|
|
2006
|
+
- metadata on Actions = JSONB-merged, use for type-specific data
|
|
2007
|
+
- Creating/completing an Action auto-updates Last Touch and Next Move on linked records
|
|
2008
|
+
- Leads = People with status LEAD (use update_person to modify)
|
|
2009
|
+
- Enrichment triggers async data collection from external sources` }]
|
|
2010
|
+
}));
|
|
2011
|
+
|
|
2012
|
+
// ===== MEMORY TOOLS =====
|
|
2013
|
+
// save_memory, recall, who_am_i, forget — work on all tiers
|
|
2014
|
+
registerMemoryTools(server, z, api, AUTH, ok);
|
|
2015
|
+
|
|
2016
|
+
// ===== MORNING BRIEF =====
|
|
2017
|
+
// morning_brief — the hero experience. Free=teaser, Pro+=full brief
|
|
2018
|
+
registerMorningBrief(server, z, api, AUTH, ok);
|
|
2019
|
+
|
|
2020
|
+
// ===== SLOAN (AI EXECUTIVE ASSISTANT) =====
|
|
2021
|
+
// sloan_status / sloan_handoff / configure_sloan — first-class wrappers over
|
|
2022
|
+
// the governed dispatcher (approve_/reject_sloan_draft ride the generic
|
|
2023
|
+
// adrata_ai_tool_execute passthrough).
|
|
2024
|
+
registerSloanTools(server, z, executeAiCrmTool, ok);
|
|
2025
|
+
|
|
2026
|
+
// ===== BILLING TOOLS =====
|
|
2027
|
+
// upgrade_account, check_subscription — work on all tiers
|
|
2028
|
+
registerBillingTools(server, { api, ok, auth: AUTH });
|
|
2029
|
+
|
|
2030
|
+
// ===== RESOURCES: adrata://profile =====
|
|
2031
|
+
// Returns user context for Claude to read at session start
|
|
2032
|
+
registerProfileResource(server, api, AUTH);
|
|
2033
|
+
|
|
2034
|
+
// ===== RESOURCES: recent-research, sales-playbook, workspace =====
|
|
2035
|
+
// Additional resources for passive context injection
|
|
2036
|
+
registerResources(server, api, AUTH);
|
|
2037
|
+
|
|
2038
|
+
// ===== ENTERPRISE TOOLS =====
|
|
2039
|
+
// bulk_import, export_data, manage_custom_fields, get_workspace_settings
|
|
2040
|
+
// These require OAuth enterprise tier and check workspace capabilities.
|
|
2041
|
+
registerEnterpriseTools(server, { z, api, AUTH, ok, API_BASE });
|
|
2042
|
+
|
|
2043
|
+
// ===== EMAIL INFRASTRUCTURE TOOLS =====
|
|
2044
|
+
// Domain management, email account provisioning, sequence management.
|
|
2045
|
+
// Enterprise tier only — calls existing Rust API endpoints.
|
|
2046
|
+
registerEmailTools(server, { z, api, AUTH, ok, API_BASE });
|
|
2047
|
+
|
|
2048
|
+
// ===== WORK BOARD TOOLS =====
|
|
2049
|
+
// Starfield's boards, read and reorganised through the SAME governed endpoints
|
|
2050
|
+
// the UI calls — never a second path into the data. Moves and tag changes are
|
|
2051
|
+
// writes and carry the standard dryRun/approved/reason/idempotencyKey contract.
|
|
2052
|
+
registerWorkBoardTools(server, {
|
|
2053
|
+
z,
|
|
2054
|
+
api,
|
|
2055
|
+
ok,
|
|
2056
|
+
validateApiBridgeRequest,
|
|
2057
|
+
buildMutationHeaders,
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
// ===== DEMO SCHEDULING: Cal.com integration (free tier) =====
|
|
2061
|
+
// schedule_demo and schedule_meeting are registered here; get_demo_availability
|
|
2062
|
+
// is overridden in always-loaded.js to use the Cal.com-powered implementation.
|
|
2063
|
+
|
|
2064
|
+
server.tool('schedule_demo',
|
|
2065
|
+
'Book a product demo with Adrata. Free tier — no account needed. Creates a calendar event via Cal.com. The attendee receives an email confirmation automatically.',
|
|
2066
|
+
{
|
|
2067
|
+
date: z.string().describe('Date in YYYY-MM-DD format (e.g. "2026-04-15")'),
|
|
2068
|
+
time: z.string().describe('Time in HH:MM 24h format (e.g. "14:00")'),
|
|
2069
|
+
name: z.string().describe('Attendee full name'),
|
|
2070
|
+
email: z.string().describe('Attendee email address'),
|
|
2071
|
+
company: z.string().optional().describe('Attendee company name'),
|
|
2072
|
+
timezone: z.string().optional().describe('IANA timezone (e.g. "America/New_York"). Auto-detected if omitted.'),
|
|
2073
|
+
},
|
|
2074
|
+
async (args) => {
|
|
2075
|
+
const result = await scheduleDemo(args);
|
|
2076
|
+
return ok(result);
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
server.tool('schedule_meeting',
|
|
2080
|
+
'Preview or create a meeting through Adrata chat’s governed calendar path. Live creation requires an audit reason and the server-issued confirmation token returned by the first approved request. Provider delivery remains pending until sync confirms it.',
|
|
2081
|
+
{
|
|
2082
|
+
title: z.string().describe('Meeting title'),
|
|
2083
|
+
eventId: z.string().optional().describe('Existing Adrata event ID to update instead of creating a duplicate'),
|
|
2084
|
+
date: z.string().describe('Date in YYYY-MM-DD format'),
|
|
2085
|
+
time: z.string().describe('Time in HH:MM 24h format'),
|
|
2086
|
+
duration: z.number().optional().describe('Duration in minutes (default 30)'),
|
|
2087
|
+
attendees: z.array(z.string()).optional().describe('List of attendee email addresses'),
|
|
2088
|
+
description: z.string().optional().describe('Meeting description or agenda'),
|
|
2089
|
+
timezone: z.string().optional().describe('IANA timezone. Auto-detected if omitted.'),
|
|
2090
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false after explicit approval to request a confirmation token.'),
|
|
2091
|
+
reason: z.string().optional().describe('Required audit reason when requesting a live-write confirmation token.'),
|
|
2092
|
+
confirmationToken: z.string().optional().describe('Single-use server token from the approved first request.'),
|
|
2093
|
+
},
|
|
2094
|
+
async (args) => {
|
|
2095
|
+
try {
|
|
2096
|
+
const result = await scheduleMeeting(args, api);
|
|
2097
|
+
return ok(result);
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
return ok({ error: true, message: err.message, hint: 'Ensure your workspace is connected (use connect_workspace) and calendar integration is enabled.' });
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
// ===== ANALYTICS & TELEMETRY =====
|
|
2104
|
+
// Tracks tool invocations, search queries, tier gates, conversions, sessions.
|
|
2105
|
+
// Builds on top of existing event logging — adds classification and dashboards.
|
|
2106
|
+
// ADRATA_TELEMETRY=off disables all tracking. PII is hashed before storage.
|
|
2107
|
+
registerAnalytics(server, { z, api, auth: AUTH, ok, checkToolAccess });
|
|
2108
|
+
|
|
2109
|
+
// ===== COMPOSITE TOOLSETS =====
|
|
2110
|
+
// 42 composite tools organized into 7 toolsets with dynamic discovery.
|
|
2111
|
+
// Only always-loaded tools (list_toolsets, enable_toolset, get_demo_availability,
|
|
2112
|
+
// check_inbox) are registered at startup. Other toolsets load on demand via
|
|
2113
|
+
// enable_toolset to save tokens (~12K vs ~135K for 450 individual tools).
|
|
2114
|
+
// The existing individual tools above remain for backward compatibility.
|
|
2115
|
+
registerAlwaysLoadedTools(server, api, AUTH);
|
|
2116
|
+
|
|
2117
|
+
// Extensibility surface: custom integration / extension catalog inspection,
|
|
2118
|
+
// manifest validation, and deploy-request submission. Free tier so any
|
|
2119
|
+
// connected agent can iterate on a manifest end-to-end; the control plane
|
|
2120
|
+
// still gates activation on policy review.
|
|
2121
|
+
registerExtensibilityTools(server, api, AUTH);
|
|
2122
|
+
|
|
2123
|
+
// ---------------------------------------------------------------------------
|
|
2124
|
+
// Start
|
|
2125
|
+
// ---------------------------------------------------------------------------
|
|
2126
|
+
|
|
2127
|
+
const TRANSPORT_MODE = (process.env.ADRATA_MCP_TRANSPORT || 'stdio').toLowerCase();
|
|
2128
|
+
|
|
2129
|
+
if (TRANSPORT_MODE === 'http' || TRANSPORT_MODE === 'sse') {
|
|
2130
|
+
// Streamable HTTP transport — single POST /mcp endpoint that can upgrade to SSE
|
|
2131
|
+
const { startHttpTransport } = await import('./transport-http.js');
|
|
2132
|
+
await startHttpTransport(server, {
|
|
2133
|
+
runWithAuthContext,
|
|
2134
|
+
});
|
|
2135
|
+
} else {
|
|
2136
|
+
// Default: stdio transport (backward compatible)
|
|
2137
|
+
const transport = new StdioServerTransport();
|
|
2138
|
+
await server.connect(transport);
|
|
2139
|
+
}
|