@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,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure Toolset (9 tools, Enterprise tier)
|
|
3
|
+
*
|
|
4
|
+
* manage_domains, manage_mailboxes, get_deliverability,
|
|
5
|
+
* list_provider_catalog, get_provider_connect_plan, list_provider_endpoints,
|
|
6
|
+
* connect_provider, manage_workspace, manage_data
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { md, mdError, table, formatDeliverability, formatDate, progressBar } from '../output-formatter.js';
|
|
11
|
+
|
|
12
|
+
export function register(server, api, AUTH) {
|
|
13
|
+
|
|
14
|
+
const DOMAINS_BASE = '/api/v1/email-provisioning/domains';
|
|
15
|
+
const MAILBOXES_BASE = '/api/v1/email-provisioning/mailboxes';
|
|
16
|
+
|
|
17
|
+
/** Resolve a domain id or name to its workspace email-domain record. */
|
|
18
|
+
async function findDomainRecord(domainOrId) {
|
|
19
|
+
const result = await api('GET', DOMAINS_BASE, { params: { limit: 200, page: 1 } });
|
|
20
|
+
const items = result?.data || [];
|
|
21
|
+
const needle = String(domainOrId || '').trim().toLowerCase();
|
|
22
|
+
return items.find(d =>
|
|
23
|
+
d.id === domainOrId || (d.domain && String(d.domain).toLowerCase() === needle)
|
|
24
|
+
) || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// -----------------------------------------------------------------------
|
|
28
|
+
// manage_domains
|
|
29
|
+
// -----------------------------------------------------------------------
|
|
30
|
+
server.tool(
|
|
31
|
+
'manage_domains',
|
|
32
|
+
'Add, verify, and configure domains for email sending. Returns SPF/DKIM/DMARC setup instructions and verification status.',
|
|
33
|
+
{
|
|
34
|
+
action: z.enum(['add', 'verify', 'get', 'list', 'delete']).describe('Operation'),
|
|
35
|
+
domain: z.string().optional().describe('Domain name (e.g. outreach.acme.com)'),
|
|
36
|
+
id: z.string().optional().describe('Domain ID (for verify/get/delete)'),
|
|
37
|
+
},
|
|
38
|
+
async (args) => {
|
|
39
|
+
try {
|
|
40
|
+
switch (args.action) {
|
|
41
|
+
case 'add': {
|
|
42
|
+
const result = await api('POST', DOMAINS_BASE, { body: { domain: args.domain } });
|
|
43
|
+
const d = result?.data || result || {};
|
|
44
|
+
let text = `## Domain Added: ${args.domain}\n\n`;
|
|
45
|
+
text += `- **ID:** ${d.id || '\u2014'}\n`;
|
|
46
|
+
text += `- **Status:** ${d.status || 'pending'}\n\n`;
|
|
47
|
+
const records = Array.isArray(d.dnsRecords) ? d.dnsRecords : (d.dnsRecords?.records || []);
|
|
48
|
+
if (records.length > 0) {
|
|
49
|
+
text += '### DNS Records to Add\n\n';
|
|
50
|
+
text += '| Type | Name | Value |\n|------|------|-------|\n';
|
|
51
|
+
for (const r of records) {
|
|
52
|
+
text += `| ${r.type || r.recordType || '\u2014'} | ${r.name || r.host || '\u2014'} | ${r.value || r.record || '\u2014'} |\n`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
text += '\nAfter adding DNS records, run `manage_domains` with action: "verify".\n';
|
|
56
|
+
return md(text);
|
|
57
|
+
}
|
|
58
|
+
case 'verify': {
|
|
59
|
+
const record = await findDomainRecord(args.id || args.domain);
|
|
60
|
+
if (!record) return mdError(`Domain ${args.domain || args.id} not found in this workspace. Add it with action: "add".`);
|
|
61
|
+
const result = await api('POST', `${DOMAINS_BASE}/${encodeURIComponent(record.id)}/verify`);
|
|
62
|
+
const d = result?.data || result || {};
|
|
63
|
+
const v = d.verification || {};
|
|
64
|
+
const pass = (s) => s === 'verified';
|
|
65
|
+
let text = `## Domain Verification: ${d.domain || args.domain || args.id}\n\n`;
|
|
66
|
+
text += `- **SPF:** ${pass(v.spf?.status) ? '\u2705 Verified' : '\u274C Not verified'}\n`;
|
|
67
|
+
text += `- **DKIM:** ${pass(v.dkim?.status) ? '\u2705 Verified' : '\u274C Not verified'}\n`;
|
|
68
|
+
text += `- **DMARC:** ${pass(v.dmarc?.status) ? '\u2705 Verified' : '\u274C Not verified'}\n`;
|
|
69
|
+
text += `- **Overall:** ${d.isReady ? '\u2705 Ready to send' : '\u26A0\uFE0F Incomplete'}\n`;
|
|
70
|
+
return md(text);
|
|
71
|
+
}
|
|
72
|
+
case 'get': {
|
|
73
|
+
// No GET-by-id route exists; resolve from the workspace list.
|
|
74
|
+
const d = await findDomainRecord(args.id || args.domain);
|
|
75
|
+
if (!d) return mdError(`Domain ${args.id || args.domain} not found in this workspace.`);
|
|
76
|
+
let text = `## Domain: ${d.domain || args.id}\n\n`;
|
|
77
|
+
text += `- **ID:** ${d.id || '\u2014'}\n`;
|
|
78
|
+
text += `- **Status:** ${d.status || '\u2014'}\n`;
|
|
79
|
+
text += `- **Verified:** ${d.verified || d.status === 'verified' ? 'Yes' : 'No'}\n`;
|
|
80
|
+
text += `- **Health:** ${d.health || d.healthScore || '\u2014'}\n`;
|
|
81
|
+
return md(text);
|
|
82
|
+
}
|
|
83
|
+
case 'list': {
|
|
84
|
+
const result = await api('GET', DOMAINS_BASE, { params: { limit: 25, page: 1 } });
|
|
85
|
+
const items = result?.data || [];
|
|
86
|
+
if (items.length === 0) return md('## Domains\n\nNo domains configured. Add one with action: "add".\n');
|
|
87
|
+
let text = `## ${items.length} Domains\n\n`;
|
|
88
|
+
const rows = items.map(d => [
|
|
89
|
+
d.domain || '\u2014',
|
|
90
|
+
(d.verified || d.status === 'verified') ? '\u2705' : '\u274C',
|
|
91
|
+
d.health || d.healthScore || '\u2014',
|
|
92
|
+
d.mailboxCount != null ? String(d.mailboxCount) : '\u2014',
|
|
93
|
+
]);
|
|
94
|
+
text += table(['Domain', 'Verified', 'Health', 'Mailboxes'], rows);
|
|
95
|
+
return md(text);
|
|
96
|
+
}
|
|
97
|
+
case 'delete': {
|
|
98
|
+
await api('DELETE', `${DOMAINS_BASE}/${encodeURIComponent(args.id)}`);
|
|
99
|
+
return md(`## Domain Deleted\n\nID: ${args.id}\n`);
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return mdError(`Unknown action: ${args.action}`);
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return mdError('Domain operation failed', err.message);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// -----------------------------------------------------------------------
|
|
111
|
+
// manage_mailboxes
|
|
112
|
+
// -----------------------------------------------------------------------
|
|
113
|
+
server.tool(
|
|
114
|
+
'manage_mailboxes',
|
|
115
|
+
'Create, configure, and manage sending mailboxes. Toggle warmup, check health, and set sending limits.',
|
|
116
|
+
{
|
|
117
|
+
action: z.enum(['create', 'get', 'list', 'update', 'warmup_start', 'warmup_stop', 'delete']).describe('Operation'),
|
|
118
|
+
id: z.string().optional().describe('Mailbox ID'),
|
|
119
|
+
email: z.string().optional().describe('Email address for the mailbox'),
|
|
120
|
+
domainId: z.string().optional().describe('Domain ID'),
|
|
121
|
+
displayName: z.string().optional().describe('Sender display name'),
|
|
122
|
+
dailyLimit: z.number().optional().describe('Max emails per day'),
|
|
123
|
+
},
|
|
124
|
+
async (args) => {
|
|
125
|
+
try {
|
|
126
|
+
switch (args.action) {
|
|
127
|
+
case 'create': {
|
|
128
|
+
const result = await api('POST', MAILBOXES_BASE, {
|
|
129
|
+
body: {
|
|
130
|
+
email: args.email,
|
|
131
|
+
domainId: args.domainId,
|
|
132
|
+
displayName: args.displayName,
|
|
133
|
+
dailySendLimit: args.dailyLimit || 50,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
const m = result?.data || result || {};
|
|
137
|
+
let text = `## Mailbox Created\n\n`;
|
|
138
|
+
text += `- **Email:** ${args.email}\n`;
|
|
139
|
+
text += `- **ID:** ${m.id || '\u2014'}\n`;
|
|
140
|
+
text += `- **Daily Limit:** ${args.dailyLimit || 50}\n`;
|
|
141
|
+
text += `- **Warmup:** Not started\n\n`;
|
|
142
|
+
text += 'Run with action: "warmup_start" to begin warming up this mailbox.\n';
|
|
143
|
+
return md(text);
|
|
144
|
+
}
|
|
145
|
+
case 'get': {
|
|
146
|
+
const result = await api('GET', `${MAILBOXES_BASE}/${encodeURIComponent(args.id)}`);
|
|
147
|
+
const m = result?.data || result || {};
|
|
148
|
+
let text = `## Mailbox: ${m.email || args.id}\n\n`;
|
|
149
|
+
text += `- **Status:** ${m.status || '\u2014'}\n`;
|
|
150
|
+
text += `- **Health:** ${m.health || '\u2014'}\n`;
|
|
151
|
+
text += `- **Warmup:** ${m.warmupStatus || '\u2014'}\n`;
|
|
152
|
+
text += `- **Daily Limit:** ${m.dailyLimit || '\u2014'}\n`;
|
|
153
|
+
text += `- **Sent Today:** ${m.sentToday || 0}\n`;
|
|
154
|
+
return md(text);
|
|
155
|
+
}
|
|
156
|
+
case 'list': {
|
|
157
|
+
const result = await api('GET', MAILBOXES_BASE, { params: { limit: 25, page: 1 } });
|
|
158
|
+
const items = result?.data || [];
|
|
159
|
+
if (items.length === 0) return md('## Mailboxes\n\nNo mailboxes configured.\n');
|
|
160
|
+
let text = `## ${items.length} Mailboxes\n\n`;
|
|
161
|
+
const rows = items.map(m => [
|
|
162
|
+
m.email || '\u2014',
|
|
163
|
+
m.status || '\u2014',
|
|
164
|
+
m.warmupStatus || '\u2014',
|
|
165
|
+
`${m.sentToday || 0}/${m.dailyLimit || '\u2014'}`,
|
|
166
|
+
m.health || '\u2014',
|
|
167
|
+
]);
|
|
168
|
+
text += table(['Email', 'Status', 'Warmup', 'Sent/Limit', 'Health'], rows);
|
|
169
|
+
return md(text);
|
|
170
|
+
}
|
|
171
|
+
case 'update': {
|
|
172
|
+
const body = {};
|
|
173
|
+
if (args.displayName) body.displayName = args.displayName;
|
|
174
|
+
if (args.dailyLimit) body.dailySendLimit = args.dailyLimit;
|
|
175
|
+
await api('PATCH', `${MAILBOXES_BASE}/${encodeURIComponent(args.id)}`, { body });
|
|
176
|
+
return md(`## Mailbox Updated\n\nID: ${args.id}\n`);
|
|
177
|
+
}
|
|
178
|
+
case 'warmup_start': {
|
|
179
|
+
await api('POST', `${MAILBOXES_BASE}/${encodeURIComponent(args.id)}/warmup`, { body: { enable: true } });
|
|
180
|
+
return md(`## Warmup Started\n\nMailbox: ${args.id}\nWarmup gradually increases send volume over 2-4 weeks to build sender reputation.\n`);
|
|
181
|
+
}
|
|
182
|
+
case 'warmup_stop': {
|
|
183
|
+
await api('POST', `${MAILBOXES_BASE}/${encodeURIComponent(args.id)}/warmup`, { body: { enable: false } });
|
|
184
|
+
return md(`## Warmup Stopped\n\nMailbox: ${args.id}\n`);
|
|
185
|
+
}
|
|
186
|
+
case 'delete': {
|
|
187
|
+
await api('DELETE', `${MAILBOXES_BASE}/${encodeURIComponent(args.id)}`);
|
|
188
|
+
return md(`## Mailbox Deleted\n\nID: ${args.id}\n`);
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
return mdError(`Unknown action: ${args.action}`);
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return mdError('Mailbox operation failed', err.message);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// -----------------------------------------------------------------------
|
|
200
|
+
// get_deliverability
|
|
201
|
+
// -----------------------------------------------------------------------
|
|
202
|
+
server.tool(
|
|
203
|
+
'get_deliverability',
|
|
204
|
+
'Domain health check: SPF/DKIM/DMARC status, blacklist check, inbox placement prediction, and sender reputation score.',
|
|
205
|
+
{
|
|
206
|
+
domainId: z.string().optional().describe('Domain ID'),
|
|
207
|
+
domain: z.string().optional().describe('Domain name'),
|
|
208
|
+
},
|
|
209
|
+
async (args) => {
|
|
210
|
+
try {
|
|
211
|
+
// The deliverability API keys on the domain name; resolve an id first.
|
|
212
|
+
let domainName = args.domain;
|
|
213
|
+
if (!domainName && args.domainId) {
|
|
214
|
+
const record = await findDomainRecord(args.domainId);
|
|
215
|
+
domainName = record?.domain;
|
|
216
|
+
}
|
|
217
|
+
if (!domainName) {
|
|
218
|
+
return mdError('Deliverability check failed', 'Provide a domain name, or a domainId that exists in this workspace.');
|
|
219
|
+
}
|
|
220
|
+
const result = await api('GET', '/api/v1/email-deliverability/check-domain', {
|
|
221
|
+
params: {
|
|
222
|
+
domain: domainName,
|
|
223
|
+
// Required by the route signature but derived server-side from auth.
|
|
224
|
+
workspaceId: AUTH.workspaceId || 'current',
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const d = result?.data || result || {};
|
|
228
|
+
let text = formatDeliverability({
|
|
229
|
+
domainHealth: d.healthScore,
|
|
230
|
+
spf: d.spf?.status,
|
|
231
|
+
dkim: d.dkim?.status,
|
|
232
|
+
dmarc: d.dmarc?.status,
|
|
233
|
+
});
|
|
234
|
+
if (Array.isArray(d.recommendations) && d.recommendations.length > 0) {
|
|
235
|
+
text += '\n### Recommendations\n';
|
|
236
|
+
for (const r of d.recommendations) text += `- ${typeof r === 'string' ? r : JSON.stringify(r)}\n`;
|
|
237
|
+
}
|
|
238
|
+
return md(text);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return mdError('Deliverability check failed', err.message);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// -----------------------------------------------------------------------
|
|
246
|
+
// list_provider_catalog
|
|
247
|
+
// -----------------------------------------------------------------------
|
|
248
|
+
server.tool(
|
|
249
|
+
'list_provider_catalog',
|
|
250
|
+
'List Adrata integration providers with category, auth model, runtime status, headless support, and connect path.',
|
|
251
|
+
{
|
|
252
|
+
category: z.string().optional().describe('Optional category filter, e.g. Outbound, Sourcing, CRM'),
|
|
253
|
+
status: z.string().optional().describe('Optional status filter, e.g. customer_connectable, credential_only, registry_only, planned'),
|
|
254
|
+
},
|
|
255
|
+
async (args) => {
|
|
256
|
+
try {
|
|
257
|
+
const result = await api('GET', '/api/v1/providers/catalog', {
|
|
258
|
+
params: Object.fromEntries(Object.entries(args || {}).filter(([, v]) => v != null && v !== '')),
|
|
259
|
+
});
|
|
260
|
+
const data = result?.data || result || {};
|
|
261
|
+
const providers = data.providers || [];
|
|
262
|
+
if (providers.length === 0) return md('## Provider Catalog\n\nNo providers matched.\n');
|
|
263
|
+
let text = `## Provider Catalog — ${providers.length} providers\n\n`;
|
|
264
|
+
text += table(
|
|
265
|
+
['Provider', 'Category', 'Status', 'Runtime', 'Auth', 'Headless'],
|
|
266
|
+
providers.map((p) => [
|
|
267
|
+
p.name || p.id,
|
|
268
|
+
p.category || '\u2014',
|
|
269
|
+
p.status || '\u2014',
|
|
270
|
+
p.runtime || '\u2014',
|
|
271
|
+
p.auth || '\u2014',
|
|
272
|
+
p.headless ? 'yes' : 'no',
|
|
273
|
+
])
|
|
274
|
+
);
|
|
275
|
+
text += '\nUse `get_provider_connect_plan` or `list_provider_endpoints` for a provider before connecting it.\n';
|
|
276
|
+
return md(text);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
return mdError('Provider catalog failed', err.message);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// -----------------------------------------------------------------------
|
|
284
|
+
// get_provider_connect_plan
|
|
285
|
+
// -----------------------------------------------------------------------
|
|
286
|
+
server.tool(
|
|
287
|
+
'get_provider_connect_plan',
|
|
288
|
+
'Get the exact app/API/MCP/CLI connection plan for an integration provider.',
|
|
289
|
+
{
|
|
290
|
+
provider: z.string().describe('Provider id, e.g. gmail, salesforce, heyreach'),
|
|
291
|
+
},
|
|
292
|
+
async (args) => {
|
|
293
|
+
try {
|
|
294
|
+
const result = await api('GET', `/api/v1/providers/catalog/${encodeURIComponent(args.provider)}/connect`);
|
|
295
|
+
const data = result?.data || result || {};
|
|
296
|
+
const provider = data.provider || {};
|
|
297
|
+
const connect = data.connect || {};
|
|
298
|
+
let text = `## Connect Plan: ${provider.name || args.provider}\n\n`;
|
|
299
|
+
text += `- **Status:** ${provider.status || '\u2014'}\n`;
|
|
300
|
+
text += `- **Runtime:** ${provider.runtime || '\u2014'}\n`;
|
|
301
|
+
text += `- **Auth:** ${provider.auth || '\u2014'}\n`;
|
|
302
|
+
text += `- **Headless:** ${data.headless ? 'yes' : 'no'}\n`;
|
|
303
|
+
if (connect.path) {
|
|
304
|
+
text += `- **Method:** ${connect.method || '\u2014'}\n`;
|
|
305
|
+
text += `- **Path:** \`${connect.path}\`\n`;
|
|
306
|
+
if (connect.body) text += `- **Body:** \`${JSON.stringify(connect.body)}\`\n`;
|
|
307
|
+
}
|
|
308
|
+
if (data.warning) text += `\n> ${data.warning}\n`;
|
|
309
|
+
return md(text);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
return mdError('Connect plan failed', err.message);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// -----------------------------------------------------------------------
|
|
317
|
+
// list_provider_endpoints
|
|
318
|
+
// -----------------------------------------------------------------------
|
|
319
|
+
server.tool(
|
|
320
|
+
'list_provider_endpoints',
|
|
321
|
+
'List documented endpoint/actions for an integration provider, including scopes, latency, tags, and billing behavior.',
|
|
322
|
+
{
|
|
323
|
+
provider: z.string().describe('Provider id, e.g. heyreach'),
|
|
324
|
+
search: z.string().optional().describe('Optional endpoint search string'),
|
|
325
|
+
},
|
|
326
|
+
async (args) => {
|
|
327
|
+
try {
|
|
328
|
+
const result = await api('GET', `/api/v1/providers/catalog/${encodeURIComponent(args.provider)}/endpoints`);
|
|
329
|
+
const data = result?.data || result || {};
|
|
330
|
+
const needle = (args.search || '').toLowerCase();
|
|
331
|
+
const endpoints = (data.endpoints || []).filter((e) => {
|
|
332
|
+
if (!needle) return true;
|
|
333
|
+
return [e.method, e.path, e.slug, e.requiredScope, e.description, ...(e.tags || [])].join(' ').toLowerCase().includes(needle);
|
|
334
|
+
});
|
|
335
|
+
let text = `## ${data.provider?.name || args.provider} Endpoints — ${endpoints.length}/${data.total || 0}\n\n`;
|
|
336
|
+
if (endpoints.length === 0) {
|
|
337
|
+
text += data.coverage === 'connect_contract_only'
|
|
338
|
+
? 'No endpoint catalog is published yet. This provider currently exposes only the connect contract.\n'
|
|
339
|
+
: 'No endpoints matched.\n';
|
|
340
|
+
return md(text);
|
|
341
|
+
}
|
|
342
|
+
text += table(
|
|
343
|
+
['Method', 'Path', 'Slug', 'Scope', 'Latency', 'Billing'],
|
|
344
|
+
endpoints.map((e) => [e.method, e.path, e.slug, e.requiredScope || e.required_scope || '\u2014', e.latency, e.billing])
|
|
345
|
+
);
|
|
346
|
+
return md(text);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
return mdError('Provider endpoints failed', err.message);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// -----------------------------------------------------------------------
|
|
354
|
+
// connect_provider
|
|
355
|
+
// -----------------------------------------------------------------------
|
|
356
|
+
server.tool(
|
|
357
|
+
'connect_provider',
|
|
358
|
+
'Connect a provider using the backend provider catalog. Supports hosted OAuth providers and generic credential/BYOK providers.',
|
|
359
|
+
{
|
|
360
|
+
provider: z.string().describe('Provider id, e.g. gmail, salesforce, heyreach'),
|
|
361
|
+
scopes: z.array(z.string()).optional().describe('Additional OAuth scopes (advanced)'),
|
|
362
|
+
workspaceId: z.string().optional().describe('Workspace id required by some OAuth launch routes'),
|
|
363
|
+
credentials: z.record(z.unknown()).optional().describe('Credential payload for API-key/BYOK providers'),
|
|
364
|
+
returnUrl: z.string().optional().describe('Optional return URL for OAuth flows'),
|
|
365
|
+
},
|
|
366
|
+
async (args) => {
|
|
367
|
+
try {
|
|
368
|
+
const planResult = await api('GET', `/api/v1/providers/catalog/${encodeURIComponent(args.provider)}/connect`);
|
|
369
|
+
const plan = planResult?.data || planResult || {};
|
|
370
|
+
const provider = plan.provider || {};
|
|
371
|
+
const connect = plan.connect || {};
|
|
372
|
+
if (!plan.canConnect) {
|
|
373
|
+
return mdError('Connection failed', `${args.provider} is not connectable yet. ${plan.warning || ''}`);
|
|
374
|
+
}
|
|
375
|
+
if (!connect.path) {
|
|
376
|
+
return mdError('Connection failed', `${args.provider} is not connectable yet. ${plan.warning || ''}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let method = connect.method === 'POST' ? 'POST' : undefined;
|
|
380
|
+
let path = connect.path;
|
|
381
|
+
let body = connect.body || undefined;
|
|
382
|
+
const params = {};
|
|
383
|
+
|
|
384
|
+
if (args.returnUrl) params.returnUrl = args.returnUrl;
|
|
385
|
+
if (args.workspaceId && (args.provider === 'salesforce' || args.provider === 'hubspot' || args.provider === 'slack')) {
|
|
386
|
+
params.workspace_id = args.workspaceId;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (args.provider === 'gmail' || args.provider === 'google_calendar' || args.provider === 'google_meet') {
|
|
390
|
+
method = 'POST';
|
|
391
|
+
body = { provider: 'google', scopes: args.scopes };
|
|
392
|
+
} else if (args.provider === 'outlook') {
|
|
393
|
+
method = 'POST';
|
|
394
|
+
body = { provider: 'microsoft', scopes: args.scopes };
|
|
395
|
+
} else if (path === '/api/v1/integrations') {
|
|
396
|
+
method = 'POST';
|
|
397
|
+
body = {
|
|
398
|
+
provider: args.provider,
|
|
399
|
+
type: 'grand_central',
|
|
400
|
+
credentials: args.credentials,
|
|
401
|
+
config: { source: 'mcp', runtime: provider.runtime, status: provider.status },
|
|
402
|
+
};
|
|
403
|
+
} else if (!method) {
|
|
404
|
+
method = 'GET';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const result = await api(method, path, { params, body });
|
|
408
|
+
|
|
409
|
+
const r = result?.data || result || {};
|
|
410
|
+
let text = `## Connect ${args.provider}\n\n`;
|
|
411
|
+
|
|
412
|
+
if (r.authUrl) {
|
|
413
|
+
text += `Authorization URL:\n${r.authUrl}\n\n`;
|
|
414
|
+
text += 'Open this URL in your browser to complete the connection.\n';
|
|
415
|
+
} else {
|
|
416
|
+
text += `- **Status:** ${r.status || 'Initiated'}\n`;
|
|
417
|
+
text += `- **Provider:** ${args.provider}\n`;
|
|
418
|
+
text += `- **Runtime:** ${provider.runtime || '\u2014'}\n`;
|
|
419
|
+
text += `- **Runtime actions:** ${plan.runtimeActions ? 'available' : 'not yet available'}\n`;
|
|
420
|
+
if (r.message) text += `\n${r.message}\n`;
|
|
421
|
+
if (plan.warning) text += `\n> ${plan.warning}\n`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return md(text);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
return mdError('Connection failed', err.message);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
// -----------------------------------------------------------------------
|
|
432
|
+
// add_provider_action_column
|
|
433
|
+
// -----------------------------------------------------------------------
|
|
434
|
+
server.tool(
|
|
435
|
+
'add_provider_action_column',
|
|
436
|
+
'Add a provider action column to a GTM table. Mirrors `adrata action-column add-provider --provider <slug> --table <table_id>` with dry-run policy metadata by default.',
|
|
437
|
+
{
|
|
438
|
+
provider: z.string().describe('Provider id, e.g. heyreach, apollo, gmail'),
|
|
439
|
+
tableId: z.string().describe('Adrata table id'),
|
|
440
|
+
columnName: z.string().optional().describe('Optional action-column name'),
|
|
441
|
+
endpoint: z.string().optional().describe('Optional endpoint/tool slug to bind first'),
|
|
442
|
+
scopes: z.array(z.string()).optional().describe('Scopes required by the provider action'),
|
|
443
|
+
credentialRef: z.string().optional().describe('Stored credential reference. Required for live requests.'),
|
|
444
|
+
idempotencyKey: z.string().optional().describe('Stable idempotency key for live requests. Generated when omitted.'),
|
|
445
|
+
billingClassification: z.string().optional().describe('Billing class for live execution review. Required for live requests.'),
|
|
446
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to submit a live request.'),
|
|
447
|
+
},
|
|
448
|
+
async (args) => {
|
|
449
|
+
const payload = {
|
|
450
|
+
provider: args.provider,
|
|
451
|
+
tableId: args.tableId,
|
|
452
|
+
columnName: args.columnName || `${args.provider}_actions`,
|
|
453
|
+
endpoint: args.endpoint || null,
|
|
454
|
+
credentialRef: args.credentialRef || null,
|
|
455
|
+
idempotencyKey: args.idempotencyKey || null,
|
|
456
|
+
billingClassification: args.billingClassification || null,
|
|
457
|
+
scopes: args.scopes || [],
|
|
458
|
+
dryRun: args.dryRun !== false,
|
|
459
|
+
policy: {
|
|
460
|
+
operation: 'action_column.add_provider',
|
|
461
|
+
permission: 'tables.action_columns.write',
|
|
462
|
+
auditEvent: 'action_column.provider.add_requested',
|
|
463
|
+
dryRunRequired: args.dryRun !== false,
|
|
464
|
+
approvalRequired: true,
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
try {
|
|
468
|
+
const command = await api('POST', '/api/v1/commands', {
|
|
469
|
+
body: {
|
|
470
|
+
commandType: 'action_column.add_provider',
|
|
471
|
+
target: {
|
|
472
|
+
provider: payload.provider,
|
|
473
|
+
tableId: payload.tableId,
|
|
474
|
+
endpoint: payload.endpoint,
|
|
475
|
+
source: 'mcp',
|
|
476
|
+
},
|
|
477
|
+
parameters: payload,
|
|
478
|
+
dryRun: payload.dryRun,
|
|
479
|
+
idempotencyKey: payload.dryRun
|
|
480
|
+
? null
|
|
481
|
+
: payload.idempotencyKey || actionColumnIdempotencyKey(payload),
|
|
482
|
+
reason: `Add ${payload.provider} action column to ${payload.tableId}`,
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
if (payload.dryRun) {
|
|
486
|
+
return md(`## Action Column Dry Run\n\n\`\`\`json\n${JSON.stringify({ command, payload }, null, 2)}\n\`\`\`\n`);
|
|
487
|
+
}
|
|
488
|
+
const result = await api('POST', '/api/v1/action-columns/providers', { body: payload });
|
|
489
|
+
return md(`## Action Column Requested\n\n\`\`\`json\n${JSON.stringify({ command, result }, null, 2)}\n\`\`\`\n`);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
return mdError('Action column request failed', err.message);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// -----------------------------------------------------------------------
|
|
497
|
+
// manage_workspace
|
|
498
|
+
// -----------------------------------------------------------------------
|
|
499
|
+
server.tool(
|
|
500
|
+
'manage_workspace',
|
|
501
|
+
'View and update workspace settings, features, team members, and integrations. The admin control panel.',
|
|
502
|
+
{
|
|
503
|
+
action: z.enum(['get', 'update', 'list_users', 'list_integrations']).describe('Operation'),
|
|
504
|
+
settings: z.record(z.unknown()).optional().describe('Settings to update (for update action)'),
|
|
505
|
+
},
|
|
506
|
+
async (args) => {
|
|
507
|
+
try {
|
|
508
|
+
switch (args.action) {
|
|
509
|
+
case 'get': {
|
|
510
|
+
// Parallel: user + counts
|
|
511
|
+
const [user, counts] = await Promise.all([
|
|
512
|
+
api('GET', '/api/v1/users/me').catch(() => ({})),
|
|
513
|
+
api('GET', '/api/v1/data/counts').catch(() => ({})),
|
|
514
|
+
]);
|
|
515
|
+
|
|
516
|
+
const u = user?.data || user || {};
|
|
517
|
+
const c = counts?.data || counts || {};
|
|
518
|
+
let text = `## Workspace\n\n`;
|
|
519
|
+
text += `- **User:** ${u.name || u.email || '\u2014'}\n`;
|
|
520
|
+
text += `- **Tier:** ${AUTH.tier}\n`;
|
|
521
|
+
text += `- **Companies:** ${c.companies || 0}\n`;
|
|
522
|
+
text += `- **People:** ${c.people || 0}\n`;
|
|
523
|
+
text += `- **Opportunities:** ${c.opportunities || 0}\n`;
|
|
524
|
+
return md(text);
|
|
525
|
+
}
|
|
526
|
+
case 'update': {
|
|
527
|
+
// Workspace settings are written one key at a time:
|
|
528
|
+
// PUT /api/v1/workspace-settings/{setting_key} with { value }.
|
|
529
|
+
const entries = Object.entries(args.settings || {});
|
|
530
|
+
if (entries.length === 0) {
|
|
531
|
+
return mdError('Workspace update failed', 'Provide a settings object, e.g. { "timezone": "America/Los_Angeles" }.');
|
|
532
|
+
}
|
|
533
|
+
const updated = [];
|
|
534
|
+
const failed = [];
|
|
535
|
+
for (const [key, value] of entries) {
|
|
536
|
+
try {
|
|
537
|
+
await api('PUT', `/api/v1/workspace-settings/${encodeURIComponent(key)}`, { body: { value } });
|
|
538
|
+
updated.push(key);
|
|
539
|
+
} catch (err) {
|
|
540
|
+
failed.push(`${key}: ${err.message}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
let text = `## Workspace Updated\n\n`;
|
|
544
|
+
text += `- **Updated:** ${updated.join(', ') || '—'}\n`;
|
|
545
|
+
if (failed.length > 0) {
|
|
546
|
+
text += `- **Failed:**\n`;
|
|
547
|
+
for (const f of failed) text += ` - ${f}\n`;
|
|
548
|
+
}
|
|
549
|
+
return md(text);
|
|
550
|
+
}
|
|
551
|
+
case 'list_users': {
|
|
552
|
+
const result = await api('GET', '/api/v1/users', { params: { limit: 50, page: 1 } });
|
|
553
|
+
const users = result?.data || [];
|
|
554
|
+
if (users.length === 0) return md('## Team\n\nNo team members found.\n');
|
|
555
|
+
let text = `## Team \u2014 ${users.length} Members\n\n`;
|
|
556
|
+
const rows = users.map(u => [u.name || '\u2014', u.email || '\u2014', u.role || '\u2014']);
|
|
557
|
+
text += table(['Name', 'Email', 'Role'], rows);
|
|
558
|
+
return md(text);
|
|
559
|
+
}
|
|
560
|
+
case 'list_integrations': {
|
|
561
|
+
const status = await api('GET', '/api/v1/providers/status').catch(() => ({ data: [] }));
|
|
562
|
+
const integrations = status?.data || [];
|
|
563
|
+
if (integrations.length === 0) return md('## Integrations\n\nNo integrations connected. Use `connect_provider` to add one.\n');
|
|
564
|
+
let text = `## ${integrations.length} Integrations\n\n`;
|
|
565
|
+
const rows = integrations.map(i => [
|
|
566
|
+
i.provider || i.id || '\u2014',
|
|
567
|
+
i.status === 'active' || i.status === 'connected' ? '\u2705 Active' : (i.status || '\u274C Inactive'),
|
|
568
|
+
formatDate(i.connectedAt || i.lastTestedAt || i.updatedAt),
|
|
569
|
+
]);
|
|
570
|
+
text += table(['Provider', 'Status', 'Connected'], rows);
|
|
571
|
+
return md(text);
|
|
572
|
+
}
|
|
573
|
+
default:
|
|
574
|
+
return mdError(`Unknown action: ${args.action}`);
|
|
575
|
+
}
|
|
576
|
+
} catch (err) {
|
|
577
|
+
return mdError('Workspace operation failed', err.message);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
// -----------------------------------------------------------------------
|
|
583
|
+
// manage_data
|
|
584
|
+
// -----------------------------------------------------------------------
|
|
585
|
+
server.tool(
|
|
586
|
+
'manage_data',
|
|
587
|
+
'Import CSV data, run bulk operations, check data quality, or export records. The data management swiss army knife.',
|
|
588
|
+
{
|
|
589
|
+
action: z.enum(['import', 'export', 'quality_check', 'bulk_update', 'bulk_delete']).describe('Operation'),
|
|
590
|
+
entityType: z.enum(['companies', 'people', 'opportunities']).optional().describe('Entity type'),
|
|
591
|
+
csvData: z.string().optional().describe('CSV content for import'),
|
|
592
|
+
filters: z.record(z.unknown()).optional().describe('Filters for export/bulk operations'),
|
|
593
|
+
updates: z.record(z.unknown()).optional().describe('Fields to update (for bulk_update)'),
|
|
594
|
+
},
|
|
595
|
+
async (args) => {
|
|
596
|
+
try {
|
|
597
|
+
switch (args.action) {
|
|
598
|
+
case 'import': {
|
|
599
|
+
// The live importer is POST /api/v1/bulk/import. It takes parsed
|
|
600
|
+
// records, not a raw CSV string. The older data-import route is a
|
|
601
|
+
// 501 stub and never wrote anything.
|
|
602
|
+
const records = parseCsvRecords(args.csvData || '');
|
|
603
|
+
if (records.length === 0) {
|
|
604
|
+
return mdError('Import failed', 'No data rows found. Provide CSV with a header row and at least one data row.');
|
|
605
|
+
}
|
|
606
|
+
const result = await api('POST', '/api/v1/bulk/import', {
|
|
607
|
+
body: {
|
|
608
|
+
entityType: args.entityType,
|
|
609
|
+
records,
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
const r = result?.data || result || {};
|
|
613
|
+
let text = `## Import Complete\n\n`;
|
|
614
|
+
text += `- **Type:** ${args.entityType}\n`;
|
|
615
|
+
text += `- **Job:** ${r.jobId || '—'}\n`;
|
|
616
|
+
text += `- **Records imported:** ${r.processedRecords || 0}\n`;
|
|
617
|
+
text += `- **Records failed:** ${r.failedRecords || 0}\n`;
|
|
618
|
+
text += `- **Companies created / updated:** ${r.createdCompanies || 0} / ${r.updatedCompanies || 0}\n`;
|
|
619
|
+
text += `- **People created / updated:** ${r.createdPeople || 0} / ${r.updatedPeople || 0}\n`;
|
|
620
|
+
if (Array.isArray(r.errors) && r.errors.length > 0) {
|
|
621
|
+
text += '\n### Errors\n';
|
|
622
|
+
r.errors.slice(0, 5).forEach(e => {
|
|
623
|
+
text += `- ${typeof e === 'string' ? e : `Row ${e.row ?? '—'}: ${e.message || JSON.stringify(e)}`}\n`;
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
return md(text);
|
|
627
|
+
}
|
|
628
|
+
case 'export': {
|
|
629
|
+
const result = await api('POST', '/api/v1/bulk/export', {
|
|
630
|
+
body: {
|
|
631
|
+
entityType: args.entityType,
|
|
632
|
+
filters: args.filters,
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
const r = result?.data || result || {};
|
|
636
|
+
let text = `## Export Ready\n\n`;
|
|
637
|
+
text += `- **Type:** ${args.entityType}\n`;
|
|
638
|
+
text += `- **Records:** ${r.count || 0}\n`;
|
|
639
|
+
if (r.downloadUrl) text += `- **Download:** ${r.downloadUrl}\n`;
|
|
640
|
+
return md(text);
|
|
641
|
+
}
|
|
642
|
+
case 'quality_check': {
|
|
643
|
+
// Real data quality lives under /api/v1/data-quality. The hygiene
|
|
644
|
+
// rollup covers people records (score, freshness, worst offenders).
|
|
645
|
+
const result = await api('GET', '/api/v1/data-quality/hygiene');
|
|
646
|
+
const q = result?.data || result || {};
|
|
647
|
+
const summary = q.summary || {};
|
|
648
|
+
let text = `## Data Quality \u2014 People\n\n`;
|
|
649
|
+
if (args.entityType && args.entityType !== 'people') {
|
|
650
|
+
text += `> Note: the data-quality API currently reports on people records; "${args.entityType}" is not scoped separately.\n\n`;
|
|
651
|
+
}
|
|
652
|
+
if (summary.averageScore != null) {
|
|
653
|
+
text += `**Average Health Score:** ${summary.averageScore}/100\n`;
|
|
654
|
+
text += progressBar(summary.averageScore) + '\n\n';
|
|
655
|
+
}
|
|
656
|
+
if (summary.total != null) text += `- **Records scored:** ${summary.total}\n`;
|
|
657
|
+
const dist = summary.distribution || {};
|
|
658
|
+
for (const [bucket, info] of Object.entries(dist)) {
|
|
659
|
+
if (info?.count != null) text += `- **${bucket}** (${info.range}): ${info.count} (${info.coverage})\n`;
|
|
660
|
+
}
|
|
661
|
+
const fresh = summary.freshness || {};
|
|
662
|
+
if (fresh.stale != null) text += `- **Stale records (>${fresh.staleThresholdDays || 90}d):** ${fresh.stale}\n`;
|
|
663
|
+
if (fresh.neverEnriched != null) text += `- **Never enriched:** ${fresh.neverEnriched}\n`;
|
|
664
|
+
if (Array.isArray(q.worstOffenders) && q.worstOffenders.length > 0) {
|
|
665
|
+
text += `\n### Worst Offenders (lowest scores)\n`;
|
|
666
|
+
q.worstOffenders.slice(0, 5).forEach(w => {
|
|
667
|
+
text += `- ${w.name || `${w.firstName || ''} ${w.lastName || ''}`.trim() || w.id}: ${w.score}/100 (missing: ${(w.missingFields || []).join(', ') || '\u2014'})\n`;
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
return md(text);
|
|
671
|
+
}
|
|
672
|
+
case 'bulk_update': {
|
|
673
|
+
return mdError(
|
|
674
|
+
'bulk_update is not supported',
|
|
675
|
+
'The Adrata API has no filtered bulk-update endpoint yet. Update records individually (update_company, update_person, update_opportunity) or use export + import.'
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
case 'bulk_delete': {
|
|
679
|
+
return mdError(
|
|
680
|
+
'bulk_delete is not supported',
|
|
681
|
+
'The Adrata API has no filtered bulk-delete endpoint yet. Delete records individually (delete_company, delete_person, delete_opportunity).'
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
default:
|
|
685
|
+
return mdError(`Unknown action: ${args.action}`);
|
|
686
|
+
}
|
|
687
|
+
} catch (err) {
|
|
688
|
+
return mdError('Data operation failed', err.message);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Parse a CSV string into an array of row objects keyed by the header row.
|
|
696
|
+
* Handles quoted fields and escaped double-quotes.
|
|
697
|
+
*/
|
|
698
|
+
function parseCsvRecords(csvString) {
|
|
699
|
+
const lines = String(csvString).trim().split(/\r?\n/);
|
|
700
|
+
if (lines.length < 2) return [];
|
|
701
|
+
const headers = parseCsvLine(lines[0]).map(h => h.trim());
|
|
702
|
+
const records = [];
|
|
703
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
704
|
+
if (!lines[i].trim()) continue;
|
|
705
|
+
const values = parseCsvLine(lines[i]);
|
|
706
|
+
const record = {};
|
|
707
|
+
headers.forEach((h, idx) => {
|
|
708
|
+
const val = values[idx];
|
|
709
|
+
if (h && val !== undefined && val !== '') record[h] = val;
|
|
710
|
+
});
|
|
711
|
+
if (Object.keys(record).length > 0) records.push(record);
|
|
712
|
+
}
|
|
713
|
+
return records;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function parseCsvLine(line) {
|
|
717
|
+
const fields = [];
|
|
718
|
+
let current = '';
|
|
719
|
+
let inQuotes = false;
|
|
720
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
721
|
+
const ch = line[i];
|
|
722
|
+
if (ch === '"') {
|
|
723
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
724
|
+
current += '"';
|
|
725
|
+
i += 1;
|
|
726
|
+
} else {
|
|
727
|
+
inQuotes = !inQuotes;
|
|
728
|
+
}
|
|
729
|
+
} else if (ch === ',' && !inQuotes) {
|
|
730
|
+
fields.push(current);
|
|
731
|
+
current = '';
|
|
732
|
+
} else {
|
|
733
|
+
current += ch;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
fields.push(current);
|
|
737
|
+
return fields.map(f => f.trim());
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function actionColumnIdempotencyKey(payload) {
|
|
741
|
+
return [
|
|
742
|
+
'mcp-action-column',
|
|
743
|
+
stableToken(payload.provider),
|
|
744
|
+
stableToken(payload.tableId),
|
|
745
|
+
stableToken(payload.endpoint || 'default'),
|
|
746
|
+
stableToken(payload.columnName || ''),
|
|
747
|
+
].join('-');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function stableToken(value) {
|
|
751
|
+
const input = String(value || '');
|
|
752
|
+
let hash = 0;
|
|
753
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
754
|
+
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
|
|
755
|
+
}
|
|
756
|
+
return hash.toString(36);
|
|
757
|
+
}
|