@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/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
- '/api/v1/entities',
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
- '/api/v1/sales',
162
- '/api/v1/scim',
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.1",
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.4",
67
- "hono": "4.12.27"
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
- server.resource(
230
- 'recent-research',
231
- 'adrata://recent-research',
232
- buildRecentResearchHandler(apiFn, auth)
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
- server.resource(
236
- 'sales-playbook',
237
- 'adrata://guide/sales-playbook',
238
- buildSalesPlaybookHandler()
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
- // Retain a bounded retry buffer. Never retain arbitrary arguments: log()
344
- // already redacts and truncates them before they enter this buffer.
345
- this.buffer = [...entries, ...this.buffer].slice(-this.maxBufferedEntries);
346
- console.error(`[audit] Failed to flush ${entries.length} audit entries; retained for retry`);
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
  }