@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.
@@ -8,17 +8,19 @@
8
8
  */
9
9
 
10
10
  const TERMINAL_STAGES = new Set(['production', 'deep backlog']);
11
- // Up Next is the deliberately unowned pull buffer. Triage/Aligning are capture
12
- // and grooming, where thin cards are allowed. Only these stages mean somebody
11
+ // Backlog is thin capture, and Up Next is the deliberately unowned pull buffer.
12
+ // These stages mean somebody
13
13
  // has actually taken a pass and therefore require both an end-to-end owner and
14
14
  // a current handler.
15
- const ACTIVE_STAGES = new Set(['in progress', 'staging qa1', 'staging qa2']);
15
+ const ACTIVE_STAGES = new Set(['aligning', 'in progress', 'staging qa1', 'staging qa2']);
16
16
  // A card crossing the cut line into Up Next must be executable. Production is
17
17
  // included because historical evidence does not stop mattering after release.
18
18
  const EXECUTABLE_STAGES = new Set(['up next', ...ACTIVE_STAGES, 'production']);
19
19
 
20
20
  function normalized(value) {
21
- return String(value ?? '').trim().toLowerCase();
21
+ return String(value ?? '')
22
+ .trim()
23
+ .toLowerCase();
22
24
  }
23
25
 
24
26
  function hoursSince(iso, nowMs) {
@@ -96,7 +98,8 @@ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
96
98
  } else if (item.criteria === undefined) {
97
99
  add('criteria_not_measured', 'This read did not include acceptance-criteria status.');
98
100
  }
99
- if (!item.kind) add('missing_kind', 'The card is not classified as a story, bug, or chore.');
101
+ if (!item.kind)
102
+ add('missing_kind', 'The card is not classified as a story, bug, or chore.');
100
103
  }
