@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.
Files changed (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. 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
+ }