@adrata/adrata-mcp 1.0.0 → 1.0.2
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 +82 -8
- package/access/tiers.js +33 -0
- package/api-bridge.js +68 -5
- package/governance/governed-args.js +261 -0
- package/package.json +11 -6
- 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 +5 -4
- package/tool-annotations.js +57 -7
- package/tools/billing.js +5 -1
- package/tools/roadmap-tools.js +192 -0
- package/tools/work-board-tools.js +286 -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
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrata/adrata-mcp",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Adrata MCP Server
|
|
3
|
+
"version": "1.0.2",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -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",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
35
|
-
"url": "https://
|
|
35
|
+
"url": "https://github.com/adrata/adrata",
|
|
36
|
+
"directory": "code/mcp"
|
|
36
37
|
},
|
|
37
38
|
"homepage": "https://adrata.com",
|
|
38
39
|
"engines": {
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
"transport-http.js",
|
|
47
48
|
"resources.js",
|
|
48
49
|
"tool-annotations.js",
|
|
50
|
+
"product-profile.js",
|
|
49
51
|
"output-formatter.js",
|
|
50
52
|
"access/",
|
|
51
53
|
"tools/",
|
|
@@ -62,7 +64,10 @@
|
|
|
62
64
|
},
|
|
63
65
|
"overrides": {
|
|
64
66
|
"@hono/node-server": "2.0.12",
|
|
65
|
-
"fast-uri": "3.1.
|
|
66
|
-
"hono": "4.12.
|
|
67
|
+
"fast-uri": "3.1.5",
|
|
68
|
+
"hono": "4.12.34"
|
|
69
|
+
},
|
|
70
|
+
"bugs": {
|
|
71
|
+
"url": "https://github.com/adrata/adrata/issues"
|
|
67
72
|
}
|
|
68
73
|
}
|
|
@@ -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
|
}
|