101
104
  if (isActive && !item.assigneeUserId) {
102
105
  add('unowned_active_card', 'The card is active but has no end-to-end owner.');
@@ -107,7 +110,12 @@ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
107
110
 
108
111
  const staleAfter = column?.staleness?.staleAfterHours;
109
112
  const ageHours = hoursSince(item.enteredColumnAt, nowMs);
110
- if (!isTerminal && Number.isFinite(staleAfter) && ageHours !== null && ageHours > staleAfter) {
113
+ if (
114
+ !isTerminal &&
115
+ Number.isFinite(staleAfter) &&
116
+ ageHours !== null &&
117
+ ageHours > staleAfter
118
+ ) {
111
119
  add(
112
120
  'stale_open_card',
113
121
  `The card has spent ${Math.floor(ageHours)}h in ${stage}; this column is stale after ${staleAfter}h.`
@@ -115,7 +123,9 @@ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
115
123
  }
116
124
  }
117
125
 
118
- findings.push(...boardFindings.map((finding) => ({ boardId: board.id, boardName: board.name, ...finding })));
126
+ findings.push(
127
+ ...boardFindings.map((finding) => ({ boardId: board.id, boardName: board.name, ...finding }))
128
+ );
119
129
  perBoard.push({
120
130
  boardId: board.id,
121
131
  boardName: board.name,
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Coverage for the two competitive-intelligence reads, both of which used to answer zero with
3
+ * no denominator and no statement of what they had actually asked.
4
+ *
5
+ * `get_competitor_intel` was documented as competitive intelligence FOR A NAMED COMPANY and
6
+ * sent `companyId` to a handler that took no query extractor at all: the parameter was
7
+ * discarded without error, and the workspace's whole competitor-definition list came back. So
8
+ * the tool returned the same answer for every company, and on an unpopulated workspace
9
+ * returned `count: 0`. A seller could not tell "no competitors at this account" from "we never
10
+ * scoped this to the account" from "ingest is broken".
11
+ *
12
+ * `list_customer_signals` passed its endpoint straight through and returned a bare `items: []`.
13
+ *
14
+ * The repo already knows how to do this — `get_installed_base` and `get_icp_distribution` carry
15
+ * coverage blocks saying in words that zero invoices means nothing has been ingested rather
16
+ * than zero spend, `check_inbox` checks provider connectivity before reporting no replies, and
17
+ * `describeCount` states a denominator or explains why it cannot. These two tools predate that
18
+ * discipline; this brings them forward.
19
+ *
20
+ * Kept separate from `server.js` so the composition can be tested: `server.js` auto-connects a
21
+ * transport on import and cannot be loaded by a unit test.
22
+ */
23
+
24
+ import { describeCount } from '../spaces.js';
25
+
26
+ /** The rows out of an API envelope, whatever shape it came in as. Null when it is not a list. */
27
+ function rowsOf(payload) {
28
+ if (Array.isArray(payload)) return payload;
29
+ const data = payload?.data;
30
+ if (Array.isArray(data)) return data;
31
+ if (Array.isArray(data?.data)) return data.data;
32
+ return null;
33
+ }
34
+
35
+ /** The signal rows out of `SignalListResponse`, or null when the body was not a list. */
36
+ function signalRows(payload) {
37
+ if (Array.isArray(payload?.items)) return payload.items;
38
+ return rowsOf(payload);
39
+ }
40
+
41
+ /** A count the API stated, as opposed to one we inferred from a truncated page. */
42
+ function statedTotal(payload) {
43
+ const meta = payload?.meta ?? payload?.data?.meta;
44
+ const total = meta?.total ?? meta?.count;
45
+ return typeof total === 'number' ? total : null;
46
+ }
47
+
48
+ /**
49
+ * Compose the answer for one company from the workspace's competitor definitions and that
50
+ * company's mentions.
51
+ *
52
+ * The two halves have DIFFERENT SCOPES and saying so is most of the fix. Competitor
53
+ * definitions are a workspace-level configuration — the list of rivals this workspace tracks —
54
+ * and were previously returned as though they were an answer about the account. Mentions are
55
+ * the company-scoped evidence, and they live one route over.
56
+ *
57
+ * `mentionsUnavailable` is a third state again: a mentions read that FAILED must not be
58
+ * reported as a company with no mentions.
59
+ */
60
+ export function composeCompetitorIntel({
61
+ companyId,
62
+ definitionsPayload,
63
+ mentionsPayload,
64
+ workspaceMentionsPayload,
65
+ mentionsUnavailable = false,
66
+ }) {
67
+ const definitions = rowsOf(definitionsPayload);
68
+ const mentions = mentionsUnavailable ? null : rowsOf(mentionsPayload);
69
+ // The denominator: how many competitor mentions this workspace holds AT ALL. Without it
70
+ // "0 mentions" is indistinguishable from a store nothing has written to, which is the exact
71
+ // ambiguity a seller cannot resolve from the tool output.
72
+ const workspaceMentionTotal = mentionsUnavailable ? null : statedTotal(workspaceMentionsPayload);
73
+
74
+ const coverage = { companyId, scope: 'One company, read from two differently-scoped stores.' };
75
+
76
+ if (definitions === null) {
77
+ coverage.definitions =
78
+ 'Could not read competitor definitions: the endpoint responded, but the body was not a collection. Treat this as a broken read, NOT as zero.';
79
+ } else if (definitions.length === 0) {
80
+ coverage.definitions =
81
+ 'No competitors are defined for this workspace at all, so nothing can be attributed to any account. This is an UNCONFIGURED store, not evidence that this company faces no competition. Define competitors first (POST /api/v1/competitors).';
82
+ } else {
83
+ coverage.definitions = `${definitions.length} competitor definitions are configured. These are WORKSPACE-WIDE — they are the rivals this workspace tracks, not a finding about this company.`;
84
+ }
85
+
86
+ if (mentionsUnavailable) {
87
+ coverage.mentions =
88
+ 'Could not read competitor mentions for this company. This is unknown, not zero — do not report that the account has no competitive activity.';
89
+ } else if (mentions === null) {
90
+ coverage.mentions =
91
+ 'Could not read competitor mentions: the endpoint responded, but the body was not a collection. Treat this as a broken read, NOT as zero.';
92
+ } else {
93
+ coverage.mentions = describeCount({
94
+ found: statedTotal(mentionsPayload) ?? mentions.length,
95
+ of: workspaceMentionTotal ?? undefined,
96
+ noun: 'competitor mentions attributed to this company',
97
+ ofNoun: 'competitor mentions in this workspace',
98
+ });
99
+ }
100
+
101
+ return {
102
+ companyId,
103
+ competitorDefinitions: definitions ?? [],
104
+ mentionsForCompany: mentions ?? [],
105
+ coverage,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Attach a denominator, or an explanation of why there is none, to a customer-signal read.
111
+ *
112
+ * An empty `items` array is the ambiguous case and the only one worth spending a second
113
+ * request on: zero rows for these filters is a real answer if the store holds signals, and a
114
+ * completely different answer if the store has never been written to. `workspaceProbePayload`
115
+ * is that unfiltered probe, and is expected to be absent when there were rows to report.
116
+ */
117
+ export function composeCustomerSignals({ payload, filtersApplied, workspaceProbePayload }) {
118
+ const items = signalRows(payload);
119
+ const named = Object.entries(filtersApplied ?? {})
120
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
121
+ .map(([k]) => k);
122
+ const filterNote = named.length
123
+ ? `Filters applied: ${named.join(', ')}.`
124
+ : 'No filters applied beyond the workspace scope.';
125
+
126
+ if (items === null) {
127
+ return {
128
+ ...payload,
129
+ coverage: {
130
+ signals:
131
+ 'Could not read customer signals: the endpoint responded, but the body was not a collection. Treat this as a broken read, NOT as zero.',
132
+ },
133
+ };
134
+ }
135
+
136
+ if (items.length > 0) {
137
+ return {
138
+ ...payload,
139
+ coverage: { signals: describeCount({ found: items.length, noun: 'customer signals' }), filters: filterNote },
140
+ };
141
+ }
142
+
143
+ // `undefined` means the probe was not run; `null` means it ran and could not be read. Those
144
+ // are different sentences, and collapsing them would reintroduce the defect one level up.
145
+ const workspaceRows =
146
+ workspaceProbePayload === undefined ? undefined : signalRows(workspaceProbePayload);
147
+
148
+ let signals;
149
+ if (workspaceProbePayload === undefined) {
150
+ signals =
151
+ 'Zero signals for these filters, and the workspace total was not checked, so this cannot distinguish an empty result from a store nothing has ever written to. Say so rather than reporting "none".';
152
+ } else if (workspaceRows === null) {
153
+ signals =
154
+ 'Zero signals for these filters, and the workspace-wide probe could not be read. Treat this as unknown, NOT as zero.';
155
+ } else if (workspaceRows.length === 0) {
156
+ signals =
157
+ 'The customer_signals store holds NO rows for this workspace at all — nothing has been ingested (webhook, manual log, or BYOK adapter). This is an unwritten store, not an absence of buying activity.';
158
+ } else {
159
+ signals =
160
+ 'Zero signals match these filters, but the store does hold signals for this workspace. This is a real zero for the filters, not a failed read — try widening them.';
161
+ }
162
+
163
+ return { ...payload, coverage: { signals, filters: filterNote } };
164
+ }
package/transport-http.js CHANGED
@@ -18,12 +18,17 @@
18
18
 
19
19
  import http from 'node:http';
20
20
  import crypto from 'node:crypto';
21
+ import { readFileSync } from 'node:fs';
21
22
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
22
23
  import { authenticate } from './access/auth.js';
23
24
  import { getCorsHeaders, isOriginAllowed, checkTransportSecurity, isIpAllowed, checkRateLimit, rateLimitResponse as buildRateLimitResponse } from './security.js';
24
25
  import { protectedResourceMetadata, validateTokenAudience } from './access/resource-metadata.js';
25
26
 
26
27
  const PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource';
28
+ const PACKAGE_VERSION = JSON.parse(
29
+ readFileSync(new URL('./package.json', import.meta.url), 'utf8')
30
+ ).version;
31
+ const BUILD_SHA = process.env.ADRATA_MCP_BUILD_SHA?.trim() || 'unknown';
27
32
 
28
33
  const PORT = parseInt(process.env.ADRATA_MCP_PORT || '3100', 10);
29
34
  const CORS_ORIGINS = process.env.ADRATA_MCP_CORS_ORIGINS || '*';
@@ -55,7 +60,7 @@ function parsePositiveInt(value, fallback) {
55
60
  // connect it to the server, and let the SDK assign a session ID.
56
61
  // ---------------------------------------------------------------------------
57
62
 
58
- /** @type {Map<string, { transport: StreamableHTTPServerTransport, principal: string, lastSeenAt: number }>} */
63
+ /** @type {Map<string, { transport: StreamableHTTPServerTransport, server: import('@modelcontextprotocol/sdk/server/mcp.js').McpServer, principal: string, lastSeenAt: number }>} */
59
64
  const sessions = new Map();
60
65
  const validatedPrincipals = new Map();
61
66
 
@@ -72,7 +77,12 @@ async function validateHostedBearer(auth, token, principal) {
72
77
  if (cachedUntil > Date.now()) return true;
73
78
 
74
79
  const apiBase = auth.apiUrl || process.env.ADRATA_API_URL || 'https://api.adrata.com';
75
- const validationPath = process.env.ADRATA_MCP_TOKEN_VALIDATION_PATH || '/api/v1/capabilities/workspace';
80
+ // The board capability inventory is an authenticated, read-only endpoint
81
+ // carried by every hosted MCP grant (`read:tasks`). Do not use the legacy
82
+ // `/api/v1/capabilities/workspace` route here: that platform-capability
83
+ // surface is not implemented in the live API and returns 404, which made a
84
+ // correctly signed, audience-bound token look invalid at this boundary.
85
+ const validationPath = process.env.ADRATA_MCP_TOKEN_VALIDATION_PATH || '/api/v1/work-capabilities';
76
86
  const validationUrl = new URL(validationPath, apiBase);
77
87
 
78
88
  try {
@@ -262,11 +272,15 @@ function readBody(req, maxBytes = MAX_BODY_BYTES) {
262
272
  /**
263
273
  * Start the Streamable HTTP server and return a cleanup function.
264
274
  *
265
- * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} mcpServer
266
- * The shared MCP server instance (same one used by stdio).
275
+ * @param {() => import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} createMcpServer
276
+ * Factory for a fresh protocol instance per hosted session. The SDK owns one
277
+ * transport per server instance and refuses a second connection.
267
278
  * @returns {Promise<http.Server>}
268
279
  */
269
- export async function startHttpTransport(mcpServer, options = {}) {
280
+ export async function startHttpTransport(createMcpServer, options = {}) {
281
+ if (typeof createMcpServer !== 'function') {
282
+ throw new TypeError('Hosted MCP transport requires a server factory.');
283
+ }
270
284
  const runWithAuthContext = options.runWithAuthContext || ((auth, fn) => fn());
271
285
 
272
286
  const httpServer = http.createServer(async (req, res) => {
@@ -283,7 +297,11 @@ export async function startHttpTransport(mcpServer, options = {}) {
283
297
 
284
298
  if (pathname === '/health') {
285
299
  setCorsHeaders(res, req);
286
- writeJson(res, 200, { status: 'ok' });
300
+ writeJson(res, 200, {
301
+ status: 'ok',
302
+ packageVersion: PACKAGE_VERSION,
303
+ buildSha: BUILD_SHA,
304
+ });
287
305
  return;
288
306
  }
289
307
 
@@ -417,8 +435,11 @@ export async function startHttpTransport(mcpServer, options = {}) {
417
435
  if (sid) sessions.delete(sid);
418
436
  };
419
437
 
420
- // Connect the MCP server to this transport
421
- await runWithAuthContext(auth, () => mcpServer.connect(transport));
438
+ // Each transport owns its protocol instance. Reusing one McpServer here
439
+ // makes the second client crash the whole process with "Already
440
+ // connected to a transport" and prevents concurrent hosted sessions.
441
+ const sessionServer = createMcpServer();
442
+ await runWithAuthContext(auth, () => sessionServer.connect(transport));
422
443
 
423
444
  // Handle the request (the SDK will set the session ID during init)
424
445
  await runWithAuthContext(auth, () => transport.handleRequest(req, res, body));
@@ -426,7 +447,7 @@ export async function startHttpTransport(mcpServer, options = {}) {
426
447
  // Store the session after initialization
427
448
  const sid = transport.sessionId;
428
449
  if (sid) {
429
- sessions.set(sid, { transport, principal, lastSeenAt: Date.now() });
450
+ sessions.set(sid, { transport, server: sessionServer, principal, lastSeenAt: Date.now() });
430
451
  }
431
452
  } else if (sessionId && sessions.has(sessionId)) {
432
453
  // Existing session