@adrata/adrata-mcp 1.0.2 → 1.0.6

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/access/tiers.js CHANGED
@@ -218,22 +218,39 @@ export const TOOL_TIERS = {
218
218
  // the tool — only exists on an OAuth token.
219
219
  list_my_work_items: TIERS.ENTERPRISE,
220
220
  list_work_boards: TIERS.ENTERPRISE,
221
+ list_source_control_connections: TIERS.ENTERPRISE,
222
+ get_source_control_connection_events: TIERS.ENTERPRISE,
221
223
  get_work_item_delivery_evidence: TIERS.ENTERPRISE,
222
224
  audit_work_hub: TIERS.ENTERPRISE,
223
225
  list_work_item_acceptance_criteria: TIERS.ENTERPRISE,
226
+ list_work_item_qa_evidence: TIERS.ENTERPRISE,
224
227
  get_work_board: TIERS.ENTERPRISE,
225
228
  get_work_item: TIERS.ENTERPRISE,
229
+ get_work_item_worker_lease: TIERS.ENTERPRISE,
230
+ get_work_item_worker_activity: TIERS.ENTERPRISE,
231
+ claim_work_item_qa_pass: TIERS.ENTERPRISE,
232
+ claim_next_work_item_qa_pass: TIERS.ENTERPRISE,
233
+ heartbeat_work_item_qa_pass: TIERS.ENTERPRISE,
234
+ release_work_item_qa_pass: TIERS.ENTERPRISE,
235
+ record_work_item_qa_failure_and_release: TIERS.ENTERPRISE,
236
+ requeue_work_item_qa_after_fix: TIERS.ENTERPRISE,
226
237
  get_work_item_history: TIERS.ENTERPRISE,
227
238
  get_work_item_comments: TIERS.ENTERPRISE,
228
239
  get_work_board_rollup: TIERS.ENTERPRISE,
229
240
  list_work_board_rollups: TIERS.ENTERPRISE,
230
241
  set_work_board_archived: TIERS.ENTERPRISE,
231
242
  set_work_board_column_wip_limit: TIERS.ENTERPRISE,
243
+ attach_work_item_qa_evidence: TIERS.ENTERPRISE,
244
+ verify_work_item_qa_evidence_playback: TIERS.ENTERPRISE,
232
245
  move_work_item: TIERS.ENTERPRISE,
246
+ transfer_work_item_between_boards: TIERS.ENTERPRISE,
233
247
  set_work_item_tag: TIERS.ENTERPRISE,
234
248
  set_work_item_kind: TIERS.ENTERPRISE,
235
249
  create_work_item: TIERS.ENTERPRISE,
236
250
  add_work_item_acceptance_criterion: TIERS.ENTERPRISE,
251
+ satisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
252
+ record_work_item_criterion_engineering_proof: TIERS.ENTERPRISE,
253
+ unsatisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
237
254
  comment_on_work_item: TIERS.ENTERPRISE,
238
255
  flag_work_item: TIERS.ENTERPRISE,
239
256
  // The containers above the cards, and the "add this to the roadmap" verb.
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/api-bridge.js CHANGED
@@ -171,6 +171,14 @@ const ALLOWED_PREFIXES = [
171
171
  '/api/v1/retention',
172
172
  '/api/v1/revenue',
173
173
  '/api/v1/revenue-cloud',
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',
174
182
  '/api/v1/scoring',
175
183
  '/api/v1/security',
176
184
  '/api/v1/self-service',
@@ -279,6 +287,14 @@ const PATH_WRITE_SCOPES = [
279
287
  // All three are listed separately because `matchesPathPrefix` is
280
288
  // segment-boundary matched: `/work-boards` does not annex
281
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'],
282
298
  ['/api/v1/work-boards', 'write:tasks'],
283
299
  ['/api/v1/work-items', 'write:tasks'],
284
300
  ['/api/v1/work-board-rollups', 'write:tasks'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.0.6",
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 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",
package/server.js CHANGED
@@ -50,13 +50,15 @@ import { connectWorkspace, disconnectWorkspace, getConnectionStatus, getValidTok
50
50
  import { registerEnterpriseTools } from './tools/enterprise-tools.js';
51
51
  import { registerEmailTools } from './tools/email-tools.js';
52
52
  import { registerWorkBoardTools } from './tools/work-board-tools.js';
53
+ import { registerSourceControlTools } from './tools/source-control/connection-tools.js';
53
54
  import { registerRoadmapTools } from './tools/roadmap-tools.js';
54
55
  import { registerPaperTools } from './tools/paper-tools.js';
55
56
  import { register as registerAlwaysLoadedTools } from './toolsets/revenue/always-loaded.js';
56
57
  import { register as registerExtensibilityTools } from './toolsets/extensibility.js';
57
58
  import { registerSpaceTools } from './toolsets/spaces.js';
59
+ import { composeCompetitorIntel, composeCustomerSignals } from './toolsets/revenue/competitive-coverage.js';
58
60
  import { getDemoAvailability, scheduleDemo, scheduleMeeting } from './tools/scheduling.js';
59
- import { registerAnalytics } from './analytics.js';
61
+ import { registerAnalytics, wrapWithAnalytics } from './analytics.js';
60
62
  import {
61
63
  apiBridgeCatalog,
62
64
  buildMutationHeaders,
@@ -131,7 +133,7 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
131
133
  const auth = currentAuth();
132
134
  assertOAuthIssuerMatchesTarget(auth, API_BASE);
133
135
  let currentToken = auth.token;
134
- if (auth.source === 'stored') {
136
+ if (auth.source === 'stored' || auth.source === 'stored_pool') {
135
137
  let freshToken;
136
138
  try {
137
139
  freshToken = await getValidToken(API_BASE);
@@ -177,7 +179,7 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
177
179
  };
178
180
 
179
181
  let res = await request(currentToken);
180
- if (res.status === 401 && auth.source === 'stored') {
182
+ if (res.status === 401 && (auth.source === 'stored' || auth.source === 'stored_pool')) {
181
183
  let refreshedToken;
182
184
  try {
183
185
  refreshedToken = await getValidToken(API_BASE, { forceRefresh: true });
@@ -298,6 +300,7 @@ const moneyWrite = {
298
300
  */
299
301
  const SERVER_NAME = process.env.ADRATA_MCP_SERVER_NAME?.trim() || '@adrata/adrata-mcp';
300
302
 
303
+ function createMcpServer() {
301
304
  const server = new McpServer({ name: SERVER_NAME, version: '1.0.0' });
302
305
 
303
306
  // ---------------------------------------------------------------------------
@@ -367,6 +370,26 @@ wrapWithEventLogging(server, api, AUTH);
367
370
  // Must be applied AFTER tier-gating and event logging wrappers above.
368
371
  applySecurityLayer(server, currentAuth, api);
369
372
 
373
+ // ---------------------------------------------------------------------------
374
+ // Analytics wrapper
375
+ // ---------------------------------------------------------------------------
376
+ // MUST be installed here — after the wrappers above, and BEFORE the first
377
+ // server.tool(...) call below. It monkey-patches `server.tool`, so it can only
378
+ // instrument FUTURE registrations. It used to be installed from
379
+ // registerAnalytics() ~1975 lines further down, which put 227 of the 254 tools
380
+ // this server exposes ahead of it: trackToolInvocation and recordSessionTool
381
+ // could not fire for 89% of the surface, and get_mcp_analytics reported
382
+ // tool_invocations.total: 0 as though it were a measurement.
383
+ //
384
+ // The cost is deliberate and worth naming: every counted invocation now also
385
+ // fire-and-forget POSTs a `tool_invocation` event, roughly doubling the writes
386
+ // on /api/v1/mcp/events (the event-logging wrapper above already posts one per
387
+ // call for the whole surface). ADRATA_TELEMETRY=off disables both.
388
+ //
389
+ // registerAnalytics() still calls this; the wrapper is idempotent, so the later
390
+ // call is a no-op rather than a second layer.
391
+ wrapWithAnalytics(server, api, currentAuth, checkToolAccess);
392
+
370
393
  // ===== WORKSPACE CONNECTION: OAuth 2.0 (available at all tiers) =====
371
394
 
372
395
  server.tool('connect_workspace',
@@ -381,7 +404,18 @@ server.tool('connect_workspace',
381
404
  AUTH = reauthenticate();
382
405
  return ok(result);
383
406
  } catch (err) {
384
- return ok({ error: true, message: err.message, hint: 'Make sure your browser is accessible and try again.' });
407
+ // `invalid_scope` is not a browser problem, and saying it is costs real
408
+ // time: it means the authorization server rejected one of the scopes we
409
+ // asked for, which in practice means the deployed API is running behind
410
+ // this client. The whole request is refused, so read-only will still
411
+ // connect — that asymmetry is the tell.
412
+ const hint = /invalid_scope/.test(err.message ?? '')
413
+ ? 'The API rejected a requested scope, so it granted nothing — this is NOT a browser problem. '
414
+ + 'The deployed API is probably behind this client. Compare them: '
415
+ + 'curl -s ' + API_BASE + '/health | jq -r .build_sha '
416
+ + 'Read-only connect_workspace() should still work; use it to keep going.'
417
+ : 'Make sure your browser is accessible and try again.';
418
+ return ok({ error: true, message: err.message, hint });
385
419
  }
386
420
  });
387
421
 
@@ -412,7 +446,7 @@ server.tool('workspace_status',
412
446
  // answer from a diagnostic: it sends someone to reconnect a connection that
413
447
  // was never the problem, and it is the tool a connect flow tells you to run
414
448
  // to confirm the setup worked.
415
- const usingStoredSession = AUTH.source === 'stored';
449
+ const usingStoredSession = AUTH.source === 'stored' || AUTH.source === 'stored_pool';
416
450
  const stored = await getConnectionStatus(API_BASE);
417
451
 
418
452
  // AUTH.workspaceId is only populated when the workspace was pinned via env
@@ -452,8 +486,11 @@ server.tool('workspace_status',
452
486
  return ok({
453
487
  ...stored,
454
488
  tier: stored.connected ? AUTH.tier : TIERS.FREE,
455
- credential: 'stored OAuth session (~/.adrata/tokens.json)',
489
+ credential: AUTH.source === 'stored_pool'
490
+ ? `dedicated OAuth session for named identity pool ${AUTH.identityPool}`
491
+ : 'stored OAuth session (~/.adrata/tokens.json)',
456
492
  authSource: AUTH.source,
493
+ identityPool: AUTH.identityPool || null,
457
494
  apiBase: API_BASE,
458
495
  workspaceId,
459
496
  workspaceName,
@@ -1756,7 +1793,7 @@ server.tool('get_intent_signals', 'Get buying intent signals for a company.',
1756
1793
  // are workspace-scoped from the token; filter by subject (a company or person),
1757
1794
  // source, signal type, or minimum score, and page with the returned cursor.
1758
1795
  server.tool('list_customer_signals',
1759
- 'List first-party customer signals (the store live plays fire from) for the workspace, or scope to one company/person via subjectType + subjectId. Distinct from get_intent_signals (third-party intent).',
1796
+ 'List first-party customer signals (the store live plays fire from) for the workspace, or scope to one company/person via subjectType + subjectId. Distinct from get_intent_signals (third-party intent). Always relay the returned `coverage`: an empty result is checked against the unfiltered workspace, so a real zero is distinguished from a store nothing has ever written to.',
1760
1797
  {
1761
1798
  subjectType: z.enum(['company', 'person']).optional().describe('Scope to a subject kind; pair with subjectId'),
1762
1799
  subjectId: z.string().optional().describe('Company or person ID to scope signals to'),
@@ -1768,11 +1805,31 @@ server.tool('list_customer_signals',
1768
1805
  limit: z.number().optional().describe('Page size (default 50, max 500)'),
1769
1806
  cursor: z.string().optional().describe('Opaque cursor from a previous page'),
1770
1807
  },
1771
- async (a) => ok(await api('GET', '/api/v1/customer-signals', { params: {
1772
- subjectType: a.subjectType, subjectId: a.subjectId, source: a.source,
1773
- signalType: a.signalType, minScore: a.minScore, since: a.since,
1774
- until: a.until, limit: a.limit || 50, cursor: a.cursor,
1775
- } })));
1808
+ async (a) => {
1809
+ const filtersApplied = {
1810
+ subjectType: a.subjectType, subjectId: a.subjectId, source: a.source,
1811
+ signalType: a.signalType, minScore: a.minScore, since: a.since, until: a.until,
1812
+ };
1813
+ const payload = await api('GET', '/api/v1/customer-signals', { params: {
1814
+ ...filtersApplied, limit: a.limit || 50, cursor: a.cursor,
1815
+ } });
1816
+
1817
+ // The probe is spent ONLY on the ambiguous case. Rows returned means the store is
1818
+ // demonstrably written to and the count speaks for itself; an empty array is the reading a
1819
+ // seller cannot interpret, and one extra request is what separates "no signals match these
1820
+ // filters" from "nothing has ever been ingested".
1821
+ const empty = Array.isArray(payload?.items) ? payload.items.length === 0 : undefined;
1822
+ let workspaceProbePayload;
1823
+ if (empty === true) {
1824
+ try {
1825
+ workspaceProbePayload = await api('GET', '/api/v1/customer-signals', { params: { limit: 1 } });
1826
+ } catch {
1827
+ workspaceProbePayload = null;
1828
+ }
1829
+ }
1830
+
1831
+ return ok(composeCustomerSignals({ payload, filtersApplied, workspaceProbePayload }));
1832
+ });
1776
1833
 
1777
1834
  server.tool('get_deal_authority', 'Get authority mapping and stakeholder analysis for a deal.',
1778
1835
  { opportunityId: z.string() },
@@ -1781,9 +1838,39 @@ server.tool('get_deal_authority', 'Get authority mapping and stakeholder analysi
1781
1838
  params: { opportunityId: a.opportunityId },
1782
1839
  })));
1783
1840
 
1784
- server.tool('get_competitor_intel', 'Get competitive intelligence for a company.',
1841
+ server.tool('get_competitor_intel',
1842
+ 'Competitive intelligence for ONE company: the competitor mentions attributed to that account, plus the workspace-wide competitor definitions they are scored against. Always relay the returned `coverage` — it says which half is company-scoped and which is workspace-wide, and states whether a zero is a real zero or an unconfigured store. `/api/v1/competitors` is a workspace configuration list and takes no company filter; the company-scoped evidence lives at `/api/v1/competitors/mentions?companyId=`.',
1785
1843
  { companyId: z.string() },
1786
- async (a) => ok(await api('GET', `/api/v1/competitors`, { params: { companyId: a.companyId } })));
1844
+ async (a) => {
1845
+ // The definition list is workspace-scoped by construction. Sending `companyId` to it — as
1846
+ // this tool used to — got the parameter silently discarded and returned the same answer for
1847
+ // every company. The two reads are separate because their SCOPES are separate.
1848
+ const definitionsPayload = await api('GET', '/api/v1/competitors');
1849
+
1850
+ let mentionsPayload;
1851
+ let workspaceMentionsPayload;
1852
+ let mentionsUnavailable = false;
1853
+ try {
1854
+ mentionsPayload = await api('GET', '/api/v1/competitors/mentions', {
1855
+ params: { companyId: a.companyId, limit: 50 },
1856
+ });
1857
+ // The denominator. One extra cheap request buys the difference between "this account is
1858
+ // quiet" and "nothing has ever been scanned".
1859
+ workspaceMentionsPayload = await api('GET', '/api/v1/competitors/mentions', {
1860
+ params: { limit: 1 },
1861
+ });
1862
+ } catch {
1863
+ mentionsUnavailable = true;
1864
+ }
1865
+
1866
+ return ok(composeCompetitorIntel({
1867
+ companyId: a.companyId,
1868
+ definitionsPayload,
1869
+ mentionsPayload,
1870
+ workspaceMentionsPayload,
1871
+ mentionsUnavailable,
1872
+ }));
1873
+ });
1787
1874
 
1788
1875
  // ===== ANALYTICS =====
1789
1876
 
@@ -2266,6 +2353,8 @@ registerWorkBoardTools(server, {
2266
2353
  getGrantedScope: () => loadTokens()?.scope,
2267
2354
  });
2268
2355
 
2356
+ registerSourceControlTools(server, { z, api, ok });
2357
+
2269
2358
  // The containers above the cards, and the "add this to the roadmap" verb
2270
2359
  // (company/decisions/2026-08-06-spoq-roadmap-sync.md).
2271
2360
  registerRoadmapTools(server, {
@@ -2323,7 +2412,7 @@ server.tool('schedule_meeting',
2323
2412
  // Tracks tool invocations, search queries, tier gates, conversions, sessions.
2324
2413
  // Builds on top of existing event logging — adds classification and dashboards.
2325
2414
  // ADRATA_TELEMETRY=off disables all tracking. PII is hashed before storage.
2326
- registerAnalytics(server, { z, api, auth: AUTH, ok, checkToolAccess });
2415
+ registerAnalytics(server, { z, api, auth: currentAuth, ok, checkToolAccess });
2327
2416
 
2328
2417
  // ===== COMPOSITE TOOLSETS =====
2329
2418
  // 42 composite tools organized into 7 toolsets with dynamic discovery.
@@ -2344,6 +2433,9 @@ registerExtensibilityTools(server, api, AUTH);
2344
2433
  // Service" got nothing, while the app answered it fine.
2345
2434
  registerSpaceTools(server, { z, api, ok });
2346
2435
 
2436
+ return server;
2437
+ }
2438
+
2347
2439
  // ---------------------------------------------------------------------------
2348
2440
  // Start
2349
2441
  // ---------------------------------------------------------------------------
@@ -2353,11 +2445,12 @@ const TRANSPORT_MODE = (process.env.ADRATA_MCP_TRANSPORT || 'stdio').toLowerCase
2353
2445
  if (TRANSPORT_MODE === 'http' || TRANSPORT_MODE === 'sse') {
2354
2446
  // Streamable HTTP transport — single POST /mcp endpoint that can upgrade to SSE
2355
2447
  const { startHttpTransport } = await import('./transport-http.js');
2356
- await startHttpTransport(server, {
2448
+ await startHttpTransport(createMcpServer, {
2357
2449
  runWithAuthContext,
2358
2450
  });
2359
2451
  } else {
2360
2452
  // Default: stdio transport (backward compatible)
2453
+ const server = createMcpServer();
2361
2454
  const transport = new StdioServerTransport();
2362
2455
  await server.connect(transport);
2363
2456
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "com.adrata/adrata-mcp",
4
4
  "description": "Adrata revenue-intelligence MCP server: companies, people, opportunities, actions, buyer groups, enrichment, email, and workspace operations for AI agents.",
5
5
  "status": "active",
6
- "version": "1.0.0",
6
+ "version": "1.0.6",
7
7
  "websiteUrl": "https://adrata.com/developers",
8
8
  "repository": {
9
9
  "url": "https://github.com/adrata/adrata",
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "@adrata/adrata-mcp",
18
- "version": "1.0.0",
18
+ "version": "1.0.6",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },
@@ -31,6 +31,18 @@
31
31
  "description": "Enterprise OAuth 2.1 access token, audience-bound to the Adrata MCP resource (RFC 8707).",
32
32
  "isRequired": false,
33
33
  "isSecret": true
34
+ },
35
+ {
36
+ "name": "ADRATA_MCP_IDENTITY_POOL",
37
+ "description": "Optional named worker identity (for example qa1 or qa2). Named pools fail closed and only use their dedicated stored OAuth session.",
38
+ "isRequired": false,
39
+ "isSecret": false
40
+ },
41
+ {
42
+ "name": "ADRATA_MCP_CONFIG_DIR",
43
+ "description": "OAuth storage directory. Required and non-default when ADRATA_MCP_IDENTITY_POOL is set.",
44
+ "isRequired": false,
45
+ "isSecret": false
34
46
  }
35
47
  ]
36
48
  }