@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
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise-only tools for Adrata MCP Server.
|
|
3
|
+
*
|
|
4
|
+
* These tools require an OAuth-connected workspace (enterprise tier) and provide:
|
|
5
|
+
* - bulk_import: CSV data -> create records in bulk
|
|
6
|
+
* - export_data: filtered export of records as JSON or CSV
|
|
7
|
+
* - custom_field_management: list, create, update custom fields
|
|
8
|
+
* - workspace_settings: view workspace configuration
|
|
9
|
+
*
|
|
10
|
+
* All tools check workspace capabilities before executing and respect
|
|
11
|
+
* rate limits per workspace tier.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getWorkspaceCapabilities, hasCapability, fetchWorkspaceCapabilities } from '../access/oauth.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Capability checking
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Module name -> capability flag mapping.
|
|
22
|
+
* Tools check their module is enabled before executing.
|
|
23
|
+
*/
|
|
24
|
+
const MODULE_CAPABILITIES = {
|
|
25
|
+
bulk_import: 'bulk_operations',
|
|
26
|
+
export_data: 'data_export',
|
|
27
|
+
manage_custom_fields: 'custom_fields',
|
|
28
|
+
get_workspace_settings: 'workspace_admin',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if a module is enabled for the current workspace.
|
|
33
|
+
* Returns { enabled: true } or { enabled: false, response } with a helpful message.
|
|
34
|
+
*/
|
|
35
|
+
function checkCapability(toolName, ok) {
|
|
36
|
+
const requiredCap = MODULE_CAPABILITIES[toolName];
|
|
37
|
+
if (!requiredCap) return { enabled: true };
|
|
38
|
+
|
|
39
|
+
const caps = getWorkspaceCapabilities();
|
|
40
|
+
|
|
41
|
+
// If capabilities haven't been fetched yet, allow the call
|
|
42
|
+
// (the API will enforce server-side anyway)
|
|
43
|
+
if (!caps) return { enabled: true };
|
|
44
|
+
|
|
45
|
+
if (hasCapability(requiredCap)) return { enabled: true };
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
enabled: false,
|
|
49
|
+
response: ok({
|
|
50
|
+
error: 'module_not_enabled',
|
|
51
|
+
tool: toolName,
|
|
52
|
+
requiredCapability: requiredCap,
|
|
53
|
+
message: `The "${toolName}" tool requires the "${requiredCap}" module, which is not enabled for your workspace. Contact your Adrata administrator to enable this module.`,
|
|
54
|
+
currentCapabilities: caps.features || Object.keys(caps).filter(k => caps[k] === true),
|
|
55
|
+
help: 'Use workspace_settings to view your current workspace configuration and enabled modules.',
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Rate limiting
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Per-workspace rate limit state */
|
|
65
|
+
const _rateLimitState = {
|
|
66
|
+
windowStart: Date.now(),
|
|
67
|
+
requestCount: 0,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the rate limit for the current workspace tier.
|
|
72
|
+
* Enterprise workspaces get higher limits based on capabilities.
|
|
73
|
+
*/
|
|
74
|
+
function getRateLimit() {
|
|
75
|
+
const caps = getWorkspaceCapabilities();
|
|
76
|
+
if (caps && caps.rateLimits) {
|
|
77
|
+
return {
|
|
78
|
+
maxRequests: caps.rateLimits.maxRequestsPerMinute || 120,
|
|
79
|
+
windowMs: 60000,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Default enterprise rate limit
|
|
83
|
+
return { maxRequests: 120, windowMs: 60000 };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check and increment rate limit counter.
|
|
88
|
+
* Returns { allowed: true } or { allowed: false, response }.
|
|
89
|
+
*/
|
|
90
|
+
function checkRateLimit(toolName, ok) {
|
|
91
|
+
const limit = getRateLimit();
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
|
|
94
|
+
// Reset window if expired
|
|
95
|
+
if (now - _rateLimitState.windowStart > limit.windowMs) {
|
|
96
|
+
_rateLimitState.windowStart = now;
|
|
97
|
+
_rateLimitState.requestCount = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_rateLimitState.requestCount++;
|
|
101
|
+
|
|
102
|
+
if (_rateLimitState.requestCount > limit.maxRequests) {
|
|
103
|
+
const retryAfterMs = limit.windowMs - (now - _rateLimitState.windowStart);
|
|
104
|
+
return {
|
|
105
|
+
allowed: false,
|
|
106
|
+
response: ok({
|
|
107
|
+
error: 'rate_limit_exceeded',
|
|
108
|
+
tool: toolName,
|
|
109
|
+
message: `Rate limit exceeded (${limit.maxRequests} requests per minute). Please wait ${Math.ceil(retryAfterMs / 1000)} seconds before retrying.`,
|
|
110
|
+
retryAfterSeconds: Math.ceil(retryAfterMs / 1000),
|
|
111
|
+
limit: limit.maxRequests,
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { allowed: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// CSV parsing helper
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse CSV string into array of objects.
|
|
125
|
+
* Handles quoted fields, commas within quotes, and newlines.
|
|
126
|
+
*/
|
|
127
|
+
function parseCSV(csvString) {
|
|
128
|
+
const lines = csvString.trim().split('\n');
|
|
129
|
+
if (lines.length < 2) return [];
|
|
130
|
+
|
|
131
|
+
// Parse header row
|
|
132
|
+
const headers = parseLine(lines[0]);
|
|
133
|
+
const records = [];
|
|
134
|
+
|
|
135
|
+
for (let i = 1; i < lines.length; i++) {
|
|
136
|
+
const line = lines[i].trim();
|
|
137
|
+
if (!line) continue;
|
|
138
|
+
const values = parseLine(line);
|
|
139
|
+
const record = {};
|
|
140
|
+
headers.forEach((h, idx) => {
|
|
141
|
+
const val = values[idx];
|
|
142
|
+
if (val !== undefined && val !== '') {
|
|
143
|
+
record[h.trim()] = val;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
if (Object.keys(record).length > 0) records.push(record);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return records;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseLine(line) {
|
|
153
|
+
const fields = [];
|
|
154
|
+
let current = '';
|
|
155
|
+
let inQuotes = false;
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < line.length; i++) {
|
|
158
|
+
const ch = line[i];
|
|
159
|
+
if (ch === '"') {
|
|
160
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
161
|
+
current += '"';
|
|
162
|
+
i++;
|
|
163
|
+
} else {
|
|
164
|
+
inQuotes = !inQuotes;
|
|
165
|
+
}
|
|
166
|
+
} else if (ch === ',' && !inQuotes) {
|
|
167
|
+
fields.push(current.trim());
|
|
168
|
+
current = '';
|
|
169
|
+
} else {
|
|
170
|
+
current += ch;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
fields.push(current.trim());
|
|
174
|
+
return fields;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// CSV serialization helper
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Convert an array of objects to CSV string.
|
|
183
|
+
*/
|
|
184
|
+
function toCSV(records) {
|
|
185
|
+
if (!records || records.length === 0) return '';
|
|
186
|
+
|
|
187
|
+
const headers = [...new Set(records.flatMap(r => Object.keys(r)))];
|
|
188
|
+
const csvLines = [headers.join(',')];
|
|
189
|
+
|
|
190
|
+
for (const record of records) {
|
|
191
|
+
const values = headers.map(h => {
|
|
192
|
+
const val = record[h];
|
|
193
|
+
if (val == null) return '';
|
|
194
|
+
const str = typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
195
|
+
// Escape quotes and wrap in quotes if contains comma, quote, or newline
|
|
196
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
197
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
198
|
+
}
|
|
199
|
+
return str;
|
|
200
|
+
});
|
|
201
|
+
csvLines.push(values.join(','));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return csvLines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Tool registration
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Register all enterprise-only tools on the MCP server.
|
|
213
|
+
*
|
|
214
|
+
* @param {McpServer} server - The MCP server instance (already tier-gated)
|
|
215
|
+
* @param {object} deps - Dependencies: { z, api, AUTH, ok, API_BASE }
|
|
216
|
+
*/
|
|
217
|
+
export function registerEnterpriseTools(server, { z, api, AUTH, ok, API_BASE }) {
|
|
218
|
+
|
|
219
|
+
// ===== BULK IMPORT =====
|
|
220
|
+
|
|
221
|
+
server.tool('bulk_import',
|
|
222
|
+
'Import records in bulk from CSV data. Creates companies, people, or opportunities from structured CSV input. Each row becomes a record. Custom fields are supported via column names prefixed with "cf_" (e.g., cf_industry_segment). Returns created record IDs and any errors. Enterprise only.',
|
|
223
|
+
{
|
|
224
|
+
entity: z.enum(['company', 'person', 'opportunity']).describe('Record type to import'),
|
|
225
|
+
csvData: z.string().describe('CSV string with headers in first row. Standard columns map to entity fields, cf_* columns map to customFields.'),
|
|
226
|
+
dryRun: z.boolean().optional().describe('If true, validate data without creating records (default: false)'),
|
|
227
|
+
skipDuplicates: z.boolean().optional().describe('If true, skip records that match existing records by email/domain (default: true)'),
|
|
228
|
+
},
|
|
229
|
+
async (args) => {
|
|
230
|
+
// Check capability
|
|
231
|
+
const capCheck = checkCapability('bulk_import', ok);
|
|
232
|
+
if (!capCheck.enabled) return capCheck.response;
|
|
233
|
+
|
|
234
|
+
// Check rate limit
|
|
235
|
+
const rateCheck = checkRateLimit('bulk_import', ok);
|
|
236
|
+
if (!rateCheck.allowed) return rateCheck.response;
|
|
237
|
+
|
|
238
|
+
const records = parseCSV(args.csvData);
|
|
239
|
+
if (records.length === 0) {
|
|
240
|
+
return ok({ error: 'empty_csv', message: 'No data rows found in CSV. Ensure the first row contains headers and subsequent rows contain data.' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (records.length > 500) {
|
|
244
|
+
return ok({ error: 'too_many_records', message: `CSV contains ${records.length} rows. Maximum is 500 per import. Split your data into smaller batches.` });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const endpoint = args.entity === 'company' ? 'companies'
|
|
248
|
+
: args.entity === 'person' ? 'people'
|
|
249
|
+
: 'opportunities';
|
|
250
|
+
|
|
251
|
+
const skipDuplicates = args.skipDuplicates !== false;
|
|
252
|
+
|
|
253
|
+
// Transform records: extract cf_* columns into customFields
|
|
254
|
+
const transformed = records.map(record => {
|
|
255
|
+
const base = {};
|
|
256
|
+
const customFields = {};
|
|
257
|
+
|
|
258
|
+
for (const [key, value] of Object.entries(record)) {
|
|
259
|
+
if (key.startsWith('cf_')) {
|
|
260
|
+
customFields[key.slice(3)] = value;
|
|
261
|
+
} else {
|
|
262
|
+
base[key] = value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (Object.keys(customFields).length > 0) {
|
|
267
|
+
base.customFields = customFields;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Coerce numeric fields
|
|
271
|
+
if (base.amount) base.amount = parseFloat(base.amount) || 0;
|
|
272
|
+
if (base.probability) base.probability = parseFloat(base.probability) || 0;
|
|
273
|
+
if (base.employeeCount) base.employeeCount = parseInt(base.employeeCount, 10) || 0;
|
|
274
|
+
|
|
275
|
+
return base;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (args.dryRun) {
|
|
279
|
+
return ok({
|
|
280
|
+
dryRun: true,
|
|
281
|
+
entity: args.entity,
|
|
282
|
+
recordCount: transformed.length,
|
|
283
|
+
sampleRecord: transformed[0],
|
|
284
|
+
columns: Object.keys(records[0]),
|
|
285
|
+
customFieldColumns: Object.keys(records[0]).filter(k => k.startsWith('cf_')),
|
|
286
|
+
message: `Dry run: ${transformed.length} ${args.entity} records parsed and ready to import.`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Execute import
|
|
291
|
+
const results = { created: [], skipped: [], errors: [] };
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < transformed.length; i++) {
|
|
294
|
+
const record = transformed[i];
|
|
295
|
+
try {
|
|
296
|
+
// Duplicate check
|
|
297
|
+
if (skipDuplicates) {
|
|
298
|
+
const searchKey = args.entity === 'person' ? record.email
|
|
299
|
+
: args.entity === 'company' ? (record.domain || record.name)
|
|
300
|
+
: null;
|
|
301
|
+
|
|
302
|
+
if (searchKey) {
|
|
303
|
+
const existing = await api('GET', `/api/v1/${endpoint}`, {
|
|
304
|
+
params: { search: searchKey, limit: 1, page: 1 },
|
|
305
|
+
});
|
|
306
|
+
const matches = existing?.data || [];
|
|
307
|
+
if (matches.length > 0) {
|
|
308
|
+
results.skipped.push({ row: i + 2, reason: 'duplicate', key: searchKey });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const created = await api('POST', `/api/v1/${endpoint}`, { body: record });
|
|
315
|
+
results.created.push({
|
|
316
|
+
row: i + 2,
|
|
317
|
+
id: created?.data?.id || created?.id,
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
results.errors.push({
|
|
321
|
+
row: i + 2,
|
|
322
|
+
error: err.message.slice(0, 200),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return ok({
|
|
328
|
+
entity: args.entity,
|
|
329
|
+
totalRows: transformed.length,
|
|
330
|
+
created: results.created.length,
|
|
331
|
+
skipped: results.skipped.length,
|
|
332
|
+
errors: results.errors.length,
|
|
333
|
+
createdIds: results.created.map(r => r.id),
|
|
334
|
+
skippedDetails: results.skipped.length > 0 ? results.skipped : undefined,
|
|
335
|
+
errorDetails: results.errors.length > 0 ? results.errors : undefined,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
// ===== EXPORT DATA =====
|
|
341
|
+
|
|
342
|
+
server.tool('export_data',
|
|
343
|
+
'Export records as JSON or CSV with filters. Supports companies, people, opportunities, actions, and notes. Includes custom fields. Enterprise only.',
|
|
344
|
+
{
|
|
345
|
+
entity: z.enum(['company', 'person', 'opportunity', 'action', 'note']).describe('Record type to export'),
|
|
346
|
+
format: z.enum(['json', 'csv']).optional().describe('Output format (default: json)'),
|
|
347
|
+
filters: z.object({
|
|
348
|
+
search: z.string().optional(),
|
|
349
|
+
status: z.string().optional(),
|
|
350
|
+
companyId: z.string().optional(),
|
|
351
|
+
stage: z.string().optional(),
|
|
352
|
+
type: z.string().optional(),
|
|
353
|
+
ownerId: z.string().optional(),
|
|
354
|
+
}).optional().describe('Optional filters to narrow the export'),
|
|
355
|
+
limit: z.number().optional().describe('Max records to export (default 100, max 1000)'),
|
|
356
|
+
fields: z.array(z.string()).optional().describe('Specific fields to include (default: all)'),
|
|
357
|
+
},
|
|
358
|
+
async (args) => {
|
|
359
|
+
// Check capability
|
|
360
|
+
const capCheck = checkCapability('export_data', ok);
|
|
361
|
+
if (!capCheck.enabled) return capCheck.response;
|
|
362
|
+
|
|
363
|
+
// Check rate limit
|
|
364
|
+
const rateCheck = checkRateLimit('export_data', ok);
|
|
365
|
+
if (!rateCheck.allowed) return rateCheck.response;
|
|
366
|
+
|
|
367
|
+
const endpoint = {
|
|
368
|
+
company: 'companies',
|
|
369
|
+
person: 'people',
|
|
370
|
+
opportunity: 'opportunities',
|
|
371
|
+
action: 'actions',
|
|
372
|
+
note: 'notes',
|
|
373
|
+
}[args.entity];
|
|
374
|
+
|
|
375
|
+
const exportLimit = Math.min(args.limit || 100, 1000);
|
|
376
|
+
const format = args.format || 'json';
|
|
377
|
+
|
|
378
|
+
// Fetch records (paginate if needed)
|
|
379
|
+
const allRecords = [];
|
|
380
|
+
let page = 1;
|
|
381
|
+
const perPage = Math.min(exportLimit, 100);
|
|
382
|
+
|
|
383
|
+
while (allRecords.length < exportLimit) {
|
|
384
|
+
const params = {
|
|
385
|
+
...(args.filters || {}),
|
|
386
|
+
limit: perPage,
|
|
387
|
+
page,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const data = await api('GET', `/api/v1/${endpoint}`, { params });
|
|
391
|
+
const records = data?.data || [];
|
|
392
|
+
if (records.length === 0) break;
|
|
393
|
+
|
|
394
|
+
allRecords.push(...records);
|
|
395
|
+
if (records.length < perPage) break;
|
|
396
|
+
page++;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Trim to requested limit
|
|
400
|
+
const exported = allRecords.slice(0, exportLimit);
|
|
401
|
+
|
|
402
|
+
// Filter fields if specified
|
|
403
|
+
let output = exported;
|
|
404
|
+
if (args.fields && args.fields.length > 0) {
|
|
405
|
+
output = exported.map(record => {
|
|
406
|
+
const filtered = {};
|
|
407
|
+
for (const field of args.fields) {
|
|
408
|
+
if (record[field] !== undefined) filtered[field] = record[field];
|
|
409
|
+
}
|
|
410
|
+
return filtered;
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (format === 'csv') {
|
|
415
|
+
return ok({
|
|
416
|
+
entity: args.entity,
|
|
417
|
+
format: 'csv',
|
|
418
|
+
recordCount: output.length,
|
|
419
|
+
csv: toCSV(output),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return ok({
|
|
424
|
+
entity: args.entity,
|
|
425
|
+
format: 'json',
|
|
426
|
+
recordCount: output.length,
|
|
427
|
+
records: output,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
// ===== CUSTOM FIELD MANAGEMENT =====
|
|
433
|
+
|
|
434
|
+
server.tool('manage_custom_fields',
|
|
435
|
+
'List, create, or update custom fields for an entity type. Custom fields in Adrata are JSONB-merged, so this tool helps discover existing fields, define conventions, and bulk-update field values. Enterprise only.',
|
|
436
|
+
{
|
|
437
|
+
action: z.enum(['list', 'create', 'update']).describe('list: discover fields, create: add a new field to records, update: modify field values across records'),
|
|
438
|
+
entity: z.enum(['company', 'person', 'opportunity']).describe('Entity type'),
|
|
439
|
+
fieldName: z.string().optional().describe('Field name (required for create/update)'),
|
|
440
|
+
fieldValue: z.unknown().optional().describe('Default value for create, or new value for update'),
|
|
441
|
+
targetIds: z.array(z.string()).optional().describe('Record IDs to update (for update action)'),
|
|
442
|
+
description: z.string().optional().describe('Field description/documentation (stored as cf_meta)'),
|
|
443
|
+
},
|
|
444
|
+
async (args) => {
|
|
445
|
+
// Check capability
|
|
446
|
+
const capCheck = checkCapability('manage_custom_fields', ok);
|
|
447
|
+
if (!capCheck.enabled) return capCheck.response;
|
|
448
|
+
|
|
449
|
+
// Check rate limit
|
|
450
|
+
const rateCheck = checkRateLimit('manage_custom_fields', ok);
|
|
451
|
+
if (!rateCheck.allowed) return rateCheck.response;
|
|
452
|
+
|
|
453
|
+
const endpoint = args.entity === 'company' ? 'companies'
|
|
454
|
+
: args.entity === 'person' ? 'people'
|
|
455
|
+
: 'opportunities';
|
|
456
|
+
|
|
457
|
+
// LIST: discover custom fields across records
|
|
458
|
+
if (args.action === 'list') {
|
|
459
|
+
const data = await api('GET', `/api/v1/${endpoint}`, { params: { limit: 100, page: 1 } });
|
|
460
|
+
const records = data?.data || [];
|
|
461
|
+
const fieldMap = {};
|
|
462
|
+
|
|
463
|
+
for (const record of records) {
|
|
464
|
+
const cf = record.customFields;
|
|
465
|
+
if (cf && typeof cf === 'object') {
|
|
466
|
+
for (const [key, value] of Object.entries(cf)) {
|
|
467
|
+
if (!fieldMap[key]) {
|
|
468
|
+
fieldMap[key] = {
|
|
469
|
+
name: key,
|
|
470
|
+
type: typeof value,
|
|
471
|
+
sampleValues: [],
|
|
472
|
+
recordCount: 0,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
fieldMap[key].recordCount++;
|
|
476
|
+
if (fieldMap[key].sampleValues.length < 5) {
|
|
477
|
+
const sample = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
478
|
+
if (!fieldMap[key].sampleValues.includes(sample)) {
|
|
479
|
+
fieldMap[key].sampleValues.push(sample);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return ok({
|
|
487
|
+
entity: args.entity,
|
|
488
|
+
recordsSampled: records.length,
|
|
489
|
+
customFields: Object.values(fieldMap).sort((a, b) => b.recordCount - a.recordCount),
|
|
490
|
+
note: 'Custom fields are JSONB. Add any field by including it in the customFields object when creating/updating records.',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// CREATE: add a new custom field to specified records (or all)
|
|
495
|
+
if (args.action === 'create') {
|
|
496
|
+
if (!args.fieldName) {
|
|
497
|
+
return ok({ error: 'missing_field_name', message: 'fieldName is required for create action.' });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const targets = args.targetIds || [];
|
|
501
|
+
|
|
502
|
+
if (targets.length === 0) {
|
|
503
|
+
// Just document the field convention (no records to update)
|
|
504
|
+
return ok({
|
|
505
|
+
entity: args.entity,
|
|
506
|
+
fieldCreated: args.fieldName,
|
|
507
|
+
defaultValue: args.fieldValue ?? null,
|
|
508
|
+
description: args.description || null,
|
|
509
|
+
message: `Custom field "${args.fieldName}" is now documented. To populate it, update records with customFields: { "${args.fieldName}": value } or specify targetIds to set it on specific records.`,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Apply to target records
|
|
514
|
+
const results = { updated: [], errors: [] };
|
|
515
|
+
for (const id of targets.slice(0, 100)) {
|
|
516
|
+
try {
|
|
517
|
+
await api('PATCH', `/api/v1/${endpoint}/${id}`, {
|
|
518
|
+
body: { customFields: { [args.fieldName]: args.fieldValue ?? null } },
|
|
519
|
+
});
|
|
520
|
+
results.updated.push(id);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
results.errors.push({ id, error: err.message.slice(0, 100) });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return ok({
|
|
527
|
+
entity: args.entity,
|
|
528
|
+
fieldName: args.fieldName,
|
|
529
|
+
updated: results.updated.length,
|
|
530
|
+
errors: results.errors.length,
|
|
531
|
+
errorDetails: results.errors.length > 0 ? results.errors : undefined,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// UPDATE: change a custom field value across records
|
|
536
|
+
if (args.action === 'update') {
|
|
537
|
+
if (!args.fieldName) {
|
|
538
|
+
return ok({ error: 'missing_field_name', message: 'fieldName is required for update action.' });
|
|
539
|
+
}
|
|
540
|
+
if (!args.targetIds || args.targetIds.length === 0) {
|
|
541
|
+
return ok({ error: 'missing_target_ids', message: 'targetIds is required for update action. Provide record IDs to update.' });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const results = { updated: [], errors: [] };
|
|
545
|
+
for (const id of args.targetIds.slice(0, 100)) {
|
|
546
|
+
try {
|
|
547
|
+
await api('PATCH', `/api/v1/${endpoint}/${id}`, {
|
|
548
|
+
body: { customFields: { [args.fieldName]: args.fieldValue } },
|
|
549
|
+
});
|
|
550
|
+
results.updated.push(id);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
results.errors.push({ id, error: err.message.slice(0, 100) });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return ok({
|
|
557
|
+
entity: args.entity,
|
|
558
|
+
fieldName: args.fieldName,
|
|
559
|
+
newValue: args.fieldValue,
|
|
560
|
+
updated: results.updated.length,
|
|
561
|
+
errors: results.errors.length,
|
|
562
|
+
errorDetails: results.errors.length > 0 ? results.errors : undefined,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// ===== WORKSPACE SETTINGS =====
|
|
569
|
+
|
|
570
|
+
server.tool('get_workspace_settings',
|
|
571
|
+
'View your workspace configuration, enabled modules, rate limits, user count, and subscription details. Use this to understand what features are available. Enterprise only.',
|
|
572
|
+
{},
|
|
573
|
+
async () => {
|
|
574
|
+
// Check capability
|
|
575
|
+
const capCheck = checkCapability('get_workspace_settings', ok);
|
|
576
|
+
if (!capCheck.enabled) return capCheck.response;
|
|
577
|
+
|
|
578
|
+
// Check rate limit
|
|
579
|
+
const rateCheck = checkRateLimit('get_workspace_settings', ok);
|
|
580
|
+
if (!rateCheck.allowed) return rateCheck.response;
|
|
581
|
+
|
|
582
|
+
// Refresh capabilities from server
|
|
583
|
+
let caps = getWorkspaceCapabilities();
|
|
584
|
+
if (!caps && AUTH.token) {
|
|
585
|
+
caps = await fetchWorkspaceCapabilities(API_BASE, AUTH.token);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Also fetch current user and basic workspace info
|
|
589
|
+
let currentUser = null;
|
|
590
|
+
try {
|
|
591
|
+
currentUser = await api('GET', '/api/v1/users/me');
|
|
592
|
+
} catch { /* non-critical */ }
|
|
593
|
+
|
|
594
|
+
let userCount = null;
|
|
595
|
+
try {
|
|
596
|
+
const users = await api('GET', '/api/v1/users', { params: { limit: 1, page: 1 } });
|
|
597
|
+
userCount = users?.total || users?.meta?.total || null;
|
|
598
|
+
} catch { /* non-critical */ }
|
|
599
|
+
|
|
600
|
+
let recordCounts = null;
|
|
601
|
+
try {
|
|
602
|
+
recordCounts = await api('GET', '/api/v1/data/counts');
|
|
603
|
+
} catch { /* non-critical */ }
|
|
604
|
+
|
|
605
|
+
// Report a failed lookup as structured degradation, not English prose in
|
|
606
|
+
// a data field. This previously returned success with
|
|
607
|
+
// capabilities:"Unable to fetch workspace capabilities" and
|
|
608
|
+
// enabledModules:"Unknown", so a caller could not distinguish "this
|
|
609
|
+
// workspace has no modules" from "the lookup failed" without
|
|
610
|
+
// string-matching a sentence.
|
|
611
|
+
return ok({
|
|
612
|
+
workspace: {
|
|
613
|
+
capabilitiesAvailable: caps != null,
|
|
614
|
+
capabilities: caps ?? null,
|
|
615
|
+
enabledModules: caps?.features ?? null,
|
|
616
|
+
rateLimits: caps?.rateLimits || { maxRequestsPerMinute: 120 },
|
|
617
|
+
...(caps == null
|
|
618
|
+
? { degraded: 'capabilities lookup failed; fields are null rather than empty' }
|
|
619
|
+
: {}),
|
|
620
|
+
},
|
|
621
|
+
currentUser: currentUser?.data || currentUser,
|
|
622
|
+
teamSize: userCount,
|
|
623
|
+
recordCounts: recordCounts?.data || recordCounts,
|
|
624
|
+
auth: {
|
|
625
|
+
tier: AUTH.tier,
|
|
626
|
+
source: AUTH.source,
|
|
627
|
+
apiBase: API_BASE,
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Wrap an existing tool handler with capability checking.
|
|
636
|
+
* Used to add capability checks to the existing 67 tools.
|
|
637
|
+
*
|
|
638
|
+
* @param {string} toolName - Tool name
|
|
639
|
+
* @param {string} capabilityName - Required capability
|
|
640
|
+
* @param {function} ok - The ok() helper
|
|
641
|
+
* @returns {function} Wrapper that checks capability before delegating
|
|
642
|
+
*/
|
|
643
|
+
export function withCapabilityCheck(toolName, capabilityName, ok) {
|
|
644
|
+
return (originalHandler) => {
|
|
645
|
+
return async (...args) => {
|
|
646
|
+
const capCheck = checkCapability(toolName, ok);
|
|
647
|
+
if (!capCheck.enabled) return capCheck.response;
|
|
648
|
+
return originalHandler(...args);
|
|
649
|
+
};
|
|
650
|
+
};
|
|
651
|
+
}
|