@adrata/adrata-mcp 1.0.3 → 1.0.7

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/analytics.js CHANGED
@@ -119,7 +119,6 @@ function ensureDir() {
119
119
  }
120
120
 
121
121
  function readLocalAnalytics() {
122
- ensureDir();
123
122
  if (!existsSync(ANALYTICS_FILE)) {
124
123
  return { events: [], sessions: [] };
125
124
  }
@@ -278,6 +277,69 @@ export function trackConversion(apiFn, auth, fromTier, toTier, trigger) {
278
277
  /** @type {{ startTime: number, toolsUsed: Set<string>, invocationCount: number } | null} */
279
278
  let currentSession = null;
280
279
 
280
+ // ---------------------------------------------------------------------------
281
+ // Analytics wrapper coverage
282
+ // ---------------------------------------------------------------------------
283
+ // The analytics counter is a monkey-patch on `server.tool`, so it can only see
284
+ // tools registered AFTER it is installed. That makes its own coverage a fact
285
+ // worth reporting: a zero from an instrumented surface and a zero from an
286
+ // uninstrumented one are different answers, and until 2026-08-29 they were
287
+ // rendered identically. `countedToolNames` is what the wrapper actually
288
+ // wrapped; `get_mcp_analytics` compares it against what the server registered.
289
+
290
+ /**
291
+ * Wrapper state belongs to one McpServer instance. Hosted HTTP creates a fresh
292
+ * server for every protocol session, so a module-global installed flag would
293
+ * wrap the first client and silently skip every client after it.
294
+ *
295
+ * @type {WeakMap<object, { countedToolNames: Set<string> }>}
296
+ */
297
+ let analyticsWrapperStates = new WeakMap();
298
+
299
+ /**
300
+ * How much of the registered tool surface the invocation counter can see.
301
+ *
302
+ * @param {object} [server] - McpServer instance, for the registered-tool total
303
+ * @returns {{installed: boolean, tools_counted: number, tools_registered: number|null, complete: boolean|null, uncounted_note: string|undefined}}
304
+ */
305
+ export function getAnalyticsCoverage(server) {
306
+ const registered = registeredToolCount(server);
307
+ const wrapperState = server && typeof server === 'object'
308
+ ? analyticsWrapperStates.get(server)
309
+ : undefined;
310
+ const counted = wrapperState?.countedToolNames.size ?? 0;
311
+ const complete = registered === null ? null : counted >= registered;
312
+ return {
313
+ installed: wrapperState !== undefined,
314
+ tools_counted: counted,
315
+ tools_registered: registered,
316
+ complete,
317
+ uncounted_note:
318
+ complete === false
319
+ ? `${registered - counted} of ${registered} registered tools were registered before the `
320
+ + 'analytics wrapper was installed, so their invocations are NOT counted. '
321
+ + 'Treat tool_invocations as a lower bound, not a measurement.'
322
+ : undefined,
323
+ };
324
+ }
325
+
326
+ /** Best-effort read of the SDK's registered-tool map. Returns null if unavailable. */
327
+ function registeredToolCount(server) {
328
+ try {
329
+ const registry = server?._registeredTools;
330
+ if (registry && typeof registry === 'object') return Object.keys(registry).length;
331
+ } catch {
332
+ // The SDK internal is not a contract. An unknown total is reported as
333
+ // null, never as a number we did not measure.
334
+ }
335
+ return null;
336
+ }
337
+
338
+ /** Test-only: forget that the wrapper was installed. */
339
+ export function __resetAnalyticsWrapperForTests() {
340
+ analyticsWrapperStates = new WeakMap();
341
+ }
342
+
281
343
  /**
282
344
  * Start a new analytics session.
283
345
  *
@@ -388,24 +450,43 @@ const SEARCH_TOOLS = {
388
450
  * invocation. This builds on top of the existing event logging in memory.js
389
451
  * by adding richer analytics (search classification, tier gates, sessions).
390
452
  *
391
- * Must be applied AFTER the event logging wrapper from memory.js.
453
+ * Must be applied AFTER the event logging wrapper from memory.js, and BEFORE
454
+ * the first `server.tool(...)` call. Monkey-patching `server.tool` can only
455
+ * ever reach FUTURE registrations, so installing it late does not degrade the
456
+ * count — it silently zeroes it. Measured 2026-08-29: installed from
457
+ * `registerAnalytics` ~1975 lines after the first registration, it covered 27
458
+ * of 254 tools and `get_mcp_analytics` reported `tool_invocations.total: 0`
459
+ * with no indication that 89% of the surface was never instrumented.
460
+ *
461
+ * Idempotent: calling it twice installs one wrapper, so `registerAnalytics`
462
+ * remains safe to call after server.js has already installed it early.
463
+ *
464
+ * `auth` may be an AUTH object or a zero-argument getter. Prefer the getter:
465
+ * server.js reassigns AUTH on `connect_workspace`, and an object captured at
466
+ * install time would report the pre-connection tier forever.
392
467
  *
393
468
  * @param {object} server - McpServer instance
394
469
  * @param {Function} apiFn - The api() helper
395
- * @param {object} auth - AUTH context
470
+ * @param {object|Function} auth - AUTH context, or a getter returning it
396
471
  * @param {Function} checkToolAccessFn - checkToolAccess from auth.js
397
472
  */
398
473
  export function wrapWithAnalytics(server, apiFn, auth, checkToolAccessFn) {
399
474
  if (!TELEMETRY_ENABLED) return;
475
+ if (analyticsWrapperStates.has(server)) return;
476
+ const wrapperState = { countedToolNames: new Set() };
477
+ analyticsWrapperStates.set(server, wrapperState);
400
478
 
479
+ const resolveAuth = () => (typeof auth === 'function' ? auth() : auth);
401
480
  const _analyticsWrappedTool = server.tool.bind(server);
402
481
 
403
482
  server.tool = function analyticsTool(name, ...rest) {
404
483
  const handler = rest[rest.length - 1];
484
+ wrapperState.countedToolNames.add(name);
405
485
  rest[rest.length - 1] = async function analyticsHandler(...handlerArgs) {
406
486
  const start = Date.now();
407
487
  let success = true;
408
488
  let errorMsg = null;
489
+ const auth = resolveAuth();
409
490
 
410
491
  // Track tier gate if access was denied (check without modifying flow)
411
492
  const access = checkToolAccessFn(name, auth);
@@ -574,16 +655,18 @@ function parsePeriod(period) {
574
655
  * @param {object} opts - { z, api, auth, ok, checkToolAccess }
575
656
  */
576
657
  export function registerAnalytics(server, { z, api, auth, ok, checkToolAccess }) {
658
+ const resolveAuth = () => (typeof auth === 'function' ? auth() : auth);
659
+
577
660
  // Wire up the analytics wrapper on all future tool registrations
578
661
  wrapWithAnalytics(server, api, auth, checkToolAccess);
579
662
 
580
663
  // Start a session
581
- startSession(api, auth);
664
+ startSession(api, resolveAuth());
582
665
 
583
666
  // Graceful shutdown: end session on process exit
584
- process.on('beforeExit', () => endSession(api, auth));
585
- process.on('SIGINT', () => { endSession(api, auth); process.exit(0); });
586
- process.on('SIGTERM', () => { endSession(api, auth); process.exit(0); });
667
+ process.on('beforeExit', () => endSession(api, resolveAuth()));
668
+ process.on('SIGINT', () => { endSession(api, resolveAuth()); process.exit(0); });
669
+ process.on('SIGTERM', () => { endSession(api, resolveAuth()); process.exit(0); });
587
670
 
588
671
  // ----- get_mcp_analytics -----
589
672
  server.tool('get_mcp_analytics',
@@ -594,27 +677,65 @@ export function registerAnalytics(server, { z, api, auth, ok, checkToolAccess })
594
677
  async (args) => {
595
678
  const period = args.period || '7d';
596
679
 
597
- if (auth.authenticated) {
598
- // The MCP surface exposes no dedicated analytics route. The
599
- // subscription endpoint carries the server-side usage rollup
600
- // (usage.tool_invocations_30d) alongside plan/trial state.
680
+ // Coverage travels with every answer. `tool_invocations.total: 0` from a
681
+ // fully instrumented surface means nobody called anything; the same zero
682
+ // from a surface the wrapper never reached means nothing was measured.
683
+ // Reporting the second as the first is how this counter read 0 for 227
684
+ // of 254 tools without anyone noticing.
685
+ const coverage = getAnalyticsCoverage(server);
686
+
687
+ if (resolveAuth().authenticated) {
688
+ // Authenticated hosted sessions persist invocation rows in the API,
689
+ // not ~/.adrata. Reading the local dashboard here used to both report
690
+ // zero and attempt to mkdir /home/adrata/.adrata in the read-only ECS
691
+ // image. The profile endpoint is the canonical named-tool rollup.
601
692
  try {
602
- const data = await api('GET', '/api/v1/mcp/subscription');
603
- const payload = data?.data || data || {};
693
+ const profileData = await api('GET', '/api/v1/mcp/profile');
694
+ const profile = profileData?.data || profileData || {};
695
+ const stats = profile.stats || {};
696
+ const topTools = Array.isArray(stats.top_tools)
697
+ ? stats.top_tools.map((entry) => ({
698
+ name: entry.name || entry.tool,
699
+ count: entry.count,
700
+ }))
701
+ : [];
702
+
703
+ let usage = null;
704
+ try {
705
+ const subscriptionData = await api('GET', '/api/v1/mcp/subscription');
706
+ const subscription = subscriptionData?.data || subscriptionData || {};
707
+ usage = subscription.usage || null;
708
+ } catch {
709
+ // Named invocation analytics remain valid when billing is absent.
710
+ }
711
+
604
712
  return ok({
605
713
  period,
606
- server: payload,
607
- usage: payload.usage || null,
608
- local: getLocalDashboard(period),
714
+ coverage,
715
+ source: 'server',
716
+ server_scope: 'all_time',
717
+ period_note: 'The server profile currently reports all-time named invocation totals; period applies only to the local fallback.',
718
+ tool_invocations: {
719
+ total: Number(stats.total_queries || 0),
720
+ success: null,
721
+ errors: null,
722
+ avg_latency_ms: null,
723
+ top_tools: topTools,
724
+ },
725
+ server: profile,
726
+ usage,
727
+ server_events_30d: usage ? usage.tool_invocations_30d : null,
609
728
  });
610
729
  } catch {
611
- // Fallback to local if server endpoint not available
612
- return ok(getLocalDashboard(period));
730
+ // Offline-compatible fallback. readLocalAnalytics does not create a
731
+ // directory merely to answer an empty read, so this cannot break a
732
+ // read-only hosted container either.
733
+ return ok({ ...getLocalDashboard(period), coverage, source: 'local_fallback' });
613
734
  }
614
735
  }
615
736
 
616
737
  // Free tier: local analytics only
617
- return ok(getLocalDashboard(period));
738
+ return ok({ ...getLocalDashboard(period), coverage });
618
739
  }
619
740
  );
620
741
 
@@ -627,7 +748,7 @@ export function registerAnalytics(server, { z, api, auth, ok, checkToolAccess })
627
748
  trigger: z.string().optional().describe('What triggered the conversion'),
628
749
  },
629
750
  async (args) => {
630
- trackConversion(api, auth, args.from_tier, args.to_tier, args.trigger || 'manual');
751
+ trackConversion(api, resolveAuth(), args.from_tier, args.to_tier, args.trigger || 'manual');
631
752
  return ok({ tracked: true, from: args.from_tier, to: args.to_tier });
632
753
  }
633
754
  );
package/edge-block.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Tell an edge block apart from an authorization failure.
3
+ *
4
+ * The problem this exists for. api.adrata.com resolves to the ALB directly, and
5
+ * an AWS WAF web ACL sits on that ALB. When a managed rule matches a REQUEST
6
+ * BODY the request is refused at the edge and never reaches the Rust API, so
7
+ * the caller gets a bare HTML page:
8
+ *
9
+ * <html>
10
+ * <head><title>403 Forbidden</title></head>
11
+ * <body>
12
+ * <center><h1>403 Forbidden</h1></center>
13
+ * </body>
14
+ * </html>
15
+ *
16
+ * 403 is also what the API returns for a genuine `insufficient_scope`. Nothing
17
+ * in the old error message distinguished them, so the reasonable conclusion was
18
+ * "my token lost board write access" — and the reasonable next step was to ask
19
+ * the owner to reconnect the workspace. That reconnect loop has been run more
20
+ * than once for a cause that had nothing to do with credentials.
21
+ *
22
+ * The tell is decisive and needs no guessing. The Adrata API serialises EVERY
23
+ * error through one `IntoResponse` impl that always emits a JSON envelope
24
+ * (`code/api/crates/core/src/error.rs`), and its fallback route is JSON-only.
25
+ * An HTML body from this host is therefore structurally impossible to have come
26
+ * from the application. Something in front of it answered.
27
+ *
28
+ * Measured 2026-08-29 against production, same endpoint, same session, same
29
+ * everything but the body text:
30
+ *
31
+ * plain prose ....................... 404 JSON (reached the app)
32
+ * markdown table, pipes only ........ 404 JSON (reached the app)
33
+ * fenced code block, backticks ...... 404 JSON (reached the app)
34
+ * JSX with angle brackets ........... 404 JSON (reached the app)
35
+ * "aaa ../ bbb" ..................... 403 HTML (refused at the edge)
36
+ * "link:../../packages/..." ......... 403 HTML (refused at the edge)
37
+ * "the file /etc/passwd is not read" 404 JSON (reached the app)
38
+ *
39
+ * So the trigger is the path-traversal token `../`, not markdown structure —
40
+ * which matters, because "avoid tables and code fences" is the wrong lesson and
41
+ * would quietly strip evidence out of bug reports for no reason.
42
+ */
43
+
44
+ /** Substrings measured to be refused at the edge, most specific first. */
45
+ const MEASURED_TRIGGERS = [
46
+ { pattern: '../', label: 'a relative path segment (`../`)' },
47
+ { pattern: '..\\', label: 'a Windows relative path segment (`..\\`)' },
48
+ { pattern: '..%2f', label: 'a percent-encoded path segment (`..%2f`)' },
49
+ { pattern: '..%5c', label: 'a percent-encoded path segment (`..%5c`)' },
50
+ ];
51
+
52
+ /**
53
+ * True when a response body cannot have come from the Adrata API.
54
+ *
55
+ * Deliberately narrow. It looks for an HTML document rather than for "not
56
+ * JSON": an empty body, a timeout, or a truncated read are different failures
57
+ * and must not be reported as an edge block.
58
+ */
59
+ export function looksLikeEdgeHtml(text) {
60
+ if (typeof text !== 'string') return false;
61
+ const head = text.trimStart().slice(0, 400).toLowerCase();
62
+ if (!head.startsWith('<html') && !head.startsWith('<!doctype html')) return false;
63
+ return head.includes('<title>') || head.includes('<h1>');
64
+ }
65
+
66
+ /**
67
+ * Name the content the edge is most likely to have objected to.
68
+ *
69
+ * Returns every measured trigger present, because a body often carries more
70
+ * than one and fixing only the first sends the caller round again. Unknown is
71
+ * reported as unknown: a body with no measured trigger returns an empty list
72
+ * rather than a guess, so the caller is never told to edit the wrong sentence.
73
+ */
74
+ export function offendingContent(requestBody) {
75
+ if (requestBody == null) return [];
76
+ let serialized;
77
+ try {
78
+ serialized = typeof requestBody === 'string' ? requestBody : JSON.stringify(requestBody);
79
+ } catch {
80
+ return [];
81
+ }
82
+ if (typeof serialized !== 'string') return [];
83
+ const haystack = serialized.toLowerCase();
84
+ return MEASURED_TRIGGERS.filter(({ pattern }) => haystack.includes(pattern)).map(
85
+ ({ label }) => label
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Build the message for a request the edge refused, or return null when this
91
+ * was an ordinary API error and the caller should report it unchanged.
92
+ */
93
+ export function describeEdgeBlock({ status, text, method, path, requestBody } = {}) {
94
+ if (!looksLikeEdgeHtml(text)) return null;
95
+ if (typeof status !== 'number' || status < 400 || status > 499) return null;
96
+
97
+ const found = offendingContent(requestBody);
98
+ const because = found.length
99
+ ? `The request body contains ${found.join(' and ')}, which the edge's path-traversal rule matches.`
100
+ : 'No known trigger was found in this request body, so the matching rule is not one already measured. Report the request id and the body, and check the WAF sampled requests.';
101
+
102
+ return [
103
+ `API ${method} ${path} → ${status}, refused at the network edge before it reached Adrata.`,
104
+ '',
105
+ 'This is NOT an authentication or scope failure, and reconnecting the workspace will not',
106
+ 'change it. The proof is the response body: the Adrata API serialises every error as JSON,',
107
+ 'so an HTML error page from this host was produced by something in front of it (an AWS WAF',
108
+ 'managed rule on the ALB). Your session is unaffected — do not call connect_workspace, and',
109
+ 'do not ask the owner to reconnect.',
110
+ '',
111
+ because,
112
+ '',
113
+ 'What to do: rewrite the offending text and resend. A relative path reads the same as',
114
+ '`code/desktop` or `<repo root>/packages/client-runtime`, and the record is no worse for it.',
115
+ 'Do NOT strip out markdown tables or fenced code blocks — those were measured to pass, and',
116
+ 'removing them only makes the record vaguer.',
117
+ ].join('\n');
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.7",
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 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"
12
+ "test": "node --test analytics.test.js server.test.js api-bridge.test.js edge-block.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/competitive-coverage.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 tools/source-control/connection-tools.test.js governance/money.test.js"
13
13
  },
14
14
  "keywords": [
15
15
  "mcp",
@@ -44,6 +44,7 @@
44
44
  "api-bridge.js",
45
45
  "analytics.js",
46
46
  "security.js",
47
+ "edge-block.js",
47
48
  "transport-http.js",
48
49
  "resources.js",
49
50
  "tool-annotations.js",