@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/README.md +29 -1
- package/access/auth.js +49 -1
- package/access/oauth.js +355 -26
- package/access/resource-metadata.js +9 -1
- package/access/tiers.js +17 -0
- package/analytics.js +141 -20
- package/api-bridge.js +16 -0
- package/package.json +2 -2
- package/server.js +109 -16
- package/server.json +14 -2
- package/skills/qa-the-card/SKILL.md +179 -0
- package/skills/ship-the-card/SKILL.md +31 -5
- package/tool-annotations.js +15 -1
- package/tools/source-control/connection-tools.js +31 -0
- package/tools/work-board-tools.js +1174 -30
- package/tools/work-hub/audit.js +17 -7
- package/toolsets/revenue/competitive-coverage.js +164 -0
- package/transport-http.js +30 -9
package/tools/work-hub/audit.js
CHANGED
|
@@ -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.
|
|
12
|
-
//
|
|
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 ?? '')
|
|
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)
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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}
|
|
266
|
-
*
|
|
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(
|
|
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, {
|
|
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
|
-
//
|
|
421
|
-
|
|
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
|