@adrata/adrata-mcp 1.0.1 → 1.0.3
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 +1 -1
- package/access/auth.js +193 -4
- package/access/oauth.js +105 -15
- package/access/resource-metadata.js +9 -1
- package/access/tiers.js +35 -0
- package/api-bridge.js +84 -5
- package/governance/governed-args.js +261 -0
- package/package.json +5 -4
- package/product-profile.js +43 -0
- package/resources.js +22 -10
- package/security.js +34 -6
- package/server.js +282 -58
- package/server.json +0 -6
- package/skills/board-review/SKILL.md +10 -2
- package/skills/incident-to-card/SKILL.md +23 -15
- package/skills/ship-the-card/SKILL.md +27 -4
- package/tool-annotations.js +63 -7
- package/tools/billing.js +5 -1
- package/tools/roadmap-tools.js +192 -0
- package/tools/work-board-tools.js +455 -16
- package/tools/work-hub/audit.js +162 -0
- package/toolsets/crm.js +72 -10
- package/toolsets/prospecting.js +118 -12
- package/toolsets/spaces.js +628 -0
package/api-bridge.js
CHANGED
|
@@ -91,11 +91,19 @@ const ALLOWED_PREFIXES = [
|
|
|
91
91
|
'/api/v1/email',
|
|
92
92
|
'/api/v1/email-analytics',
|
|
93
93
|
'/api/v1/email-deliverability',
|
|
94
|
+
// `matchesPathPrefix` is segment-boundary matched, so `/api/v1/email` does
|
|
95
|
+
// NOT annex `/api/v1/email-drafts` — the draft surface the Email app reads
|
|
96
|
+
// and writes on every compose was outside the allowlist entirely.
|
|
97
|
+
'/api/v1/email-drafts',
|
|
94
98
|
'/api/v1/email-intelligence',
|
|
95
99
|
'/api/v1/email-provisioning',
|
|
96
100
|
'/api/v1/emails',
|
|
97
101
|
'/api/v1/engagement',
|
|
98
|
-
|
|
102
|
+
// What the workspace is entitled to. The nine-space chooser at /spaces reads
|
|
103
|
+
// exactly this and nothing else, so without it an agent cannot answer "which
|
|
104
|
+
// spaces does this workspace even have" — the first question about the app's
|
|
105
|
+
// new top-level shape.
|
|
106
|
+
'/api/v1/entitlements',
|
|
99
107
|
'/api/v1/env-audit',
|
|
100
108
|
'/api/v1/events',
|
|
101
109
|
'/api/v1/evidence',
|
|
@@ -121,8 +129,13 @@ const ALLOWED_PREFIXES = [
|
|
|
121
129
|
'/api/v1/jobs',
|
|
122
130
|
'/api/v1/knowledge',
|
|
123
131
|
'/api/v1/lead-lifecycle',
|
|
132
|
+
// Same segment-boundary point as email-drafts: `/api/v1/leads` does not
|
|
133
|
+
// annex `/api/v1/lead-lists`. The Deals list joins lead lists and their
|
|
134
|
+
// members, so the list surface was half-reachable without this.
|
|
135
|
+
'/api/v1/lead-lists',
|
|
124
136
|
'/api/v1/lead-routing',
|
|
125
137
|
'/api/v1/leads',
|
|
138
|
+
'/api/v1/lists',
|
|
126
139
|
'/api/v1/leaderboard',
|
|
127
140
|
'/api/v1/linkedin',
|
|
128
141
|
'/api/v1/media',
|
|
@@ -158,8 +171,14 @@ const ALLOWED_PREFIXES = [
|
|
|
158
171
|
'/api/v1/retention',
|
|
159
172
|
'/api/v1/revenue',
|
|
160
173
|
'/api/v1/revenue-cloud',
|
|
161
|
-
|
|
162
|
-
|
|
174
|
+
// Source control connections and their repository bindings. The board
|
|
175
|
+
// cannot learn that a pull request merged unless something can create the
|
|
176
|
+
// connection and register the callback, and until this row existed the
|
|
177
|
+
// bridge refused every /scm path — so the receiver at /webhooks/scm sat
|
|
178
|
+
// mounted and reachable with no connection ever pointing at it.
|
|
179
|
+
// The unauthenticated receiver itself is NOT here: it is mounted below the
|
|
180
|
+
// auth layer and is not an agent-callable path.
|
|
181
|
+
'/api/v1/scm',
|
|
163
182
|
'/api/v1/scoring',
|
|
164
183
|
'/api/v1/security',
|
|
165
184
|
'/api/v1/self-service',
|
|
@@ -169,7 +188,6 @@ const ALLOWED_PREFIXES = [
|
|
|
169
188
|
'/api/v1/signal-triggers',
|
|
170
189
|
'/api/v1/simple-features',
|
|
171
190
|
'/api/v1/sms',
|
|
172
|
-
'/api/v1/sso',
|
|
173
191
|
'/api/v1/speedrun',
|
|
174
192
|
'/api/v1/supersets',
|
|
175
193
|
'/api/v1/survival',
|
|
@@ -179,6 +197,7 @@ const ALLOWED_PREFIXES = [
|
|
|
179
197
|
'/api/v1/targets',
|
|
180
198
|
'/api/v1/team',
|
|
181
199
|
'/api/v1/trash',
|
|
200
|
+
'/api/v1/triggers',
|
|
182
201
|
'/api/v1/usage',
|
|
183
202
|
'/api/v1/user-voice-profile',
|
|
184
203
|
'/api/v1/users',
|
|
@@ -192,12 +211,36 @@ const ALLOWED_PREFIXES = [
|
|
|
192
211
|
'/api/v1/work-board-rollups',
|
|
193
212
|
'/api/v1/work-boards',
|
|
194
213
|
'/api/v1/work-items',
|
|
214
|
+
// The containers above the cards (initiative / epic / feature) and the
|
|
215
|
+
// "add this to the roadmap" verb. Creating an epic proposal is a governed
|
|
216
|
+
// write like any other; the strategic commitment itself still moves through
|
|
217
|
+
// spoq/ PRs (company/decisions/2026-08-06-spoq-roadmap-sync.md).
|
|
218
|
+
'/api/v1/work-scopes',
|
|
195
219
|
'/api/v1/workflow-runtime',
|
|
196
220
|
'/api/v1/workflows',
|
|
197
221
|
'/api/v1/workspace',
|
|
198
222
|
'/api/v1/workspace-features',
|
|
199
223
|
'/api/v1/workspace-settings',
|
|
200
224
|
'/api/v1/zoho',
|
|
225
|
+
// ---------------------------------------------------------------------
|
|
226
|
+
// /api/v2 — the desktop read models and the aimed send path.
|
|
227
|
+
//
|
|
228
|
+
// Until this block existed the bridge allowlisted `/api/v1` exclusively, so
|
|
229
|
+
// EVERY v2 path failed `path is outside the agent bridge allowlist`. That
|
|
230
|
+
// was not a niche gap: the app's Email and Calendar surfaces read from v2,
|
|
231
|
+
// not v1. `GET /api/v2/emails/threads` backs the inbox list,
|
|
232
|
+
// `GET /api/v2/emails/counts` backs the folder badges, and
|
|
233
|
+
// `GET /api/v2/events/calendar` backs the entire agenda. A person could open
|
|
234
|
+
// their inbox and their calendar; an agent could reach neither, while the
|
|
235
|
+
// v1 write paths for the same records (PUT /api/v1/events/{id}, the draft
|
|
236
|
+
// routes) were allowlisted the whole time. The bridge could modify a
|
|
237
|
+
// calendar it could not read.
|
|
238
|
+
//
|
|
239
|
+
// The v2 namespace is deliberately small — `code/api/src/routes/v2/mod.rs`
|
|
240
|
+
// nests exactly `/emails` and `/events` — so these two prefixes are the
|
|
241
|
+
// whole surface, not a wildcard.
|
|
242
|
+
'/api/v2/emails',
|
|
243
|
+
'/api/v2/events',
|
|
201
244
|
];
|
|
202
245
|
|
|
203
246
|
function matchesPathPrefix(path, prefix) {
|
|
@@ -232,10 +275,46 @@ const PATH_WRITE_SCOPES = [
|
|
|
232
275
|
['/api/v1/agent-tasks', 'write:tasks'],
|
|
233
276
|
['/api/v1/tasks', 'write:tasks'],
|
|
234
277
|
['/api/v1/actions', 'write:actions'],
|
|
278
|
+
// Starfield boards. A card IS a task, so the API's scope_guard maps all three
|
|
279
|
+
// board families onto the SAME read:tasks/write:tasks pair rather than a new
|
|
280
|
+
// scope family — see `required_scopes_for` in
|
|
281
|
+
// code/api/crates/middleware/src/scope_guard/mod.rs and the test
|
|
282
|
+
// `work_board_routes_are_reachable_by_the_credential_the_mcp_actually_issues`.
|
|
283
|
+
// Without these three rows a board write previewed as
|
|
284
|
+
// `requiredScope: "unknown (no mapping for this path)"` with `wouldSend: true`,
|
|
285
|
+
// so the preview could not warn that the connection lacked write:tasks — the
|
|
286
|
+
// exact "safe preview that predicts nothing" this map exists to prevent.
|
|
287
|
+
// All three are listed separately because `matchesPathPrefix` is
|
|
288
|
+
// segment-boundary matched: `/work-boards` does not annex
|
|
289
|
+
// `/work-board-rollups`.
|
|
290
|
+
// Authorising a source-control account is the same act as connecting a CRM —
|
|
291
|
+
// an external system permitted to write into the workspace — so scope_guard
|
|
292
|
+
// maps it onto the integrations family rather than a new one. Deliberately
|
|
293
|
+
// not an `admin:` scope: those refuse first-party bypass and a human session
|
|
294
|
+
// JWT carries an empty permission list, which would 403 every person opening
|
|
295
|
+
// the Connections screen. Mirrors `family_matches(path, "/scm")` in
|
|
296
|
+
// code/api/crates/middleware/src/scope_guard/mod.rs.
|
|
297
|
+
['/api/v1/scm', 'write:integrations'],
|
|
298
|
+
['/api/v1/work-boards', 'write:tasks'],
|
|
299
|
+
['/api/v1/work-items', 'write:tasks'],
|
|
300
|
+
['/api/v1/work-board-rollups', 'write:tasks'],
|
|
235
301
|
];
|
|
236
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The API classifies a path AFTER stripping its version prefix, so
|
|
305
|
+
* `/api/v2/emails/direct` is scoped by exactly the same rules as
|
|
306
|
+
* `/api/v1/emails/direct` (`VERSIONED_API_PREFIXES` in
|
|
307
|
+
* code/api/crates/middleware/src/scope_guard/mod.rs). Mirror that here instead
|
|
308
|
+
* of duplicating every row under a second version — a duplicated map is a map
|
|
309
|
+
* that drifts, and a v2 path silently falling through to "no mapping" is the
|
|
310
|
+
* "preview that predicts nothing" failure this module exists to prevent.
|
|
311
|
+
*/
|
|
312
|
+
function toScopeClassificationPath(path) {
|
|
313
|
+
return path.startsWith('/api/v2/') ? `/api/v1/${path.slice('/api/v2/'.length)}` : path;
|
|
314
|
+
}
|
|
315
|
+
|
|
237
316
|
export function requiredWriteScopeForPath(path) {
|
|
238
|
-
const normalized = normalizeApiPath(path);
|
|
317
|
+
const normalized = toScopeClassificationPath(normalizeApiPath(path));
|
|
239
318
|
let best = null;
|
|
240
319
|
for (const [prefix, scope] of PATH_WRITE_SCOPES) {
|
|
241
320
|
if (matchesPathPrefix(normalized, prefix)) {
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared governed-write plumbing for the toolset packs.
|
|
3
|
+
*
|
|
4
|
+
* # Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* `governedWrite` (api-bridge.js) is the enforcement point: a write previews by
|
|
7
|
+
* default and only executes with `dryRun:false` + `approved:true` + a non-empty
|
|
8
|
+
* `reason` + a non-empty `idempotencyKey`. server.js wired 18 tools to it, but
|
|
9
|
+
* it did so with two private helpers — `governedWriteArgs` (the zod fields) and
|
|
10
|
+
* `governedWriteNote` (the description sentence) — that live inside server.js
|
|
11
|
+
* and cannot be imported. Every other pack (`toolsets/*.js`, `tools/*.js`) was
|
|
12
|
+
* therefore left calling `api()` raw, which is exactly the asymmetry
|
|
13
|
+
* `governedWrite` was written to end: the purpose-built tool was strictly less
|
|
14
|
+
* safe than the generic `adrata_api_request` fallback it exists to replace.
|
|
15
|
+
*
|
|
16
|
+
* Copying the schema into five files is how a governance contract drifts — one
|
|
17
|
+
* file gains a field, another keeps the old wording, and the gate silently
|
|
18
|
+
* differs by tool. So the schema, the description sentence, the refusal text
|
|
19
|
+
* and the preview renderer live here once, and the packs import them.
|
|
20
|
+
*
|
|
21
|
+
* This governs the *client*. It does not replace the server-side scope check:
|
|
22
|
+
* the API still rejects a token without the matching `write:*` scope with 403
|
|
23
|
+
* insufficient_scope.
|
|
24
|
+
*
|
|
25
|
+
* It lives in `governance/` rather than at the package root next to
|
|
26
|
+
* `governed-args`-style helpers because `package.json`'s `files:` array names
|
|
27
|
+
* root modules one by one and whole directories wholesale — `governance/` is
|
|
28
|
+
* already shipped, a new root file would not be, and packaging.test.js's "ships
|
|
29
|
+
* every source file" check would fail on a module that exists in the checkout
|
|
30
|
+
* and is missing from the tarball. `governance/money.js` is the same idea for
|
|
31
|
+
* money writes, so this is where a governance envelope belongs anyway.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { z as defaultZod } from 'zod';
|
|
35
|
+
|
|
36
|
+
import { governedWrite } from '../api-bridge.js';
|
|
37
|
+
import { md, mdError } from '../output-formatter.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Coresignal charges 20 credits for a `collect/{employee_id}` — one person.
|
|
41
|
+
* See CLAUDE.md ("Coresignal credits are the other real bill") and
|
|
42
|
+
* `code/api/crates/integrations/src/coresignal/company_multi_source.rs`, whose
|
|
43
|
+
* `COMPANY_MULTI_SOURCE_CREDITS_PER_CALL` is likewise 20.
|
|
44
|
+
*
|
|
45
|
+
* This number is stated in the tool description AND repeated in the preview on
|
|
46
|
+
* purpose: a caller who only ever reads the preview must still see the bill
|
|
47
|
+
* before approving it.
|
|
48
|
+
*/
|
|
49
|
+
export const CORESIGNAL_CREDITS_PER_PERSON = 20;
|
|
50
|
+
export const CORESIGNAL_CREDITS_PER_COMPANY = 20;
|
|
51
|
+
|
|
52
|
+
export const PERSON_ENRICH_COST_SENTENCE =
|
|
53
|
+
` SPENDS VENDOR CREDITS: a Coresignal collect is ${CORESIGNAL_CREDITS_PER_PERSON} credits per person, charged to the workspace's shared credit pool.`;
|
|
54
|
+
|
|
55
|
+
export const COMPANY_ENRICH_COST_SENTENCE =
|
|
56
|
+
` SPENDS VENDOR CREDITS: a Coresignal company multi-source call is ${CORESIGNAL_CREDITS_PER_COMPANY} credits per company, charged to the workspace's shared credit pool.`;
|
|
57
|
+
|
|
58
|
+
/** The cost line rendered inside an enrich preview. */
|
|
59
|
+
export function enrichCostLine(kind) {
|
|
60
|
+
return kind === 'company'
|
|
61
|
+
? `${CORESIGNAL_CREDITS_PER_COMPANY} Coresignal credits per company (vendor spend, not refundable)`
|
|
62
|
+
: `${CORESIGNAL_CREDITS_PER_PERSON} Coresignal credits per person (vendor spend, not refundable)`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Governance fields every governed write tool accepts.
|
|
67
|
+
*
|
|
68
|
+
* Takes the caller's `zod` so a pack that receives `z` through its dependency
|
|
69
|
+
* object (tools/email-tools.js) builds its schema with the same instance the
|
|
70
|
+
* rest of that tool's schema uses.
|
|
71
|
+
*/
|
|
72
|
+
export function governedWriteArgs(zod = defaultZod) {
|
|
73
|
+
return {
|
|
74
|
+
dryRun: zod
|
|
75
|
+
.boolean()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Defaults to true. Returns a preview of the exact call instead of performing it. Set false to execute.'),
|
|
78
|
+
approved: zod
|
|
79
|
+
.boolean()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('Required (true) for a live write. Records that the caller confirmed the mutation; it does not by itself grant scope.'),
|
|
82
|
+
reason: zod
|
|
83
|
+
.string()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe('Required for a live write. Recorded as the audit reason (X-Adrata-Reason).'),
|
|
86
|
+
idempotencyKey: zod
|
|
87
|
+
.string()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe('Required for a live write. Sent as Idempotency-Key so a retry cannot double-apply.'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The sentence appended to a governed tool's description.
|
|
95
|
+
*
|
|
96
|
+
* It names the scope because that is the one thing the caller cannot guess and
|
|
97
|
+
* the API will reject them for: a 403 `insufficient_scope` on `write:people`
|
|
98
|
+
* reads like a bug unless the tool already said which scope it needed. Pass
|
|
99
|
+
* `null` when the route genuinely requires no OAuth write scope.
|
|
100
|
+
*/
|
|
101
|
+
export function governedWriteNote(scope) {
|
|
102
|
+
const base =
|
|
103
|
+
' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey';
|
|
104
|
+
return scope
|
|
105
|
+
? `${base}, and the connection must hold ${scope} (connect_workspace with writeAccess:true).`
|
|
106
|
+
: `${base}. This route currently requires no OAuth write scope, so approval and audit are the only gate.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when the caller has explicitly asked for a live write. */
|
|
110
|
+
export function isLiveWrite(args = {}) {
|
|
111
|
+
return args?.dryRun === false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Which authorization fields a live write is still missing.
|
|
116
|
+
*
|
|
117
|
+
* `validateApiBridgeRequest` throws on the first one it finds, which tells a
|
|
118
|
+
* caller to add a reason and then, one round trip later, to add an idempotency
|
|
119
|
+
* key. Listing them together is the same gate, said once.
|
|
120
|
+
*/
|
|
121
|
+
export function missingLiveWriteFields(args = {}) {
|
|
122
|
+
const missing = [];
|
|
123
|
+
if (args.approved !== true) missing.push('approved:true');
|
|
124
|
+
if (!args.reason || !String(args.reason).trim()) missing.push('reason');
|
|
125
|
+
if (!args.idempotencyKey || !String(args.idempotencyKey).trim()) missing.push('idempotencyKey');
|
|
126
|
+
return missing;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The refusal payload for a live write that is missing authorization fields. */
|
|
130
|
+
export function liveWriteRefusal(args = {}, request = {}) {
|
|
131
|
+
const missing = missingLiveWriteFields(args);
|
|
132
|
+
return {
|
|
133
|
+
error: true,
|
|
134
|
+
refused: true,
|
|
135
|
+
missing,
|
|
136
|
+
message:
|
|
137
|
+
`live write refused: missing ${missing.join(', ')}. ` +
|
|
138
|
+
`Nothing was sent to ${request.method || 'the API'} ${request.path || ''}`.trimEnd() + '.',
|
|
139
|
+
wouldHaveCalled: { method: request.method, path: request.path },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const PREVIEW_LABELS = {
|
|
144
|
+
method: 'Method',
|
|
145
|
+
path: 'Path',
|
|
146
|
+
wouldSend: 'Would send',
|
|
147
|
+
requiredScope: 'Required scope',
|
|
148
|
+
scopeHeld: 'Scope held',
|
|
149
|
+
entity: 'Entity',
|
|
150
|
+
entityId: 'Entity ID',
|
|
151
|
+
operation: 'Operation',
|
|
152
|
+
cost: 'Cost if executed',
|
|
153
|
+
affects: 'Would affect',
|
|
154
|
+
cascade: 'Cascades to',
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render a governed-write preview as Markdown.
|
|
159
|
+
*
|
|
160
|
+
* The preview is not a courtesy — it IS the refusal, rendered as the exact call
|
|
161
|
+
* that was withheld plus what is missing to authorize it. So it always prints
|
|
162
|
+
* the method and path, never a bare "ok".
|
|
163
|
+
*/
|
|
164
|
+
export function previewMarkdown(heading, preview = {}) {
|
|
165
|
+
const {
|
|
166
|
+
method,
|
|
167
|
+
path,
|
|
168
|
+
wouldSend,
|
|
169
|
+
requiredScope,
|
|
170
|
+
scopeHeld,
|
|
171
|
+
requiredForLiveWrite,
|
|
172
|
+
note,
|
|
173
|
+
body,
|
|
174
|
+
blocked,
|
|
175
|
+
fix,
|
|
176
|
+
reason,
|
|
177
|
+
...extra
|
|
178
|
+
} = preview;
|
|
179
|
+
|
|
180
|
+
let text = `## ${heading} — Preview Only (nothing was written)\n\n`;
|
|
181
|
+
text += `- **Method:** ${method}\n`;
|
|
182
|
+
text += `- **Path:** ${path}\n`;
|
|
183
|
+
text += `- **Required scope:** ${requiredScope}\n`;
|
|
184
|
+
text += `- **Scope held:** ${scopeHeld}\n`;
|
|
185
|
+
|
|
186
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
187
|
+
if (value === undefined || value === null) continue;
|
|
188
|
+
const label = PREVIEW_LABELS[key] || key;
|
|
189
|
+
const rendered =
|
|
190
|
+
typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
191
|
+
text += `- **${label}:** ${rendered}\n`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (blocked) {
|
|
195
|
+
text += `\n> **Blocked:** ${reason}\n>\n> Fix: ${fix}\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (body && Object.keys(body).length > 0) {
|
|
199
|
+
text += `\n### Body that would be sent\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\`\n`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
text += `\n### To execute\n\nRe-call with \`dryRun:false\`, \`approved:true\`, a \`reason\`, and an \`idempotencyKey\`.\n`;
|
|
203
|
+
if (note) text += `\n${note}\n`;
|
|
204
|
+
return text;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run a Markdown-returning tool's write through the governed contract.
|
|
209
|
+
*
|
|
210
|
+
* @param api the pack's `api(method, path, opts)` callable
|
|
211
|
+
* @param args tool arguments carrying dryRun/approved/reason/idempotencyKey
|
|
212
|
+
* @param request {method, path, body, preview, heading}
|
|
213
|
+
* @param onSuccess (result) => Markdown string, rendered only on a live write
|
|
214
|
+
*/
|
|
215
|
+
export async function runGovernedMarkdownWrite(api, args, request, onSuccess) {
|
|
216
|
+
const { heading = 'Governed write', ...call } = request;
|
|
217
|
+
|
|
218
|
+
if (isLiveWrite(args)) {
|
|
219
|
+
const missing = missingLiveWriteFields(args);
|
|
220
|
+
if (missing.length > 0) {
|
|
221
|
+
return mdError(
|
|
222
|
+
`Live write refused — missing ${missing.join(', ')}`,
|
|
223
|
+
`Nothing was sent to ${call.method} ${call.path}. Re-call with dryRun:false plus ${missing.join(', ')}.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const outcome = await governedWrite(api, args, call);
|
|
230
|
+
if (outcome.dryRun) return md(previewMarkdown(heading, outcome.preview));
|
|
231
|
+
return md(onSuccess(outcome.result));
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return mdError(`${heading} failed`, err.message);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run a JSON-returning tool's write through the governed contract.
|
|
239
|
+
*
|
|
240
|
+
* @param api the pack's `api(method, path, opts)` callable
|
|
241
|
+
* @param args tool arguments carrying dryRun/approved/reason/idempotencyKey
|
|
242
|
+
* @param request {method, path, body, preview}
|
|
243
|
+
* @param ok the pack's `ok(data)` envelope helper
|
|
244
|
+
* @param onSuccess (result) => object, merged into the live-write envelope
|
|
245
|
+
*/
|
|
246
|
+
export async function runGovernedJsonWrite(api, args, request, ok, onSuccess = (r) => ({ result: r })) {
|
|
247
|
+
if (isLiveWrite(args)) {
|
|
248
|
+
const missing = missingLiveWriteFields(args);
|
|
249
|
+
if (missing.length > 0) return ok(liveWriteRefusal(args, request));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const outcome = await governedWrite(api, args, request);
|
|
254
|
+
if (outcome.dryRun) {
|
|
255
|
+
return ok({ dryRun: true, executed: false, preview: outcome.preview });
|
|
256
|
+
}
|
|
257
|
+
return ok({ dryRun: false, executed: true, ...onSuccess(outcome.result) });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
return ok({ error: true, executed: false, message: err.message });
|
|
260
|
+
}
|
|
261
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrata/adrata-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. 80+ tools for companies, people, deals, actions, buyer groups, warm intros, webhooks, intelligence, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
|
-
"test": "node --test server.test.js api-bridge.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js security.test.js toolsets.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js governance/money.test.js"
|
|
12
|
+
"test": "node --test server.test.js api-bridge.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js tool-annotations.test.js toolsets.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js scripts/local-dev-server.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/work-hub/audit.test.js tools/roadmap-tools.test.js governance/money.test.js"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"mcp",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"transport-http.js",
|
|
48
48
|
"resources.js",
|
|
49
49
|
"tool-annotations.js",
|
|
50
|
+
"product-profile.js",
|
|
50
51
|
"output-formatter.js",
|
|
51
52
|
"access/",
|
|
52
53
|
"tools/",
|
|
@@ -63,8 +64,8 @@
|
|
|
63
64
|
},
|
|
64
65
|
"overrides": {
|
|
65
66
|
"@hono/node-server": "2.0.12",
|
|
66
|
-
"fast-uri": "3.1.
|
|
67
|
-
"hono": "4.12.
|
|
67
|
+
"fast-uri": "3.1.5",
|
|
68
|
+
"hono": "4.12.34"
|
|
68
69
|
},
|
|
69
70
|
"bugs": {
|
|
70
71
|
"url": "https://github.com/adrata/adrata/issues"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Branded MCP launch profiles over one shared server implementation. */
|
|
2
|
+
export const PRODUCT_PROFILES = Object.freeze({
|
|
3
|
+
adrata: Object.freeze({ displayName: 'Adrata', domains: null }),
|
|
4
|
+
bounce: Object.freeze({
|
|
5
|
+
displayName: 'Bounce',
|
|
6
|
+
domains: Object.freeze([
|
|
7
|
+
'actions', 'agent', 'bridge', 'calendar', 'email', 'infra',
|
|
8
|
+
'meetings', 'sequences', 'webhooks',
|
|
9
|
+
]),
|
|
10
|
+
}),
|
|
11
|
+
starfield: Object.freeze({
|
|
12
|
+
displayName: 'Starfield',
|
|
13
|
+
domains: Object.freeze([
|
|
14
|
+
'agent', 'board', 'bridge', 'forms', 'surveys', 'webhooks', 'workflows',
|
|
15
|
+
]),
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function applyProductProfile(env = process.env) {
|
|
20
|
+
const product = env.ADRATA_MCP_PRODUCT?.trim().toLowerCase();
|
|
21
|
+
if (!product) return null;
|
|
22
|
+
const profile = PRODUCT_PROFILES[product];
|
|
23
|
+
if (!profile) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Unknown ADRATA_MCP_PRODUCT "${product}". Expected ${Object.keys(PRODUCT_PROFILES).join(', ')}.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (!env.ADRATA_MCP_SERVER_NAME?.trim()) env.ADRATA_MCP_SERVER_NAME = profile.displayName;
|
|
29
|
+
// Explicit operator policy wins. A profile only supplies a least-privilege default.
|
|
30
|
+
if (!env.ADRATA_MCP_ENABLED_DOMAINS?.trim() && profile.domains) {
|
|
31
|
+
env.ADRATA_MCP_ENABLED_DOMAINS = profile.domains.join(',');
|
|
32
|
+
}
|
|
33
|
+
return profile;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function assertProductCapabilityRef(ref, product) {
|
|
37
|
+
const normalizedProduct = String(product ?? '').trim().toLowerCase();
|
|
38
|
+
if (!normalizedProduct) return ref;
|
|
39
|
+
if (typeof ref !== 'string' || !ref.startsWith(`/${normalizedProduct}/`)) {
|
|
40
|
+
throw new Error(`Capability ${String(ref)} is outside the /${normalizedProduct}/ product scope.`);
|
|
41
|
+
}
|
|
42
|
+
return ref;
|
|
43
|
+
}
|
package/resources.js
CHANGED
|
@@ -225,18 +225,30 @@ Use connect_workspace to get started.`;
|
|
|
225
225
|
* @param {Function} apiFn - The api() helper for server calls
|
|
226
226
|
* @param {object} auth - AUTH context { tier, token, apiKey, authenticated }
|
|
227
227
|
*/
|
|
228
|
+
export function resourceNamesForProduct(product = process.env.ADRATA_MCP_PRODUCT) {
|
|
229
|
+
const normalized = String(product ?? '').trim().toLowerCase();
|
|
230
|
+
return !normalized || normalized === 'adrata'
|
|
231
|
+
? ['recent-research', 'sales-playbook', 'workspace-summary']
|
|
232
|
+
: ['workspace-summary'];
|
|
233
|
+
}
|
|
234
|
+
|
|
228
235
|
export function registerResources(server, apiFn, auth) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
const enabled = new Set(resourceNamesForProduct());
|
|
237
|
+
if (enabled.has('recent-research')) {
|
|
238
|
+
server.resource(
|
|
239
|
+
'recent-research',
|
|
240
|
+
'adrata://recent-research',
|
|
241
|
+
buildRecentResearchHandler(apiFn, auth)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
234
244
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
245
|
+
if (enabled.has('sales-playbook')) {
|
|
246
|
+
server.resource(
|
|
247
|
+
'sales-playbook',
|
|
248
|
+
'adrata://guide/sales-playbook',
|
|
249
|
+
buildSalesPlaybookHandler()
|
|
250
|
+
);
|
|
251
|
+
}
|
|
240
252
|
|
|
241
253
|
server.resource(
|
|
242
254
|
'workspace-summary',
|
package/security.js
CHANGED
|
@@ -300,6 +300,16 @@ class AuditLogger {
|
|
|
300
300
|
this.maxBufferedEntries = 1000;
|
|
301
301
|
this.flushIntervalMs = 30_000;
|
|
302
302
|
this.flushing = false;
|
|
303
|
+
// Consecutive failed flushes. Telemetry is best-effort, so a server that is
|
|
304
|
+
// permanently unable to accept it must not produce an unbounded stream of
|
|
305
|
+
// stderr on somebody's terminal: the MCP's own audit endpoint answered 500
|
|
306
|
+
// to every batch for the whole life of the 1.0.x line, and the visible
|
|
307
|
+
// result was "[audit] Failed to flush N audit entries" every 30 seconds,
|
|
308
|
+
// forever, in a client the user is trying to work in. After
|
|
309
|
+
// `maxConsecutiveFailures` the logger says so ONCE and stands down.
|
|
310
|
+
this.consecutiveFailures = 0;
|
|
311
|
+
this.maxConsecutiveFailures = 3;
|
|
312
|
+
this.disabled = false;
|
|
303
313
|
this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
|
|
304
314
|
if (this.flushTimer.unref) this.flushTimer.unref();
|
|
305
315
|
}
|
|
@@ -309,7 +319,7 @@ class AuditLogger {
|
|
|
309
319
|
* @param {{ tool: string, tier: string, action: string, args: object, userId: string|null, sessionKey: string, timestamp: string }} entry
|
|
310
320
|
*/
|
|
311
321
|
log(entry) {
|
|
312
|
-
if (!TELEMETRY_ENABLED) return;
|
|
322
|
+
if (!TELEMETRY_ENABLED || this.disabled) return;
|
|
313
323
|
|
|
314
324
|
this.buffer.push({
|
|
315
325
|
tool: entry.tool,
|
|
@@ -332,18 +342,36 @@ class AuditLogger {
|
|
|
332
342
|
* Silently drops entries if the API is unavailable (non-blocking).
|
|
333
343
|
*/
|
|
334
344
|
async flush() {
|
|
335
|
-
if (this.buffer.length === 0 || this.flushing) return;
|
|
345
|
+
if (this.buffer.length === 0 || this.flushing || this.disabled) return;
|
|
336
346
|
this.flushing = true;
|
|
337
347
|
const entries = this.buffer.splice(0, this.buffer.length);
|
|
338
348
|
|
|
339
349
|
if (this.apiFn) {
|
|
340
350
|
try {
|
|
341
351
|
await this.apiFn('POST', '/api/v1/mcp/audit', { body: { entries } });
|
|
352
|
+
// A success clears the streak; an intermittent outage must not count
|
|
353
|
+
// toward standing down.
|
|
354
|
+
this.consecutiveFailures = 0;
|
|
342
355
|
} catch {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
356
|
+
this.consecutiveFailures += 1;
|
|
357
|
+
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
358
|
+
// Give up for the life of the process, and drop what we were holding
|
|
359
|
+
// rather than carrying a doomed batch forever. Telemetry is not worth
|
|
360
|
+
// a permanent retry loop or a growing buffer in someone's editor.
|
|
361
|
+
this.disabled = true;
|
|
362
|
+
this.buffer = [];
|
|
363
|
+
clearInterval(this.flushTimer);
|
|
364
|
+
console.error(
|
|
365
|
+
`[audit] Audit telemetry disabled after ${this.consecutiveFailures} consecutive flush failures. `
|
|
366
|
+
+ 'Tool calls still work; only local usage analytics stop. '
|
|
367
|
+
+ 'Set ADRATA_TELEMETRY=off to disable this at startup.',
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
// Retain a bounded retry buffer. Never retain arbitrary arguments: log()
|
|
371
|
+
// already redacts and truncates them before they enter this buffer.
|
|
372
|
+
this.buffer = [...entries, ...this.buffer].slice(-this.maxBufferedEntries);
|
|
373
|
+
console.error(`[audit] Failed to flush ${entries.length} audit entries; retained for retry`);
|
|
374
|
+
}
|
|
347
375
|
} finally {
|
|
348
376
|
this.flushing = false;
|
|
349
377
|
}
|