@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,1131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extensibility Toolset
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the Adrata custom integration + extension catalog to Claude so it
|
|
5
|
+
* can inspect what's available, validate a draft manifest locally, and
|
|
6
|
+
* request a deployment without leaving the chat.
|
|
7
|
+
*
|
|
8
|
+
* Tools registered:
|
|
9
|
+
* - inspect_provider_catalog
|
|
10
|
+
* - get_provider_connect_plan
|
|
11
|
+
* - list_provider_endpoints
|
|
12
|
+
* - test_provider_credential
|
|
13
|
+
* - request_provider_action_execution
|
|
14
|
+
* - validate_integration_manifest
|
|
15
|
+
* - validate_extension_manifest
|
|
16
|
+
* - request_deployment
|
|
17
|
+
* - submit_adrata_command
|
|
18
|
+
*
|
|
19
|
+
* Validation logic mirrors `packages/adrata-cli/src/manifests.js` and
|
|
20
|
+
* `code/api/src/services/platform/integrations/custom_integrations/validator.rs`.
|
|
21
|
+
* Keep the three implementations in sync — the backend is the truth gate.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import { md, mdError } from '../output-formatter.js';
|
|
28
|
+
|
|
29
|
+
const VISIBILITY = new Set(['workspace_private', 'org_shared']);
|
|
30
|
+
const AUTH_TYPES = new Set(['api_key', 'bearer_token', 'oauth2', 'none']);
|
|
31
|
+
const FIELD_TYPES = new Set(['text', 'number', 'boolean', 'date', 'url', 'email', 'json']);
|
|
32
|
+
const ENTITY_TYPES = new Set(['company', 'person', 'opportunity']);
|
|
33
|
+
const TRIGGER_TYPES = new Set(['manual', 'webhook', 'schedule', 'record_change']);
|
|
34
|
+
const WORKFLOW_TRIGGER_TYPES = new Set(['manual', 'provider_event', 'schedule', 'record_change']);
|
|
35
|
+
const STEP_RISKS = new Set(['low', 'medium', 'high']);
|
|
36
|
+
const ALLOWED_TARGETS = new Set(['adrata_hosted', 'customer_runner', 'hybrid_runner']);
|
|
37
|
+
const COMMAND_MODES = new Set(['dry_run', 'execute']);
|
|
38
|
+
|
|
39
|
+
export const HEADLESS_OPERATION_POLICY = {
|
|
40
|
+
inspect_provider_catalog: {
|
|
41
|
+
permission: 'providers.inspect',
|
|
42
|
+
auditEvent: 'provider.catalog.inspected',
|
|
43
|
+
risk: 'low',
|
|
44
|
+
},
|
|
45
|
+
list_provider_endpoints: {
|
|
46
|
+
permission: 'providers.inspect',
|
|
47
|
+
auditEvent: 'provider.endpoints.inspected',
|
|
48
|
+
risk: 'low',
|
|
49
|
+
},
|
|
50
|
+
test_provider_credential: {
|
|
51
|
+
permission: 'providers.credentials.test',
|
|
52
|
+
auditEvent: 'provider.credential.test.requested',
|
|
53
|
+
risk: 'medium',
|
|
54
|
+
dryRunDefault: true,
|
|
55
|
+
},
|
|
56
|
+
request_provider_action_execution: {
|
|
57
|
+
permission: 'providers.actions.execute.request',
|
|
58
|
+
auditEvent: 'provider.action.execution.requested',
|
|
59
|
+
risk: 'high',
|
|
60
|
+
dryRunDefault: true,
|
|
61
|
+
approvalRequired: true,
|
|
62
|
+
},
|
|
63
|
+
request_deployment: {
|
|
64
|
+
permission: 'extensions.deploy.request',
|
|
65
|
+
auditEvent: 'extension_or_integration.deploy.requested',
|
|
66
|
+
risk: 'high',
|
|
67
|
+
policyPassRequired: true,
|
|
68
|
+
},
|
|
69
|
+
draft_workflow: {
|
|
70
|
+
permission: 'workflows.write',
|
|
71
|
+
auditEvent: 'workflow.drafted',
|
|
72
|
+
risk: 'low',
|
|
73
|
+
},
|
|
74
|
+
validate_workflow_draft: {
|
|
75
|
+
permission: 'workflows.validate',
|
|
76
|
+
auditEvent: 'workflow.validated',
|
|
77
|
+
risk: 'low',
|
|
78
|
+
},
|
|
79
|
+
dry_run_workflow: {
|
|
80
|
+
permission: 'workflows.dry_run',
|
|
81
|
+
auditEvent: 'workflow.dry_run.requested',
|
|
82
|
+
risk: 'medium',
|
|
83
|
+
},
|
|
84
|
+
request_workflow_deployment: {
|
|
85
|
+
permission: 'workflows.deploy.request',
|
|
86
|
+
auditEvent: 'workflow.deploy.requested',
|
|
87
|
+
risk: 'high',
|
|
88
|
+
policyPassRequired: true,
|
|
89
|
+
},
|
|
90
|
+
replay_workflow_run: {
|
|
91
|
+
permission: 'workflows.replay',
|
|
92
|
+
auditEvent: 'workflow.replay.requested',
|
|
93
|
+
risk: 'high',
|
|
94
|
+
dryRunDefault: true,
|
|
95
|
+
},
|
|
96
|
+
submit_adrata_command: {
|
|
97
|
+
permission: 'commands.submit',
|
|
98
|
+
auditEvent: 'command.submitted',
|
|
99
|
+
risk: 'high',
|
|
100
|
+
dryRunDefault: true,
|
|
101
|
+
policyPassRequired: true,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function register(server, api, AUTH) {
|
|
106
|
+
server.tool(
|
|
107
|
+
'inspect_provider_catalog',
|
|
108
|
+
'List the canonical integration provider catalog without loading provider endpoint tools into context. Use category/status/search to narrow before inspecting endpoints.',
|
|
109
|
+
{
|
|
110
|
+
includeCustom: z
|
|
111
|
+
.boolean()
|
|
112
|
+
.optional()
|
|
113
|
+
.describe('Deprecated compatibility flag; canonical catalog is returned by default'),
|
|
114
|
+
category: z.string().optional().describe('Optional category filter, for example Outbound'),
|
|
115
|
+
status: z.string().optional().describe('Optional status filter, for example CredentialOnly'),
|
|
116
|
+
search: z.string().optional().describe('Optional local search across provider name/id/category/runtime/auth'),
|
|
117
|
+
},
|
|
118
|
+
async (args) => {
|
|
119
|
+
try {
|
|
120
|
+
const result = await api('GET', '/api/v1/providers/catalog', {
|
|
121
|
+
params: {
|
|
122
|
+
category: args.category,
|
|
123
|
+
status: args.status,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
let providers = Array.isArray(result?.data?.providers)
|
|
127
|
+
? result.data.providers
|
|
128
|
+
: Array.isArray(result?.data)
|
|
129
|
+
? result.data
|
|
130
|
+
: Array.isArray(result)
|
|
131
|
+
? result
|
|
132
|
+
: [];
|
|
133
|
+
const needle = (args.search || '').toLowerCase();
|
|
134
|
+
if (needle) {
|
|
135
|
+
providers = providers.filter((p) =>
|
|
136
|
+
[p.id, p.name, p.category, p.status, p.runtime, p.auth]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join(' ')
|
|
139
|
+
.toLowerCase()
|
|
140
|
+
.includes(needle),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (providers.length === 0) {
|
|
144
|
+
return md('## Provider Catalog\n\nNo providers returned. Confirm your tier and workspace.\n');
|
|
145
|
+
}
|
|
146
|
+
let text = '## Provider Catalog\n\n| Slug | Name | Category | Status | Runtime | Auth | Headless | Risk | Approval |\n|------|------|----------|--------|---------|------|----------|------|----------|\n';
|
|
147
|
+
for (const p of providers) {
|
|
148
|
+
const policy = providerPolicySummary(p);
|
|
149
|
+
text += `| ${p.slug ?? p.id ?? '\u2014'} | ${p.name ?? '\u2014'} | ${p.category ?? '\u2014'} | ${p.status ?? '\u2014'} | ${p.runtime ?? '\u2014'} | ${p.auth ?? '\u2014'} | ${p.headless ? 'yes' : 'no'} | ${policy.risk} | ${policy.approval} |\n`;
|
|
150
|
+
}
|
|
151
|
+
text += `\n${providers.length} provider(s) listed. Use \`get_provider_connect_plan\` or \`list_provider_endpoints\` for one provider before connecting it.\n`;
|
|
152
|
+
return md(text);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return mdError('Could not fetch provider catalog', err.message);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
server.tool(
|
|
160
|
+
'get_provider_connect_plan',
|
|
161
|
+
'Get the exact app/API/MCP/CLI connection plan for one integration provider without enabling the infrastructure toolset.',
|
|
162
|
+
{
|
|
163
|
+
provider: z.string().describe('Provider slug, for example heyreach, salesforce, or hubspot'),
|
|
164
|
+
},
|
|
165
|
+
async (args) => {
|
|
166
|
+
try {
|
|
167
|
+
const result = await api(
|
|
168
|
+
'GET',
|
|
169
|
+
`/api/v1/providers/catalog/${encodeURIComponent(args.provider)}/connect`,
|
|
170
|
+
);
|
|
171
|
+
const data = result?.data || result || {};
|
|
172
|
+
const provider = data.provider || {};
|
|
173
|
+
const connect = data.connect || {};
|
|
174
|
+
let text = `## Connect Plan: ${provider.name || args.provider}\n\n`;
|
|
175
|
+
text += `- **Status:** ${provider.status || '\u2014'}\n`;
|
|
176
|
+
text += `- **Runtime:** ${provider.runtime || '\u2014'}\n`;
|
|
177
|
+
text += `- **Auth:** ${provider.auth || '\u2014'}\n`;
|
|
178
|
+
text += `- **Headless:** ${data.headless ? 'yes' : 'no'}\n`;
|
|
179
|
+
if (connect.path) {
|
|
180
|
+
text += `- **Method:** ${connect.method || '\u2014'}\n`;
|
|
181
|
+
text += `- **Path:** \`${connect.path}\`\n`;
|
|
182
|
+
if (connect.body) text += `- **Body:** \`${JSON.stringify(connect.body)}\`\n`;
|
|
183
|
+
}
|
|
184
|
+
if (data.runtimePlan) {
|
|
185
|
+
text += `- **Credential test:** \`${data.runtimePlan.credentialTestEndpoint}\`\n`;
|
|
186
|
+
text += `- **Promotion state:** ${data.runtimePlan.promotionState}\n`;
|
|
187
|
+
}
|
|
188
|
+
const policy = providerPolicySummary(provider);
|
|
189
|
+
text += `- **Risk:** ${policy.risk}\n`;
|
|
190
|
+
text += `- **Billing:** ${provider.billing || data.billing || '\u2014'}\n`;
|
|
191
|
+
text += `- **Approval:** ${policy.approval}\n`;
|
|
192
|
+
if (data.warning) text += `\n> ${data.warning}\n`;
|
|
193
|
+
return md(text);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return mdError('Connect plan failed', err.message);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
server.tool(
|
|
201
|
+
'list_provider_endpoints',
|
|
202
|
+
'Inspect a provider endpoint/action catalog before drafting a workflow. Returns available actions, auth mode, required scopes, rate-limit hints, and policy constraints when the registry exposes them.',
|
|
203
|
+
{
|
|
204
|
+
provider: z.string().describe('Provider slug, for example salesforce, hubspot, heyreach, or a custom provider slug'),
|
|
205
|
+
search: z.string().optional().describe('Optional endpoint search string'),
|
|
206
|
+
},
|
|
207
|
+
async (args) => {
|
|
208
|
+
try {
|
|
209
|
+
const result = await api(
|
|
210
|
+
'GET',
|
|
211
|
+
`/api/v1/providers/catalog/${encodeURIComponent(args.provider)}/endpoints`,
|
|
212
|
+
);
|
|
213
|
+
const data = result?.data || result || {};
|
|
214
|
+
const needle = (args.search || '').toLowerCase();
|
|
215
|
+
const endpoints = (data.endpoints || []).filter((endpoint) => {
|
|
216
|
+
if (!needle) return true;
|
|
217
|
+
return [
|
|
218
|
+
endpoint.method,
|
|
219
|
+
endpoint.path,
|
|
220
|
+
endpoint.slug,
|
|
221
|
+
endpoint.requiredScope,
|
|
222
|
+
endpoint.required_scope,
|
|
223
|
+
endpoint.description,
|
|
224
|
+
...(endpoint.tags || []),
|
|
225
|
+
]
|
|
226
|
+
.filter(Boolean)
|
|
227
|
+
.join(' ')
|
|
228
|
+
.toLowerCase()
|
|
229
|
+
.includes(needle);
|
|
230
|
+
});
|
|
231
|
+
let text = `## Provider Endpoints: ${data.provider?.name || args.provider} (${endpoints.length}/${data.total || 0})\n\n`;
|
|
232
|
+
if (data.runtimePlan) {
|
|
233
|
+
text += `Credential test: \`${data.runtimePlan.credentialTestEndpoint}\`\n`;
|
|
234
|
+
text += `Promotion state: \`${data.runtimePlan.promotionState}\`\n\n`;
|
|
235
|
+
}
|
|
236
|
+
if (endpoints.length === 0) {
|
|
237
|
+
text += 'No endpoints matched, or this provider exposes only a connection contract today.\n';
|
|
238
|
+
return md(text);
|
|
239
|
+
}
|
|
240
|
+
text += '| Method | Path | Slug | Scope | Auth | Runtime | Risk | Billing | Approval |\n|--------|------|------|-------|------|---------|------|---------|----------|\n';
|
|
241
|
+
for (const endpoint of endpoints) {
|
|
242
|
+
const policy = endpointPolicySummary(data.provider, endpoint);
|
|
243
|
+
text += `| ${endpoint.method} | ${endpoint.path} | ${endpoint.slug} | ${endpoint.requiredScope || endpoint.required_scope || '\u2014'} | ${endpoint.auth || data.provider?.auth || '\u2014'} | ${endpoint.runtimeState || data.provider?.runtime || '\u2014'} | ${policy.risk} | ${endpoint.billing || data.provider?.billing || '\u2014'} | ${policy.approval} |\n`;
|
|
244
|
+
}
|
|
245
|
+
return md(text);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return mdError('Could not fetch provider endpoints', err.message);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
server.tool(
|
|
253
|
+
'test_provider_credential',
|
|
254
|
+
'Run a policy/audit-mapped credential test for a provider. Defaults to dry-run so Claude Code can check shape and policy before touching live credentials.',
|
|
255
|
+
{
|
|
256
|
+
provider: z.string().describe('Provider slug'),
|
|
257
|
+
credentialRef: z.string().optional().describe('Stored credential reference to test'),
|
|
258
|
+
manifest: z.record(z.any()).optional().describe('Inline custom integration manifest for local/provider test planning'),
|
|
259
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false only for an authenticated live credential test.'),
|
|
260
|
+
},
|
|
261
|
+
async (args) => {
|
|
262
|
+
if (!args.credentialRef && !args.manifest) {
|
|
263
|
+
return mdError('Credential test requires credentialRef or manifest');
|
|
264
|
+
}
|
|
265
|
+
const payload = {
|
|
266
|
+
slug: args.provider,
|
|
267
|
+
credentialRef: args.credentialRef ?? null,
|
|
268
|
+
manifest: args.manifest ?? null,
|
|
269
|
+
dryRun: args.dryRun !== false,
|
|
270
|
+
policy: HEADLESS_OPERATION_POLICY.test_provider_credential,
|
|
271
|
+
};
|
|
272
|
+
if (payload.dryRun) {
|
|
273
|
+
return md(`## Credential Test Dry Run\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\`\n`);
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
// Real, mounted route: POST /api/v1/providers/test (providers_router,
|
|
277
|
+
// nested at /providers). The prior /platform/integrations/providers/
|
|
278
|
+
// {p}/credential-tests path was never routed server-side and 404'd.
|
|
279
|
+
const result = await api('POST', '/api/v1/providers/test', {
|
|
280
|
+
body: {
|
|
281
|
+
providerId: args.provider,
|
|
282
|
+
testData: {
|
|
283
|
+
credentialRef: args.credentialRef ?? null,
|
|
284
|
+
manifest: args.manifest ?? null,
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
return md(`## Credential Test Submitted\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\`\n`);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
return mdError('Credential test rejected by control plane', err.message);
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
server.tool(
|
|
296
|
+
'request_provider_action_execution',
|
|
297
|
+
'Request a scoped provider action execution without registering every provider endpoint as a separate MCP tool. Defaults to dry-run and returns policy, scope, billing, risk, and approval requirements before any live request.',
|
|
298
|
+
{
|
|
299
|
+
provider: z.string().describe('Provider slug, for example heyreach, salesforce, hubspot, gmail, or a custom provider slug'),
|
|
300
|
+
action: z.string().describe('Endpoint/action slug to request, for example campaigns_get_all or salesforce_contacts_update'),
|
|
301
|
+
scope: z.string().optional().describe('Expected scope for the action. If omitted, the endpoint catalog scope is used when available.'),
|
|
302
|
+
inputs: z.record(z.any()).optional().describe('Action input payload. Secrets should be credential refs, never raw credentials.'),
|
|
303
|
+
reason: z.string().optional().describe('Business reason shown in audit logs and approval review'),
|
|
304
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false only when the user has explicitly approved a live execution request.'),
|
|
305
|
+
},
|
|
306
|
+
async (args) => {
|
|
307
|
+
const dryRun = args.dryRun !== false;
|
|
308
|
+
const catalog = await loadProviderEndpointCatalog(api, args.provider);
|
|
309
|
+
const endpoint = catalog.endpoints.find((candidate) => candidate.slug === args.action) ?? null;
|
|
310
|
+
const policy = endpointPolicySummary(catalog.provider, endpoint, args.scope);
|
|
311
|
+
const payload = {
|
|
312
|
+
provider: args.provider,
|
|
313
|
+
action: args.action,
|
|
314
|
+
scope: args.scope || endpoint?.requiredScope || endpoint?.required_scope || null,
|
|
315
|
+
inputs: args.inputs ?? {},
|
|
316
|
+
reason: args.reason ?? null,
|
|
317
|
+
dryRun,
|
|
318
|
+
providerStatus: catalog.provider?.status ?? null,
|
|
319
|
+
providerRuntime: catalog.provider?.runtime ?? null,
|
|
320
|
+
auth: endpoint?.auth || catalog.provider?.auth || null,
|
|
321
|
+
billing: endpoint?.billing || catalog.provider?.billing || null,
|
|
322
|
+
policy: {
|
|
323
|
+
...HEADLESS_OPERATION_POLICY.request_provider_action_execution,
|
|
324
|
+
risk: policy.risk,
|
|
325
|
+
approval: policy.approval,
|
|
326
|
+
requiredScope: args.scope || endpoint?.requiredScope || endpoint?.required_scope || null,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const plan = renderProviderActionExecutionPlan(payload, endpoint, catalog.provider);
|
|
330
|
+
if (dryRun) return md(plan);
|
|
331
|
+
if (payload.policy.approval !== 'required') {
|
|
332
|
+
payload.policy.approval = 'required';
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
// Real, mounted route: POST /api/v1/integrations/{provider_id}/actions/
|
|
336
|
+
// {action_id} (actions_router merged into /integrations). It runs the
|
|
337
|
+
// governed fabric executor (approval gate + idempotency + audit). The
|
|
338
|
+
// prior .../providers/{p}/actions/{a}/execution-requests path did not
|
|
339
|
+
// exist server-side and 404'd. The handler treats the JSON body as the
|
|
340
|
+
// action payload and reads the idempotency key from the header.
|
|
341
|
+
const response = await api(
|
|
342
|
+
'POST',
|
|
343
|
+
`/api/v1/integrations/${encodeURIComponent(args.provider)}/actions/${encodeURIComponent(args.action)}`,
|
|
344
|
+
{
|
|
345
|
+
body: args.inputs ?? {},
|
|
346
|
+
headers: {
|
|
347
|
+
'Idempotency-Key': `mcp-${args.provider}-${args.action}-${Date.now()}`,
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
return md(`${plan}\n## Execution Request Submitted\n\n\`\`\`json\n${JSON.stringify(response, null, 2)}\n\`\`\`\n`);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
return mdError('Provider action execution rejected by control plane', err.message);
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
server.tool(
|
|
359
|
+
'validate_integration_manifest',
|
|
360
|
+
'Validate a custom integration manifest against the Adrata safety gates (auth, scopes, rate limits, test endpoint, redaction, idempotency, PII fields, docs URL). Accepts either a file path or an inline manifest object.',
|
|
361
|
+
{
|
|
362
|
+
path: z.string().optional().describe('Path to adrata.integration.json on disk'),
|
|
363
|
+
manifest: z
|
|
364
|
+
.record(z.any())
|
|
365
|
+
.optional()
|
|
366
|
+
.describe('Inline manifest JSON; takes precedence over path'),
|
|
367
|
+
},
|
|
368
|
+
async (args) => {
|
|
369
|
+
const manifest = loadManifestInput(args);
|
|
370
|
+
if (manifest.error) return mdError('Manifest unreadable', manifest.error);
|
|
371
|
+
const report = validateIntegrationManifest(manifest.value);
|
|
372
|
+
return renderValidation('Integration', report);
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
server.tool(
|
|
377
|
+
'validate_extension_manifest',
|
|
378
|
+
'Validate a custom extension manifest (custom tables, fields, views, record cards, workflow nodes, provider actions, permissions, retention). Accepts either a file path or an inline manifest object.',
|
|
379
|
+
{
|
|
380
|
+
path: z.string().optional().describe('Path to adrata.extension.json on disk'),
|
|
381
|
+
manifest: z
|
|
382
|
+
.record(z.any())
|
|
383
|
+
.optional()
|
|
384
|
+
.describe('Inline manifest JSON; takes precedence over path'),
|
|
385
|
+
},
|
|
386
|
+
async (args) => {
|
|
387
|
+
const manifest = loadManifestInput(args);
|
|
388
|
+
if (manifest.error) return mdError('Manifest unreadable', manifest.error);
|
|
389
|
+
const report = validateExtensionManifest(manifest.value);
|
|
390
|
+
return renderValidation('Extension', report);
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
server.tool(
|
|
395
|
+
'request_deployment',
|
|
396
|
+
'Request that the Adrata control plane deploy a custom integration or extension. The manifest is validated locally first; the request is only sent if validation passes. Activation stays gated on policy review.',
|
|
397
|
+
{
|
|
398
|
+
kind: z.enum(['integration', 'extension']).describe('What is being deployed'),
|
|
399
|
+
path: z.string().optional().describe('Path to the manifest on disk'),
|
|
400
|
+
manifest: z
|
|
401
|
+
.record(z.any())
|
|
402
|
+
.optional()
|
|
403
|
+
.describe('Inline manifest JSON; takes precedence over path'),
|
|
404
|
+
target: z
|
|
405
|
+
.enum(['adrata_hosted', 'customer_runner', 'hybrid_runner'])
|
|
406
|
+
.optional()
|
|
407
|
+
.describe('Deployment target (default: adrata_hosted)'),
|
|
408
|
+
environment: z
|
|
409
|
+
.enum(['staging', 'production'])
|
|
410
|
+
.optional()
|
|
411
|
+
.describe('Deployment environment. Production requires customer_runner or hybrid_runner.'),
|
|
412
|
+
notes: z.string().optional().describe('Notes shown to the reviewer'),
|
|
413
|
+
},
|
|
414
|
+
async (args) => {
|
|
415
|
+
const target = args.target ?? 'adrata_hosted';
|
|
416
|
+
const environment = args.environment ?? 'staging';
|
|
417
|
+
if (!ALLOWED_TARGETS.has(target)) {
|
|
418
|
+
return mdError(`Unsupported target: ${target}`);
|
|
419
|
+
}
|
|
420
|
+
if (environment === 'production' && target === 'adrata_hosted') {
|
|
421
|
+
return mdError('Production deployments must target customer_runner or hybrid_runner');
|
|
422
|
+
}
|
|
423
|
+
const manifest = loadManifestInput(args);
|
|
424
|
+
if (manifest.error) return mdError('Manifest unreadable', manifest.error);
|
|
425
|
+
const report =
|
|
426
|
+
args.kind === 'integration'
|
|
427
|
+
? validateIntegrationManifest(manifest.value)
|
|
428
|
+
: validateExtensionManifest(manifest.value);
|
|
429
|
+
if (!report.ok) {
|
|
430
|
+
return renderValidation(args.kind === 'integration' ? 'Integration' : 'Extension', report);
|
|
431
|
+
}
|
|
432
|
+
const payload = {
|
|
433
|
+
slug: manifest.value.slug,
|
|
434
|
+
target,
|
|
435
|
+
environment,
|
|
436
|
+
notes: args.notes ?? null,
|
|
437
|
+
manifest: manifest.value,
|
|
438
|
+
validation: report,
|
|
439
|
+
policy: HEADLESS_OPERATION_POLICY.request_deployment,
|
|
440
|
+
};
|
|
441
|
+
const route =
|
|
442
|
+
args.kind === 'integration'
|
|
443
|
+
? '/api/v1/custom-integrations/deploy-requests'
|
|
444
|
+
: '/api/v1/extensions/deploy-requests';
|
|
445
|
+
try {
|
|
446
|
+
const response = await api('POST', route, { body: payload });
|
|
447
|
+
let text = `## Deploy request submitted\n\nSlug: \`${payload.slug}\`\nTarget: \`${target}\`\nEnvironment: \`${environment}\`\nKind: \`${args.kind}\`\n`;
|
|
448
|
+
if (response?.id) text += `Request ID: \`${response.id}\`\n`;
|
|
449
|
+
if (response?.status) text += `Status: \`${response.status}\`\n`;
|
|
450
|
+
text += '\nActivation requires policy/validation approval before runtime adapters mount the manifest.\n';
|
|
451
|
+
return md(text);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
return mdError('Deploy request rejected by control plane', err.message);
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
server.tool(
|
|
459
|
+
'draft_workflow',
|
|
460
|
+
'Create an Adrata workflow draft manifest that can be saved as adrata.workflow.json, validated, dry-run, deployed, and replayed without opening the app.',
|
|
461
|
+
{
|
|
462
|
+
slug: z.string().describe('Workflow slug in kebab-case'),
|
|
463
|
+
provider: z.string().optional().describe('Provider slug to prefill in the starter step'),
|
|
464
|
+
action: z.string().optional().describe('Provider action to prefill in the starter step'),
|
|
465
|
+
},
|
|
466
|
+
async (args) => {
|
|
467
|
+
try {
|
|
468
|
+
const workflow = createWorkflowDraft(args.slug, {
|
|
469
|
+
provider: args.provider,
|
|
470
|
+
action: args.action,
|
|
471
|
+
});
|
|
472
|
+
return md(`## Workflow Draft\n\nSave this as \`adrata.workflow.json\`.\n\n\`\`\`json\n${JSON.stringify(workflow, null, 2)}\n\`\`\`\n`);
|
|
473
|
+
} catch (err) {
|
|
474
|
+
return mdError('Could not draft workflow', err.message);
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
server.tool(
|
|
480
|
+
'validate_workflow_draft',
|
|
481
|
+
'Validate an Adrata workflow draft. Ensures every step has permissions, audit event mapping, policy risk, and deploy requires a dry-run.',
|
|
482
|
+
{
|
|
483
|
+
path: z.string().optional().describe('Path to adrata.workflow.json on disk'),
|
|
484
|
+
workflow: z.record(z.any()).optional().describe('Inline workflow JSON; takes precedence over path'),
|
|
485
|
+
},
|
|
486
|
+
async (args) => {
|
|
487
|
+
const workflow = loadWorkflowInput(args);
|
|
488
|
+
if (workflow.error) return mdError('Workflow unreadable', workflow.error);
|
|
489
|
+
const report = validateWorkflowDraft(workflow.value);
|
|
490
|
+
return renderValidation('Workflow Draft', report);
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
server.tool(
|
|
495
|
+
'dry_run_workflow',
|
|
496
|
+
'Dry-run a workflow draft through the control plane. If the API is unavailable, returns a local policy/audit execution plan for review.',
|
|
497
|
+
{
|
|
498
|
+
path: z.string().optional().describe('Path to adrata.workflow.json on disk'),
|
|
499
|
+
workflow: z.record(z.any()).optional().describe('Inline workflow JSON; takes precedence over path'),
|
|
500
|
+
inputs: z.record(z.any()).optional().describe('Dry-run input fixture'),
|
|
501
|
+
notes: z.string().optional(),
|
|
502
|
+
},
|
|
503
|
+
async (args) => {
|
|
504
|
+
const workflow = loadWorkflowInput(args);
|
|
505
|
+
if (workflow.error) return mdError('Workflow unreadable', workflow.error);
|
|
506
|
+
const report = validateWorkflowDraft(workflow.value);
|
|
507
|
+
if (!report.ok) return renderValidation('Workflow Draft', report);
|
|
508
|
+
// The control-plane dry-run is the stateless graph linter on the
|
|
509
|
+
// workflow runtime: POST /api/v1/workflow-runtime/graphs/validate.
|
|
510
|
+
const graph = workflowGraphFromDraft(workflow.value);
|
|
511
|
+
try {
|
|
512
|
+
const response = await api('POST', '/api/v1/workflow-runtime/graphs/validate', {
|
|
513
|
+
body: { graph },
|
|
514
|
+
});
|
|
515
|
+
const serverReport = response?.data || response || {};
|
|
516
|
+
let text = `## Workflow Dry Run\n\nSlug: \`${workflow.value.slug || '—'}\`\n`;
|
|
517
|
+
text += `Server validation: ${serverReport.ok ? '✅ passed' : '❌ failed'}\n`;
|
|
518
|
+
if (serverReport.triggerNodeId) text += `Trigger node: \`${serverReport.triggerNodeId}\`\n`;
|
|
519
|
+
if (serverReport.nodeCount != null) text += `Nodes: ${serverReport.nodeCount} | Edges: ${serverReport.edgeCount ?? 0}\n`;
|
|
520
|
+
if (Array.isArray(serverReport.errors) && serverReport.errors.length > 0) {
|
|
521
|
+
text += '\n### Control-plane errors\n';
|
|
522
|
+
for (const e of serverReport.errors) text += `- ${e}\n`;
|
|
523
|
+
}
|
|
524
|
+
text += `\nInputs: \`${JSON.stringify(args.inputs || {})}\`\n`;
|
|
525
|
+
if (args.notes) text += `Notes: ${args.notes}\n`;
|
|
526
|
+
text += '\nThe graph linter checks structure only. Provider action existence and scope grants are enforced at deploy time.\n';
|
|
527
|
+
return md(text);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
return mdError('Workflow dry-run rejected by control plane', err.message);
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
server.tool(
|
|
535
|
+
'request_workflow_deployment',
|
|
536
|
+
'Request hosted deployment for a workflow. Requires a prior dry-run id and keeps activation gated on policy pass and audit logging.',
|
|
537
|
+
{
|
|
538
|
+
path: z.string().optional().describe('Path to adrata.workflow.json on disk'),
|
|
539
|
+
workflow: z.record(z.any()).optional().describe('Inline workflow JSON; takes precedence over path'),
|
|
540
|
+
dryRunId: z.string().describe('Dry-run id returned by dry_run_workflow'),
|
|
541
|
+
target: z.enum(['adrata_hosted', 'customer_runner', 'hybrid_runner']).optional(),
|
|
542
|
+
notes: z.string().optional(),
|
|
543
|
+
},
|
|
544
|
+
async (args) => {
|
|
545
|
+
const workflow = loadWorkflowInput(args);
|
|
546
|
+
if (workflow.error) return mdError('Workflow unreadable', workflow.error);
|
|
547
|
+
const report = validateWorkflowDraft(workflow.value);
|
|
548
|
+
if (!report.ok) return renderValidation('Workflow Draft', report);
|
|
549
|
+
// Hosted deployment on the workflow runtime is definition -> version ->
|
|
550
|
+
// deploy. There is no single deploy-request route, so walk the chain.
|
|
551
|
+
const graph = workflowGraphFromDraft(workflow.value);
|
|
552
|
+
try {
|
|
553
|
+
const definitionResponse = await api('POST', '/api/v1/workflow-runtime/definitions', {
|
|
554
|
+
body: {
|
|
555
|
+
name: workflow.value.name || workflow.value.slug,
|
|
556
|
+
description: workflow.value.description ?? null,
|
|
557
|
+
triggerType: workflow.value.trigger?.type ?? 'manual',
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
const definitionId = (definitionResponse?.data || definitionResponse || {}).id;
|
|
561
|
+
if (!definitionId) {
|
|
562
|
+
return mdError('Deploy request rejected by control plane', 'The control plane did not return a workflow definition id.');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const versionResponse = await api(
|
|
566
|
+
'POST',
|
|
567
|
+
`/api/v1/workflow-runtime/definitions/${encodeURIComponent(definitionId)}/versions`,
|
|
568
|
+
{ body: { graph } },
|
|
569
|
+
);
|
|
570
|
+
const version = versionResponse?.data || versionResponse || {};
|
|
571
|
+
const versionId = version.id;
|
|
572
|
+
if (!versionId) {
|
|
573
|
+
return mdError('Deploy request rejected by control plane', 'The control plane did not return a workflow version id.');
|
|
574
|
+
}
|
|
575
|
+
if (version.validation && version.validation.ok === false) {
|
|
576
|
+
let text = `## Workflow version not deployable\n\nDefinition: \`${definitionId}\`\nVersion: \`${versionId}\`\n\n### Control-plane errors\n`;
|
|
577
|
+
for (const e of version.validation.errors || []) text += `- ${e}\n`;
|
|
578
|
+
return md(text);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const deployResponse = await api(
|
|
582
|
+
'POST',
|
|
583
|
+
`/api/v1/workflow-runtime/definitions/${encodeURIComponent(definitionId)}/versions/${encodeURIComponent(versionId)}/deploy`,
|
|
584
|
+
);
|
|
585
|
+
const deployed = deployResponse?.data || deployResponse || {};
|
|
586
|
+
let text = `## Workflow deployment requested\n\n`;
|
|
587
|
+
text += `Slug: \`${workflow.value.slug || '—'}\`\n`;
|
|
588
|
+
text += `Definition: \`${definitionId}\`\n`;
|
|
589
|
+
text += `Version: \`${versionId}\`\n`;
|
|
590
|
+
text += `Status: \`${deployed.status || 'deployed'}\`\n`;
|
|
591
|
+
text += `Dry-run id: \`${args.dryRunId}\`\n`;
|
|
592
|
+
if (args.target) text += `Target: \`${args.target}\`\n`;
|
|
593
|
+
if (args.notes) text += `Notes: ${args.notes}\n`;
|
|
594
|
+
text += '\nActivation stays gated on policy pass and audit logging.\n';
|
|
595
|
+
return md(text);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
return mdError('Deploy request rejected by control plane', err.message);
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
server.tool(
|
|
603
|
+
'replay_workflow_run',
|
|
604
|
+
'Replay or plan replay for a workflow run. Defaults to dry-run; live replay remains policy/audit gated.',
|
|
605
|
+
{
|
|
606
|
+
runId: z.string().describe('Workflow run id'),
|
|
607
|
+
reason: z.string().optional().describe('Why replay is being requested'),
|
|
608
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false only for a live replay request.'),
|
|
609
|
+
},
|
|
610
|
+
async (args) => {
|
|
611
|
+
// No replay endpoint exists on the workflow runtime. Fail fast with the
|
|
612
|
+
// supported alternatives instead of issuing a request that cannot route.
|
|
613
|
+
return mdError(
|
|
614
|
+
'Workflow replay is not supported in this API version',
|
|
615
|
+
`The Adrata workflow runtime exposes no replay route (requested run: ${args.runId}). Runs are read-only: inspect with GET /api/v1/workflow-runtime/runs/${args.runId}, then start a fresh run via POST /api/v1/workflow-runtime/definitions/{definition_id}/runs.`
|
|
616
|
+
);
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
server.tool(
|
|
621
|
+
'submit_adrata_command',
|
|
622
|
+
'Submit a governed Adrata Cloud command through the same command plane used by the app and CLI. Defaults to dry-run preview. Use execute only when the user asked for a write and you have an idempotency key; high-risk writes require approval.',
|
|
623
|
+
{
|
|
624
|
+
commandType: z
|
|
625
|
+
.string()
|
|
626
|
+
.describe(
|
|
627
|
+
'Command type such as path_to_power, research_account, update_buyer_group, request_intro, move_person_to_lead, create_next_action, or sync_crm',
|
|
628
|
+
),
|
|
629
|
+
mode: z.enum(['dry_run', 'execute']).optional().describe('Defaults to dry_run. execute queues the command if policy allows it.'),
|
|
630
|
+
payload: z.record(z.any()).optional().describe('Command-specific target, record ids, changes, and context.'),
|
|
631
|
+
commandId: z.string().optional().describe('Optional caller-provided command id.'),
|
|
632
|
+
idempotencyKey: z.string().optional().describe('Required for safe execute retries; generated by the API if omitted.'),
|
|
633
|
+
approvalId: z.string().optional().describe('Approval id for high-risk execute commands.'),
|
|
634
|
+
reason: z.string().optional().describe('Human-readable reason for audit and approval review.'),
|
|
635
|
+
},
|
|
636
|
+
async (args) => {
|
|
637
|
+
const command = buildCommandPayload(args);
|
|
638
|
+
if (command.error) return mdError('Command invalid', command.error);
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
const result = await api('POST', '/api/v1/platform/commands', {
|
|
642
|
+
body: command.payload,
|
|
643
|
+
});
|
|
644
|
+
return md(`## Adrata Command Submitted\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\`\n`);
|
|
645
|
+
} catch (err) {
|
|
646
|
+
if (command.payload.mode === 'dry_run') {
|
|
647
|
+
return md(`## Adrata Command Local Preview\n\nThe command plane was unavailable, so this is a local dry-run preview. Re-run when connected to Adrata Cloud for policy, audit, rollback, and token usage metadata.\n\n\`\`\`json\n${JSON.stringify(localCommandPreview(command.payload), null, 2)}\n\`\`\`\n`);
|
|
648
|
+
}
|
|
649
|
+
return mdError('Adrata command rejected by control plane', err.message);
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function loadProviderEndpointCatalog(api, provider) {
|
|
656
|
+
try {
|
|
657
|
+
const result = await api(
|
|
658
|
+
'GET',
|
|
659
|
+
`/api/v1/providers/catalog/${encodeURIComponent(provider)}/endpoints`,
|
|
660
|
+
);
|
|
661
|
+
const data = result?.data || result || {};
|
|
662
|
+
return {
|
|
663
|
+
provider: data.provider || {},
|
|
664
|
+
endpoints: Array.isArray(data.endpoints) ? data.endpoints : [],
|
|
665
|
+
runtimePlan: data.runtimePlan || null,
|
|
666
|
+
};
|
|
667
|
+
} catch {
|
|
668
|
+
return { provider: {}, endpoints: [], runtimePlan: null };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function providerPolicySummary(provider = {}) {
|
|
673
|
+
const runtime = String(provider.runtime || provider.runtimeState || '').toLowerCase();
|
|
674
|
+
const status = String(provider.status || '').toLowerCase();
|
|
675
|
+
if (runtime.includes('customer') || status === 'live') {
|
|
676
|
+
return { risk: 'medium', approval: 'required for writes/external sends' };
|
|
677
|
+
}
|
|
678
|
+
if (runtime.includes('staged') || status === 'beta') {
|
|
679
|
+
return { risk: 'medium', approval: 'required; staged adapter review' };
|
|
680
|
+
}
|
|
681
|
+
return { risk: 'high', approval: 'blocked until provider promotion' };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function endpointPolicySummary(provider = {}, endpoint = {}, requestedScope = null) {
|
|
685
|
+
const scope = String(
|
|
686
|
+
requestedScope || endpoint?.requiredScope || endpoint?.required_scope || endpoint?.scope || '',
|
|
687
|
+
).toLowerCase();
|
|
688
|
+
const runtime = String(endpoint?.runtimeState || provider?.runtime || '').toLowerCase();
|
|
689
|
+
if (runtime.includes('registry') || runtime.includes('roadmap')) {
|
|
690
|
+
return { risk: 'high', approval: 'blocked until provider promotion' };
|
|
691
|
+
}
|
|
692
|
+
if (
|
|
693
|
+
scope.includes('write') ||
|
|
694
|
+
scope.includes('send') ||
|
|
695
|
+
scope.includes('enroll') ||
|
|
696
|
+
scope.includes('webhook') ||
|
|
697
|
+
scope.includes('external')
|
|
698
|
+
) {
|
|
699
|
+
return { risk: 'high', approval: 'required' };
|
|
700
|
+
}
|
|
701
|
+
return providerPolicySummary(provider);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function renderProviderActionExecutionPlan(payload, endpoint, provider) {
|
|
705
|
+
const providerName = provider?.name || payload.provider;
|
|
706
|
+
const actionLabel = endpoint?.slug || payload.action;
|
|
707
|
+
let text = `## Provider Action Execution ${payload.dryRun ? 'Dry Run' : 'Request'}\n\n`;
|
|
708
|
+
text += `- **Provider:** ${providerName} (\`${payload.provider}\`)\n`;
|
|
709
|
+
text += `- **Action:** \`${actionLabel}\`\n`;
|
|
710
|
+
text += `- **Scope:** ${payload.scope || '\u2014'}\n`;
|
|
711
|
+
text += `- **Auth:** ${payload.auth || '\u2014'}\n`;
|
|
712
|
+
text += `- **Runtime:** ${payload.providerRuntime || endpoint?.runtimeState || '\u2014'}\n`;
|
|
713
|
+
text += `- **Status:** ${payload.providerStatus || '\u2014'}\n`;
|
|
714
|
+
text += `- **Billing:** ${payload.billing || '\u2014'}\n`;
|
|
715
|
+
text += `- **Risk:** ${payload.policy.risk}\n`;
|
|
716
|
+
text += `- **Approval:** ${payload.policy.approval}\n`;
|
|
717
|
+
text += `- **Audit event:** ${payload.policy.auditEvent}\n`;
|
|
718
|
+
if (endpoint?.method || endpoint?.path) {
|
|
719
|
+
text += `- **Endpoint:** ${endpoint.method || '\u2014'} \`${endpoint.path || '\u2014'}\`\n`;
|
|
720
|
+
}
|
|
721
|
+
text += '\n```json\n';
|
|
722
|
+
text += `${JSON.stringify(payload, null, 2)}\n`;
|
|
723
|
+
text += '```\n';
|
|
724
|
+
if (payload.dryRun) {
|
|
725
|
+
text += '\nSet `dryRun: false` only after the user approves the scoped action and inputs.\n';
|
|
726
|
+
}
|
|
727
|
+
return text;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function loadManifestInput({ path, manifest }) {
|
|
731
|
+
if (manifest && typeof manifest === 'object') {
|
|
732
|
+
return { value: manifest };
|
|
733
|
+
}
|
|
734
|
+
if (typeof path === 'string' && path.length > 0) {
|
|
735
|
+
try {
|
|
736
|
+
const absolute = resolve(process.cwd(), path);
|
|
737
|
+
const text = readFileSync(absolute, 'utf8');
|
|
738
|
+
return { value: JSON.parse(text) };
|
|
739
|
+
} catch (err) {
|
|
740
|
+
return { error: err.message };
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return { error: 'Provide either path or manifest' };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function renderValidation(label, report) {
|
|
747
|
+
if (report.ok) {
|
|
748
|
+
return md(`## ${label} Manifest \u2014 Valid\n\nNo violations. Manifest is ready for \`request_deployment\`.\n`);
|
|
749
|
+
}
|
|
750
|
+
let text = `## ${label} Manifest \u2014 ${report.errors.length} violation(s)\n\n`;
|
|
751
|
+
for (const err of report.errors) text += `- ${err}\n`;
|
|
752
|
+
text += '\nFix locally and re-run validation before requesting deployment.\n';
|
|
753
|
+
return md(text);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function loadWorkflowInput({ path, workflow }) {
|
|
757
|
+
return loadManifestInput({ path, manifest: workflow });
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Project an adrata.workflow draft manifest onto the nodes/edges graph shape
|
|
762
|
+
* the workflow runtime validates (`{ nodes: [{ id, type }], edges: [{ from, to }] }`).
|
|
763
|
+
* A manifest that already carries an explicit graph is passed through.
|
|
764
|
+
*/
|
|
765
|
+
function workflowGraphFromDraft(workflow = {}) {
|
|
766
|
+
if (workflow.graph && Array.isArray(workflow.graph.nodes)) return workflow.graph;
|
|
767
|
+
|
|
768
|
+
const triggerType = workflow.trigger?.type || 'manual';
|
|
769
|
+
const triggerId = workflow.trigger?.id || '__trigger';
|
|
770
|
+
const nodes = [
|
|
771
|
+
{
|
|
772
|
+
id: triggerId,
|
|
773
|
+
type: `trigger.${triggerType}`,
|
|
774
|
+
config: workflow.trigger?.config || {},
|
|
775
|
+
},
|
|
776
|
+
];
|
|
777
|
+
const edges = [];
|
|
778
|
+
|
|
779
|
+
let previousId = triggerId;
|
|
780
|
+
for (const step of Array.isArray(workflow.steps) ? workflow.steps : []) {
|
|
781
|
+
const [providerId, action] = String(step.uses || '').split('.');
|
|
782
|
+
nodes.push({
|
|
783
|
+
id: step.id,
|
|
784
|
+
type: step.type || 'provider.action',
|
|
785
|
+
providerId: providerId || undefined,
|
|
786
|
+
action: action || undefined,
|
|
787
|
+
config: step.input || {},
|
|
788
|
+
});
|
|
789
|
+
edges.push({ from: previousId, to: step.id });
|
|
790
|
+
previousId = step.id;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return { nodes, edges };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function createWorkflowDraft(slug, options = {}) {
|
|
797
|
+
if (!slug || !/^[a-z][a-z0-9-]*$/.test(slug)) {
|
|
798
|
+
throw new Error('slug must be kebab-case and start with a letter');
|
|
799
|
+
}
|
|
800
|
+
const provider = options.provider ?? 'replace-provider-slug';
|
|
801
|
+
const action = options.action ?? 'replace_action';
|
|
802
|
+
return {
|
|
803
|
+
schemaVersion: 1,
|
|
804
|
+
kind: 'adrata.workflow',
|
|
805
|
+
slug,
|
|
806
|
+
name: titleize(slug),
|
|
807
|
+
description: 'Describe the GTM outcome this workflow should produce.',
|
|
808
|
+
trigger: { type: 'manual' },
|
|
809
|
+
permissions: ['providers.inspect', 'workflows.dry_run', 'workflows.deploy.request'],
|
|
810
|
+
policy: {
|
|
811
|
+
dryRunRequired: true,
|
|
812
|
+
approvalRequired: true,
|
|
813
|
+
auditEventPrefix: `workflow.${slug}`,
|
|
814
|
+
maxEstimatedCostUsd: 5,
|
|
815
|
+
},
|
|
816
|
+
audit: {
|
|
817
|
+
redactFields: ['apiKey', 'token', 'authorization', 'emailBody'],
|
|
818
|
+
includeInputs: true,
|
|
819
|
+
includeOutputs: false,
|
|
820
|
+
},
|
|
821
|
+
replay: {
|
|
822
|
+
enabled: true,
|
|
823
|
+
retentionDays: 30,
|
|
824
|
+
},
|
|
825
|
+
steps: [
|
|
826
|
+
{
|
|
827
|
+
id: 'run_provider_action',
|
|
828
|
+
name: 'Run provider action',
|
|
829
|
+
uses: `${provider}.${action}`,
|
|
830
|
+
input: {},
|
|
831
|
+
permissions: ['providers.actions.execute'],
|
|
832
|
+
policy: {
|
|
833
|
+
risk: 'medium',
|
|
834
|
+
auditEvent: `workflow.${slug}.run_provider_action`,
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
],
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function validateWorkflowDraft(workflow) {
|
|
842
|
+
const errors = [];
|
|
843
|
+
if (!workflow || typeof workflow !== 'object') {
|
|
844
|
+
errors.push('workflow must be an object');
|
|
845
|
+
return { ok: false, errors };
|
|
846
|
+
}
|
|
847
|
+
if (workflow.schemaVersion !== 1) errors.push('schemaVersion must be 1');
|
|
848
|
+
if (workflow.kind !== 'adrata.workflow') errors.push('kind must be adrata.workflow');
|
|
849
|
+
if (!workflow.slug || !/^[a-z][a-z0-9-]*$/.test(workflow.slug)) {
|
|
850
|
+
errors.push('slug must be kebab-case and start with a letter');
|
|
851
|
+
}
|
|
852
|
+
if (!workflow.name) errors.push('name is required');
|
|
853
|
+
if (!workflow.trigger || !WORKFLOW_TRIGGER_TYPES.has(workflow.trigger.type)) {
|
|
854
|
+
errors.push('trigger.type must be manual, provider_event, schedule, or record_change');
|
|
855
|
+
}
|
|
856
|
+
if (!Array.isArray(workflow.permissions) || workflow.permissions.length === 0) {
|
|
857
|
+
errors.push('permissions must contain at least one workflow permission');
|
|
858
|
+
}
|
|
859
|
+
if (workflow.policy?.dryRunRequired !== true) {
|
|
860
|
+
errors.push('policy.dryRunRequired must be true for deployable workflows');
|
|
861
|
+
}
|
|
862
|
+
if (typeof workflow.policy?.approvalRequired !== 'boolean') {
|
|
863
|
+
errors.push('policy.approvalRequired must be boolean');
|
|
864
|
+
}
|
|
865
|
+
if (!workflow.policy?.auditEventPrefix) errors.push('policy.auditEventPrefix is required');
|
|
866
|
+
if (!Array.isArray(workflow.audit?.redactFields)) {
|
|
867
|
+
errors.push('audit.redactFields must list sensitive input/output fields');
|
|
868
|
+
}
|
|
869
|
+
if (typeof workflow.audit?.includeInputs !== 'boolean') errors.push('audit.includeInputs must be boolean');
|
|
870
|
+
if (typeof workflow.audit?.includeOutputs !== 'boolean') errors.push('audit.includeOutputs must be boolean');
|
|
871
|
+
if (typeof workflow.replay?.enabled !== 'boolean' || !positiveInt(workflow.replay?.retentionDays)) {
|
|
872
|
+
errors.push('replay.enabled must be boolean and replay.retentionDays must be positive');
|
|
873
|
+
}
|
|
874
|
+
if (!Array.isArray(workflow.steps) || workflow.steps.length === 0) {
|
|
875
|
+
errors.push('steps must contain at least one step');
|
|
876
|
+
} else {
|
|
877
|
+
workflow.steps.forEach((step, i) => {
|
|
878
|
+
if (!step?.id || !/^[a-z][a-z0-9_]*$/.test(step.id)) errors.push(`steps[${i}].id must be snake_case`);
|
|
879
|
+
if (!step?.name) errors.push(`steps[${i}].name is required`);
|
|
880
|
+
if (!step?.uses && !step?.action) errors.push(`steps[${i}].uses or action is required`);
|
|
881
|
+
if (!Array.isArray(step?.permissions) || step.permissions.length === 0) {
|
|
882
|
+
errors.push(`steps[${i}].permissions must contain at least one permission`);
|
|
883
|
+
}
|
|
884
|
+
if (!step?.policy?.auditEvent) errors.push(`steps[${i}].policy.auditEvent is required`);
|
|
885
|
+
if (!STEP_RISKS.has(step?.policy?.risk)) errors.push(`steps[${i}].policy.risk must be low, medium, or high`);
|
|
886
|
+
if (step?.policy?.risk === 'high' && step.policy.approvalRequired !== true) {
|
|
887
|
+
errors.push(`steps[${i}].policy.approvalRequired must be true for high-risk steps`);
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
return { ok: errors.length === 0, errors };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function buildCommandPayload(args) {
|
|
895
|
+
const commandType = normalizeCommandType(args.commandType);
|
|
896
|
+
if (!commandType) return { error: 'commandType is required' };
|
|
897
|
+
const mode = normalizeCommandMode(args.mode);
|
|
898
|
+
if (!mode) return { error: 'mode must be dry_run or execute' };
|
|
899
|
+
const payload = args.payload ?? {};
|
|
900
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
901
|
+
return { error: 'payload must be a JSON object' };
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
payload: {
|
|
905
|
+
commandType,
|
|
906
|
+
mode,
|
|
907
|
+
payload,
|
|
908
|
+
...(cleanOptional(args.commandId) ? { commandId: cleanOptional(args.commandId) } : {}),
|
|
909
|
+
...(cleanOptional(args.idempotencyKey) ? { idempotencyKey: cleanOptional(args.idempotencyKey) } : {}),
|
|
910
|
+
...(cleanOptional(args.approvalId) ? { approvalId: cleanOptional(args.approvalId) } : {}),
|
|
911
|
+
...(cleanOptional(args.reason) ? { reason: cleanOptional(args.reason) } : {}),
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function normalizeCommandType(value) {
|
|
917
|
+
const normalized = cleanOptional(value)?.replaceAll('-', '_').replaceAll(' ', '_').toLowerCase();
|
|
918
|
+
if (!normalized || normalized.length > 96 || !/^[a-z0-9_.]+$/.test(normalized)) return null;
|
|
919
|
+
return normalized;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function normalizeCommandMode(value) {
|
|
923
|
+
const normalized = String(value ?? 'dry_run').replace('-', '_');
|
|
924
|
+
return COMMAND_MODES.has(normalized) ? normalized : null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function localCommandPreview(payload) {
|
|
928
|
+
const risk = commandRisk(payload.commandType);
|
|
929
|
+
const requiresApproval = payload.mode === 'execute' && risk === 'high' && !payload.approvalId;
|
|
930
|
+
return {
|
|
931
|
+
success: true,
|
|
932
|
+
simulated: true,
|
|
933
|
+
data: {
|
|
934
|
+
commandType: payload.commandType,
|
|
935
|
+
mode: payload.mode,
|
|
936
|
+
dryRun: payload.mode === 'dry_run',
|
|
937
|
+
status: requiresApproval ? 'requires_approval' : 'planned',
|
|
938
|
+
policy: {
|
|
939
|
+
decision: requiresApproval ? 'requires_approval' : 'allow',
|
|
940
|
+
reason: requiresApproval
|
|
941
|
+
? 'High-risk write commands require approval before execution'
|
|
942
|
+
: 'Local dry-run preview; Adrata Cloud will make the final policy decision',
|
|
943
|
+
requiredApproval: risk === 'high',
|
|
944
|
+
risk,
|
|
945
|
+
},
|
|
946
|
+
audit: {
|
|
947
|
+
auditEvent: HEADLESS_OPERATION_POLICY.submit_adrata_command.auditEvent,
|
|
948
|
+
idempotencyKey: payload.idempotencyKey ?? null,
|
|
949
|
+
},
|
|
950
|
+
rollback: {
|
|
951
|
+
supported: true,
|
|
952
|
+
strategy: payload.commandType.includes('delete')
|
|
953
|
+
? 'restore_from_checkpoint'
|
|
954
|
+
: 'previous_value_checkpoint',
|
|
955
|
+
},
|
|
956
|
+
preview: {
|
|
957
|
+
summary: `Prepare ${payload.commandType}`,
|
|
958
|
+
payload: payload.payload,
|
|
959
|
+
},
|
|
960
|
+
usage: {
|
|
961
|
+
estimatedAdrataTokens: risk === 'high' ? 125 : risk === 'medium' ? 75 : 10,
|
|
962
|
+
metered: true,
|
|
963
|
+
billingEvent: `command.${payload.commandType}`,
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function commandRisk(commandType) {
|
|
970
|
+
if (
|
|
971
|
+
commandType.includes('delete') ||
|
|
972
|
+
commandType.includes('sync_crm') ||
|
|
973
|
+
commandType.includes('move_') ||
|
|
974
|
+
commandType.includes('update_') ||
|
|
975
|
+
commandType.includes('create_') ||
|
|
976
|
+
commandType.includes('send_')
|
|
977
|
+
) {
|
|
978
|
+
return 'high';
|
|
979
|
+
}
|
|
980
|
+
if (
|
|
981
|
+
commandType.includes('enrich') ||
|
|
982
|
+
commandType.includes('score') ||
|
|
983
|
+
commandType.includes('path') ||
|
|
984
|
+
commandType.includes('research')
|
|
985
|
+
) {
|
|
986
|
+
return 'medium';
|
|
987
|
+
}
|
|
988
|
+
return 'low';
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function cleanOptional(value) {
|
|
992
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
export function validateIntegrationManifest(manifest) {
|
|
996
|
+
const errors = [];
|
|
997
|
+
validateBase(manifest, 'adrata.integration', errors);
|
|
998
|
+
if (!VISIBILITY.has(manifest?.visibility)) {
|
|
999
|
+
errors.push('visibility must be workspace_private or org_shared');
|
|
1000
|
+
}
|
|
1001
|
+
if (!manifest?.auth || !AUTH_TYPES.has(manifest.auth.type)) {
|
|
1002
|
+
errors.push('auth.type must be api_key, bearer_token, oauth2, or none');
|
|
1003
|
+
}
|
|
1004
|
+
if (!Array.isArray(manifest?.scopes) || manifest.scopes.length === 0) {
|
|
1005
|
+
errors.push('scopes must contain at least one scope');
|
|
1006
|
+
}
|
|
1007
|
+
if (
|
|
1008
|
+
!positiveInt(manifest?.rateLimits?.perSecond) ||
|
|
1009
|
+
!positiveInt(manifest?.rateLimits?.perMinute)
|
|
1010
|
+
) {
|
|
1011
|
+
errors.push('rateLimits.perSecond and rateLimits.perMinute must be positive integers');
|
|
1012
|
+
}
|
|
1013
|
+
if (!manifest?.testEndpoint?.method || !manifest?.testEndpoint?.path) {
|
|
1014
|
+
errors.push('testEndpoint.method and testEndpoint.path are required');
|
|
1015
|
+
}
|
|
1016
|
+
if (!Array.isArray(manifest?.redaction?.secretFields)) {
|
|
1017
|
+
errors.push('redaction.secretFields must list fields to redact');
|
|
1018
|
+
}
|
|
1019
|
+
if (typeof manifest?.idempotency?.supported !== 'boolean') {
|
|
1020
|
+
errors.push('idempotency.supported must be boolean');
|
|
1021
|
+
}
|
|
1022
|
+
if (!Array.isArray(manifest?.piiFields)) {
|
|
1023
|
+
errors.push('piiFields must be an array');
|
|
1024
|
+
}
|
|
1025
|
+
if (!isAbsoluteUrl(manifest?.docsUrl)) {
|
|
1026
|
+
errors.push('docsUrl must be an absolute URL');
|
|
1027
|
+
}
|
|
1028
|
+
return { ok: errors.length === 0, errors };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export function validateExtensionManifest(manifest) {
|
|
1032
|
+
const errors = [];
|
|
1033
|
+
validateBase(manifest, 'adrata.extension', errors);
|
|
1034
|
+
if (!VISIBILITY.has(manifest?.visibility)) {
|
|
1035
|
+
errors.push('visibility must be workspace_private or org_shared');
|
|
1036
|
+
}
|
|
1037
|
+
if (Array.isArray(manifest?.fields)) {
|
|
1038
|
+
manifest.fields.forEach((field, i) => {
|
|
1039
|
+
if (!ENTITY_TYPES.has(field?.entity)) errors.push(`fields[${i}].entity is unsupported`);
|
|
1040
|
+
if (!isSnakeCase(field?.key)) errors.push(`fields[${i}].key must be snake_case`);
|
|
1041
|
+
if (!field?.label) errors.push(`fields[${i}].label is required`);
|
|
1042
|
+
if (!FIELD_TYPES.has(field?.type)) errors.push(`fields[${i}].type is unsupported`);
|
|
1043
|
+
});
|
|
1044
|
+
} else {
|
|
1045
|
+
errors.push('fields must be an array');
|
|
1046
|
+
}
|
|
1047
|
+
if (Array.isArray(manifest?.customTables)) {
|
|
1048
|
+
manifest.customTables.forEach((table, i) => {
|
|
1049
|
+
if (!isSnakeCase(table?.key)) errors.push(`customTables[${i}].key must be snake_case`);
|
|
1050
|
+
if (!table?.label) errors.push(`customTables[${i}].label is required`);
|
|
1051
|
+
if (!Array.isArray(table?.fields) || table.fields.length === 0) {
|
|
1052
|
+
errors.push(`customTables[${i}].fields must contain at least one field`);
|
|
1053
|
+
} else {
|
|
1054
|
+
table.fields.forEach((field, fieldIndex) => {
|
|
1055
|
+
if (!isSnakeCase(field?.key)) {
|
|
1056
|
+
errors.push(`customTables[${i}].fields[${fieldIndex}].key must be snake_case`);
|
|
1057
|
+
}
|
|
1058
|
+
if (!field?.label) {
|
|
1059
|
+
errors.push(`customTables[${i}].fields[${fieldIndex}].label is required`);
|
|
1060
|
+
}
|
|
1061
|
+
if (!FIELD_TYPES.has(field?.type)) {
|
|
1062
|
+
errors.push(`customTables[${i}].fields[${fieldIndex}].type is unsupported`);
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
} else {
|
|
1068
|
+
errors.push('customTables must be an array');
|
|
1069
|
+
}
|
|
1070
|
+
for (const key of ['tableExtensions', 'views', 'recordCards', 'workflowNodes', 'providerActions', 'permissions']) {
|
|
1071
|
+
if (!Array.isArray(manifest?.[key])) errors.push(`${key} must be an array`);
|
|
1072
|
+
}
|
|
1073
|
+
if (Array.isArray(manifest?.workflowNodes)) {
|
|
1074
|
+
manifest.workflowNodes.forEach((n, i) => {
|
|
1075
|
+
if (!isSnakeCase(n?.key)) errors.push(`workflowNodes[${i}].key must be snake_case`);
|
|
1076
|
+
if (!n?.label) errors.push(`workflowNodes[${i}].label is required`);
|
|
1077
|
+
if (!TRIGGER_TYPES.has(n?.trigger)) errors.push(`workflowNodes[${i}].trigger is unsupported`);
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
if (Array.isArray(manifest?.providerActions)) {
|
|
1081
|
+
manifest.providerActions.forEach((action, i) => {
|
|
1082
|
+
if (!action?.provider) errors.push(`providerActions[${i}].provider is required`);
|
|
1083
|
+
if (!isSnakeCase(action?.action)) errors.push(`providerActions[${i}].action must be snake_case`);
|
|
1084
|
+
if (!action?.scope) errors.push(`providerActions[${i}].scope is required`);
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
if (
|
|
1088
|
+
typeof manifest?.retention?.pii !== 'boolean' ||
|
|
1089
|
+
!positiveInt(manifest?.retention?.days)
|
|
1090
|
+
) {
|
|
1091
|
+
errors.push('retention.pii must be boolean and retention.days must be positive');
|
|
1092
|
+
}
|
|
1093
|
+
return { ok: errors.length === 0, errors };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function validateBase(manifest, kind, errors) {
|
|
1097
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
1098
|
+
errors.push('manifest must be an object');
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (manifest.schemaVersion !== 1) errors.push('schemaVersion must be 1');
|
|
1102
|
+
if (manifest.kind !== kind) errors.push(`kind must be ${kind}`);
|
|
1103
|
+
if (!manifest.slug || !/^[a-z][a-z0-9-]*$/.test(manifest.slug)) {
|
|
1104
|
+
errors.push('slug must be kebab-case and start with a letter');
|
|
1105
|
+
}
|
|
1106
|
+
if (!manifest.name) errors.push('name is required');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function isSnakeCase(key) {
|
|
1110
|
+
return typeof key === 'string' && /^[a-z][a-z0-9_]*$/.test(key);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function positiveInt(value) {
|
|
1114
|
+
return Number.isInteger(value) && value > 0;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function isAbsoluteUrl(value) {
|
|
1118
|
+
if (typeof value !== 'string' || value.length === 0) return false;
|
|
1119
|
+
try {
|
|
1120
|
+
return Boolean(new URL(value));
|
|
1121
|
+
} catch {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function titleize(slug) {
|
|
1127
|
+
return slug
|
|
1128
|
+
.split('-')
|
|
1129
|
+
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
1130
|
+
.join(' ');
|
|
1131
|
+
}
|