@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,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Space tools for the Adrata MCP server — the nine surfaces a person now
|
|
3
|
+
* chooses between, made reachable by an agent.
|
|
4
|
+
*
|
|
5
|
+
* # Why this file exists
|
|
6
|
+
*
|
|
7
|
+
* The app grew a two-step chooser at `/spaces` over nine spaces in three
|
|
8
|
+
* families (Demand: Story, Market, Sell · Supply: Build, Design, Service ·
|
|
9
|
+
* Company: People, Finance, Cloud). Six of those nine read data this server
|
|
10
|
+
* had no tool for. An agent could not answer "how healthy are our customers",
|
|
11
|
+
* "what content do we have", "what is actually connected", or even "which
|
|
12
|
+
* spaces does this workspace have" — questions a person answers by clicking
|
|
13
|
+
* once.
|
|
14
|
+
*
|
|
15
|
+
* The gap was not that the endpoints were secret. It was that reaching them
|
|
16
|
+
* required `adrata_api_request` with a hand-written path, which is the raw
|
|
17
|
+
* bridge this server's typed tools exist to replace: no shape, no denominator,
|
|
18
|
+
* no idea which of several endpoints a surface actually joins. `/service`
|
|
19
|
+
* reads `expansion/health` AND `companies`; `/market` derives its segments
|
|
20
|
+
* from four collections at once. A tool that knows the join is the difference
|
|
21
|
+
* between an agent answering the question and an agent guessing at it.
|
|
22
|
+
*
|
|
23
|
+
* # Every tool here is a READ
|
|
24
|
+
*
|
|
25
|
+
* Deliberately. The nine spaces introduced no new write endpoints — they are
|
|
26
|
+
* projections over records that already have governed write tools (a deal is
|
|
27
|
+
* still written through the opportunity tools, a task through the action
|
|
28
|
+
* tools). Adding ungoverned writes here to "complete" a space would recreate
|
|
29
|
+
* exactly the defect the `governedWrite` docblock in ../api-bridge.js was
|
|
30
|
+
* written to close. When a space does grow a write, it routes through
|
|
31
|
+
* `governedWrite` like everything else.
|
|
32
|
+
*
|
|
33
|
+
* # Never a confident zero
|
|
34
|
+
*
|
|
35
|
+
* Every count in this file carries its denominator or its basis. A tool that
|
|
36
|
+
* answers `0` with nothing beside it is indistinguishable from a broken
|
|
37
|
+
* integration, an empty workspace, and a renamed column — and that ambiguity
|
|
38
|
+
* has already cost this codebase a real investigation. So `get_customer_health`
|
|
39
|
+
* says "15 of 150 accounts scored" rather than "15", `list_content_files` says
|
|
40
|
+
* whether the store is empty or absent, and any count derived from a list the
|
|
41
|
+
* server truncated says so rather than reporting the page size as the total.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The nine spaces, their families, and where each one's data actually lives.
|
|
46
|
+
*
|
|
47
|
+
* Kept as data rather than prose because two questions get asked constantly —
|
|
48
|
+
* "what are the spaces" and "which tool reads this one" — and a table answers
|
|
49
|
+
* both without the agent reading source. `dataSource: null` is meaningful, not
|
|
50
|
+
* missing: it records a space that HAS no server-side data, which is a real
|
|
51
|
+
* answer and a very different thing from one whose endpoint is unknown.
|
|
52
|
+
*/
|
|
53
|
+
export const SPACES = [
|
|
54
|
+
{
|
|
55
|
+
id: 'story',
|
|
56
|
+
family: 'demand',
|
|
57
|
+
route: '/story',
|
|
58
|
+
reads: ['/api/v1/knowledge/files'],
|
|
59
|
+
tool: 'list_content_files',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'market',
|
|
63
|
+
family: 'demand',
|
|
64
|
+
route: '/market',
|
|
65
|
+
reads: [
|
|
66
|
+
'/api/v1/icp-profiles',
|
|
67
|
+
'/api/v1/companies',
|
|
68
|
+
'/api/v1/opportunities',
|
|
69
|
+
'/api/v1/campaigns',
|
|
70
|
+
],
|
|
71
|
+
tool: 'get_market_overview',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'sell',
|
|
75
|
+
family: 'demand',
|
|
76
|
+
route: '/opportunities',
|
|
77
|
+
reads: ['/api/v1/opportunities', '/api/v1/pipeline-stages'],
|
|
78
|
+
tool: 'search_opportunities / get_pipeline_board',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'build',
|
|
82
|
+
family: 'supply',
|
|
83
|
+
route: '/board',
|
|
84
|
+
reads: ['/api/v1/work-boards', '/api/v1/work-items', '/api/v1/work-scopes'],
|
|
85
|
+
tool: 'list_work_boards / list_work_scopes',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'design',
|
|
89
|
+
family: 'supply',
|
|
90
|
+
route: '/design',
|
|
91
|
+
reads: [],
|
|
92
|
+
dataSource: null,
|
|
93
|
+
tool: null,
|
|
94
|
+
unreachable:
|
|
95
|
+
'Design canvases are stored in the browser\'s localStorage on ONE device (key prefix adrata.starfield.design.<workspaceId>). There is no endpoint, no row, and no sync — so no agent, CLI, or second browser can read them, and this is a property of the feature rather than a missing tool. Reaching them would require the canvas to be persisted server-side first.',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'service',
|
|
99
|
+
family: 'supply',
|
|
100
|
+
route: '/service',
|
|
101
|
+
reads: ['/api/v1/expansion/health', '/api/v1/companies'],
|
|
102
|
+
tool: 'get_customer_health',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'people',
|
|
106
|
+
family: 'company',
|
|
107
|
+
route: '/people',
|
|
108
|
+
reads: ['/api/v1/people'],
|
|
109
|
+
tool: 'search_people',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'finance',
|
|
113
|
+
family: 'company',
|
|
114
|
+
route: '/finance',
|
|
115
|
+
reads: ['/api/v1/opportunities', '/api/v1/usage/limits'],
|
|
116
|
+
tool: 'get_finance_overview',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'cloud',
|
|
120
|
+
family: 'company',
|
|
121
|
+
route: '/cloud',
|
|
122
|
+
reads: ['/api/v1/providers/workspace-connectors', '/api/v1/oauth/email/providers'],
|
|
123
|
+
tool: 'list_workspace_connectors',
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
export const SPACE_FAMILIES = ['demand', 'supply', 'company'];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read a list payload without inventing a total.
|
|
131
|
+
*
|
|
132
|
+
* The API returns collections in several shapes (`{data: []}`, `{items: []}`,
|
|
133
|
+
* a bare array) and SOMETIMES a separate total. Guessing wrong yields the
|
|
134
|
+
* worst possible answer — a plausible number that is really the page size — so
|
|
135
|
+
* this returns the rows it actually found plus whether a server-side total was
|
|
136
|
+
* present, and the callers say "N returned" instead of "N exist" when it was
|
|
137
|
+
* not.
|
|
138
|
+
*/
|
|
139
|
+
export function readCollection(payload) {
|
|
140
|
+
const rows = Array.isArray(payload)
|
|
141
|
+
? payload
|
|
142
|
+
: Array.isArray(payload?.data)
|
|
143
|
+
? payload.data
|
|
144
|
+
: Array.isArray(payload?.items)
|
|
145
|
+
? payload.items
|
|
146
|
+
: Array.isArray(payload?.results)
|
|
147
|
+
? payload.results
|
|
148
|
+
: null;
|
|
149
|
+
|
|
150
|
+
const total =
|
|
151
|
+
typeof payload?.total === 'number'
|
|
152
|
+
? payload.total
|
|
153
|
+
: typeof payload?.count === 'number'
|
|
154
|
+
? payload.count
|
|
155
|
+
: typeof payload?.pagination?.total === 'number'
|
|
156
|
+
? payload.pagination.total
|
|
157
|
+
: null;
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
rows: rows ?? [],
|
|
161
|
+
// `shapeRecognized:false` is why a caller can tell "the workspace has none"
|
|
162
|
+
// from "the response was not a collection at all" — the second is a bug
|
|
163
|
+
// report, the first is an answer.
|
|
164
|
+
shapeRecognized: rows !== null,
|
|
165
|
+
total,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Turn a count into a sentence that cannot be mistaken for a broken read.
|
|
171
|
+
*
|
|
172
|
+
* This is the single most reused thing in the file. `0` alone is ambiguous;
|
|
173
|
+
* "0 of 150 accounts have a health score" is an answer, and "the response was
|
|
174
|
+
* not a collection" is a defect the agent should report rather than average
|
|
175
|
+
* into a summary.
|
|
176
|
+
*/
|
|
177
|
+
export function describeCount({ found, of, noun, ofNoun, shapeRecognized = true, truncated = false }) {
|
|
178
|
+
if (!shapeRecognized) {
|
|
179
|
+
return `Could not read ${noun}: the endpoint responded, but the body was not a collection. Treat this as a broken read, NOT as zero.`;
|
|
180
|
+
}
|
|
181
|
+
const base =
|
|
182
|
+
typeof of === 'number'
|
|
183
|
+
? `${found} of ${of} ${ofNoun ?? noun}`
|
|
184
|
+
: `${found} ${noun}${truncated ? ' (page limit reached — more exist)' : ''}`;
|
|
185
|
+
if (found === 0 && typeof of === 'number' && of > 0) {
|
|
186
|
+
return `${base}. The store is reachable and genuinely empty for this workspace — this is a real zero, not a failed read.`;
|
|
187
|
+
}
|
|
188
|
+
if (found === 0) {
|
|
189
|
+
return `${base}. Zero rows AND no denominator available, so this cannot distinguish an empty workspace from an undeployed store — say so rather than reporting "none".`;
|
|
190
|
+
}
|
|
191
|
+
return base;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function registerSpaceTools(server, { z, api, ok }) {
|
|
195
|
+
// ---------------------------------------------------------------- spaces
|
|
196
|
+
server.tool(
|
|
197
|
+
'list_spaces',
|
|
198
|
+
`The nine spaces this workspace is organised into, in their three families (Demand: Story, Market, Sell · Supply: Build, Design, Service · Company: People, Finance, Cloud), each with the endpoints it reads and the tool that reads them.
|
|
199
|
+
|
|
200
|
+
Start here when a request names a surface rather than a record — "what's in Service", "show me the Story space" — because the space-to-data mapping is not guessable: /service joins customer health to companies, /market derives segments from four collections, and /design has no server-side data AT ALL (canvases live in one browser's localStorage, so nothing can read them remotely — the tool reports that as an unreachable surface rather than an empty one).
|
|
201
|
+
|
|
202
|
+
Entitlements are fetched when available, so this distinguishes "the workspace does not have this space" from "the space is empty".`,
|
|
203
|
+
{
|
|
204
|
+
family: z
|
|
205
|
+
.enum(['demand', 'supply', 'company'])
|
|
206
|
+
.optional()
|
|
207
|
+
.describe('Limit to one family. Omit for all nine spaces.'),
|
|
208
|
+
},
|
|
209
|
+
async ({ family }) => {
|
|
210
|
+
const spaces = family ? SPACES.filter((s) => s.family === family) : SPACES;
|
|
211
|
+
|
|
212
|
+
// Entitlements are advisory here: the space list is a property of the
|
|
213
|
+
// product, so a failed entitlement read must NOT empty it. It degrades
|
|
214
|
+
// to "unknown" and says so.
|
|
215
|
+
let entitlements = null;
|
|
216
|
+
let entitlementsError = null;
|
|
217
|
+
try {
|
|
218
|
+
const raw = await api('GET', '/api/v1/entitlements');
|
|
219
|
+
entitlements = readCollection(raw).shapeRecognized ? readCollection(raw).rows : raw;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
entitlementsError = String(err?.message || err);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return ok({
|
|
225
|
+
spaces: spaces.map((s) => ({
|
|
226
|
+
...s,
|
|
227
|
+
reachableByAgent: s.tool !== null,
|
|
228
|
+
})),
|
|
229
|
+
count: describeCount({
|
|
230
|
+
found: spaces.length,
|
|
231
|
+
of: SPACES.length,
|
|
232
|
+
noun: 'spaces',
|
|
233
|
+
ofNoun: 'spaces',
|
|
234
|
+
}),
|
|
235
|
+
unreachable: SPACES.filter((s) => s.tool === null).map((s) => ({
|
|
236
|
+
space: s.id,
|
|
237
|
+
why: s.unreachable,
|
|
238
|
+
})),
|
|
239
|
+
entitlements:
|
|
240
|
+
entitlementsError === null
|
|
241
|
+
? entitlements
|
|
242
|
+
: {
|
|
243
|
+
unavailable: true,
|
|
244
|
+
error: entitlementsError,
|
|
245
|
+
note: 'Entitlements could not be read, so "which spaces does this workspace have" is UNKNOWN — not "none". The nine spaces above are the product\'s shape, not a statement about this workspace\'s grants.',
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// --------------------------------------------------------------- service
|
|
252
|
+
server.tool(
|
|
253
|
+
'get_customer_health',
|
|
254
|
+
`Customer health scores — the Service space. Every scored account with its health score, risk band, and the signals behind it, joined to the company record so the answer names accounts rather than ids.
|
|
255
|
+
|
|
256
|
+
Reports coverage as "N of M accounts scored", because the number that matters in a health review is usually the accounts with NO score — an unscored account is invisible risk, and a tool that returned only the scored ones would hide exactly the accounts a CSM needs to hear about.`,
|
|
257
|
+
{
|
|
258
|
+
limit: z.number().optional().describe('Max scored accounts to return (default 50).'),
|
|
259
|
+
riskOnly: z
|
|
260
|
+
.boolean()
|
|
261
|
+
.optional()
|
|
262
|
+
.describe('Return only accounts in a declining or at-risk band.'),
|
|
263
|
+
},
|
|
264
|
+
async ({ limit = 50, riskOnly = false }) => {
|
|
265
|
+
const healthRaw = await api('GET', '/api/v1/expansion/health', {
|
|
266
|
+
params: { limit },
|
|
267
|
+
});
|
|
268
|
+
const health = readCollection(healthRaw);
|
|
269
|
+
|
|
270
|
+
// The denominator. Without it "15 scored accounts" is unfalsifiable —
|
|
271
|
+
// it could be full coverage of a 15-account book or 10% of a 150-account
|
|
272
|
+
// one, and those call for opposite actions.
|
|
273
|
+
let companyTotal = null;
|
|
274
|
+
let companyTotalError = null;
|
|
275
|
+
try {
|
|
276
|
+
const companiesRaw = await api('GET', '/api/v1/companies', { params: { limit: 1 } });
|
|
277
|
+
const companies = readCollection(companiesRaw);
|
|
278
|
+
companyTotal = companies.total;
|
|
279
|
+
if (companyTotal === null) {
|
|
280
|
+
companyTotalError =
|
|
281
|
+
'The companies endpoint returned no server-side total, so coverage cannot be stated as a fraction.';
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
companyTotalError = String(err?.message || err);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const rows = riskOnly
|
|
288
|
+
? health.rows.filter((r) => {
|
|
289
|
+
const band = String(r?.riskBand ?? r?.risk_band ?? r?.status ?? '').toLowerCase();
|
|
290
|
+
return band.includes('risk') || band.includes('declin') || band.includes('red');
|
|
291
|
+
})
|
|
292
|
+
: health.rows;
|
|
293
|
+
|
|
294
|
+
return ok({
|
|
295
|
+
accounts: rows,
|
|
296
|
+
coverage: describeCount({
|
|
297
|
+
found: health.rows.length,
|
|
298
|
+
of: companyTotal,
|
|
299
|
+
noun: 'accounts with a health score',
|
|
300
|
+
ofNoun: 'companies',
|
|
301
|
+
shapeRecognized: health.shapeRecognized,
|
|
302
|
+
}),
|
|
303
|
+
...(companyTotalError
|
|
304
|
+
? {
|
|
305
|
+
coverageCaveat: `Denominator unavailable: ${companyTotalError} Report the scored count WITHOUT implying it is complete.`,
|
|
306
|
+
}
|
|
307
|
+
: {}),
|
|
308
|
+
...(riskOnly
|
|
309
|
+
? {
|
|
310
|
+
filtered: describeCount({
|
|
311
|
+
found: rows.length,
|
|
312
|
+
of: health.rows.length,
|
|
313
|
+
noun: 'at-risk accounts',
|
|
314
|
+
ofNoun: 'scored accounts',
|
|
315
|
+
}),
|
|
316
|
+
}
|
|
317
|
+
: {}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// ----------------------------------------------------------------- story
|
|
323
|
+
server.tool(
|
|
324
|
+
'list_content_files',
|
|
325
|
+
`Content files — the Story space. Everything the workspace has written or uploaded as narrative material, with type, owner, and linkage.
|
|
326
|
+
|
|
327
|
+
This is the CONTENT store, not the account wiki: use search_knowledge for wiki pages about an account, and this for the assets themselves.`,
|
|
328
|
+
{
|
|
329
|
+
limit: z.number().optional().describe('Max files to return (default 100).'),
|
|
330
|
+
includeDeleted: z.boolean().optional().describe('Include soft-deleted files (default false).'),
|
|
331
|
+
},
|
|
332
|
+
async ({ limit = 100, includeDeleted = false }) => {
|
|
333
|
+
const raw = await api('GET', '/api/v1/knowledge/files', {
|
|
334
|
+
params: { limit, isDeleted: includeDeleted ? undefined : false },
|
|
335
|
+
});
|
|
336
|
+
const files = readCollection(raw);
|
|
337
|
+
return ok({
|
|
338
|
+
files: files.rows,
|
|
339
|
+
count: describeCount({
|
|
340
|
+
found: files.rows.length,
|
|
341
|
+
of: files.total,
|
|
342
|
+
noun: 'content files',
|
|
343
|
+
shapeRecognized: files.shapeRecognized,
|
|
344
|
+
truncated: files.total === null && files.rows.length === limit,
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------- market
|
|
351
|
+
server.tool(
|
|
352
|
+
'get_market_overview',
|
|
353
|
+
`The Market space: ICP profiles, the campaigns running against them, and how the company book distributes across them.
|
|
354
|
+
|
|
355
|
+
The app derives its segments CLIENT-SIDE from four collections at once, so an agent hitting any single endpoint sees a fragment of what the page shows. This tool performs the same join and reports each part's own count, so a partial answer is visible AS partial rather than silently thin.`,
|
|
356
|
+
{
|
|
357
|
+
limit: z.number().optional().describe('Max companies to sample for the distribution (default 250).'),
|
|
358
|
+
},
|
|
359
|
+
async ({ limit = 250 }) => {
|
|
360
|
+
const parts = {};
|
|
361
|
+
const errors = {};
|
|
362
|
+
|
|
363
|
+
// Each read is independent: one failing endpoint must degrade its own
|
|
364
|
+
// section, never blank the overview and never quietly contribute a zero
|
|
365
|
+
// to a total.
|
|
366
|
+
for (const [key, path, params] of [
|
|
367
|
+
['icpProfiles', '/api/v1/icp-profiles', {}],
|
|
368
|
+
['campaigns', '/api/v1/campaigns', { limit: 100 }],
|
|
369
|
+
['companies', '/api/v1/companies', { limit }],
|
|
370
|
+
['opportunities', '/api/v1/opportunities', { limit: 250 }],
|
|
371
|
+
]) {
|
|
372
|
+
try {
|
|
373
|
+
const raw = await api('GET', path, { params });
|
|
374
|
+
parts[key] = readCollection(raw);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
errors[key] = String(err?.message || err);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const summary = {};
|
|
381
|
+
for (const [key, coll] of Object.entries(parts)) {
|
|
382
|
+
summary[key] = describeCount({
|
|
383
|
+
found: coll.rows.length,
|
|
384
|
+
of: coll.total,
|
|
385
|
+
noun: key,
|
|
386
|
+
shapeRecognized: coll.shapeRecognized,
|
|
387
|
+
truncated: coll.total === null && coll.rows.length >= (key === 'companies' ? limit : 100),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return ok({
|
|
392
|
+
icpProfiles: parts.icpProfiles?.rows ?? [],
|
|
393
|
+
campaigns: parts.campaigns?.rows ?? [],
|
|
394
|
+
companySample: parts.companies?.rows?.length ?? 0,
|
|
395
|
+
counts: summary,
|
|
396
|
+
...(Object.keys(errors).length
|
|
397
|
+
? {
|
|
398
|
+
partial: true,
|
|
399
|
+
failedReads: errors,
|
|
400
|
+
warning:
|
|
401
|
+
'One or more of the four collections failed to read. The sections that failed are MISSING, not empty — do not present this as a complete market picture.',
|
|
402
|
+
}
|
|
403
|
+
: {}),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// --------------------------------------------------------------- finance
|
|
409
|
+
server.tool(
|
|
410
|
+
'get_finance_overview',
|
|
411
|
+
`The Finance space: booked and open pipeline value alongside the workspace's plan usage and limits.
|
|
412
|
+
|
|
413
|
+
Money figures are summed from the opportunities actually returned, and the tool says how many that was — a total computed from a truncated page is the classic way a revenue number comes out confidently wrong.`,
|
|
414
|
+
{},
|
|
415
|
+
async () => {
|
|
416
|
+
const parts = {};
|
|
417
|
+
const errors = {};
|
|
418
|
+
for (const [key, path, params] of [
|
|
419
|
+
['opportunities', '/api/v1/opportunities', { limit: 500, scope: 'all' }],
|
|
420
|
+
['usage', '/api/v1/usage/limits', {}],
|
|
421
|
+
]) {
|
|
422
|
+
try {
|
|
423
|
+
parts[key] = await api('GET', path, { params });
|
|
424
|
+
} catch (err) {
|
|
425
|
+
errors[key] = String(err?.message || err);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const opps = parts.opportunities ? readCollection(parts.opportunities) : null;
|
|
430
|
+
let openValue = null;
|
|
431
|
+
let openCount = null;
|
|
432
|
+
if (opps?.shapeRecognized) {
|
|
433
|
+
const open = opps.rows.filter((o) => {
|
|
434
|
+
const s = String(o?.status ?? o?.stage ?? '').toLowerCase();
|
|
435
|
+
return !s.includes('won') && !s.includes('lost') && !s.includes('closed');
|
|
436
|
+
});
|
|
437
|
+
openCount = open.length;
|
|
438
|
+
openValue = open.reduce((sum, o) => sum + (Number(o?.value ?? o?.amount ?? 0) || 0), 0);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return ok({
|
|
442
|
+
openPipelineValue: openValue,
|
|
443
|
+
openDeals:
|
|
444
|
+
openCount === null
|
|
445
|
+
? 'unavailable — opportunities did not read as a collection'
|
|
446
|
+
: describeCount({
|
|
447
|
+
found: openCount,
|
|
448
|
+
of: opps.rows.length,
|
|
449
|
+
noun: 'open deals',
|
|
450
|
+
ofNoun: 'opportunities returned',
|
|
451
|
+
}),
|
|
452
|
+
basis:
|
|
453
|
+
opps === null
|
|
454
|
+
? 'No opportunities were read, so no money figure is stated. This is NOT $0.'
|
|
455
|
+
: describeCount({
|
|
456
|
+
found: opps.rows.length,
|
|
457
|
+
of: opps.total,
|
|
458
|
+
noun: 'opportunities summed',
|
|
459
|
+
shapeRecognized: opps.shapeRecognized,
|
|
460
|
+
truncated: opps.total === null && opps.rows.length >= 500,
|
|
461
|
+
}),
|
|
462
|
+
usage: parts.usage ?? { unavailable: errors.usage },
|
|
463
|
+
...(Object.keys(errors).length ? { failedReads: errors } : {}),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
// ----------------------------------------------------------------- cloud
|
|
469
|
+
server.tool(
|
|
470
|
+
'list_workspace_connectors',
|
|
471
|
+
`The Cloud space: what is ACTUALLY connected in this workspace — the connector registry joined to the live OAuth grants — not the catalogue of what could be connected.
|
|
472
|
+
|
|
473
|
+
That distinction is the whole point of the tool. list_provider_catalog answers "what does Adrata integrate with"; this answers "what does THIS workspace have wired up, and is the grant still good". A connector present in the registry with no matching grant is a broken integration wearing a connected badge, and the tool surfaces that pairing explicitly.`,
|
|
474
|
+
{},
|
|
475
|
+
async () => {
|
|
476
|
+
const parts = {};
|
|
477
|
+
const errors = {};
|
|
478
|
+
for (const [key, path] of [
|
|
479
|
+
['connectors', '/api/v1/providers/workspace-connectors'],
|
|
480
|
+
['emailGrants', '/api/v1/oauth/email/providers'],
|
|
481
|
+
]) {
|
|
482
|
+
try {
|
|
483
|
+
parts[key] = readCollection(await api('GET', path));
|
|
484
|
+
} catch (err) {
|
|
485
|
+
errors[key] = String(err?.message || err);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return ok({
|
|
490
|
+
connectors: parts.connectors?.rows ?? [],
|
|
491
|
+
emailGrants: parts.emailGrants?.rows ?? [],
|
|
492
|
+
counts: {
|
|
493
|
+
connectors: parts.connectors
|
|
494
|
+
? describeCount({
|
|
495
|
+
found: parts.connectors.rows.length,
|
|
496
|
+
of: parts.connectors.total,
|
|
497
|
+
noun: 'workspace connectors',
|
|
498
|
+
shapeRecognized: parts.connectors.shapeRecognized,
|
|
499
|
+
})
|
|
500
|
+
: `unavailable: ${errors.connectors}`,
|
|
501
|
+
emailGrants: parts.emailGrants
|
|
502
|
+
? describeCount({
|
|
503
|
+
found: parts.emailGrants.rows.length,
|
|
504
|
+
of: parts.emailGrants.total,
|
|
505
|
+
noun: 'connected email accounts',
|
|
506
|
+
shapeRecognized: parts.emailGrants.shapeRecognized,
|
|
507
|
+
})
|
|
508
|
+
: `unavailable: ${errors.emailGrants}`,
|
|
509
|
+
},
|
|
510
|
+
...(Object.keys(errors).length
|
|
511
|
+
? {
|
|
512
|
+
partial: true,
|
|
513
|
+
failedReads: errors,
|
|
514
|
+
warning:
|
|
515
|
+
'A side of the join failed. "Nothing connected" CANNOT be concluded from this response — one of the two sources did not answer.',
|
|
516
|
+
}
|
|
517
|
+
: {}),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
// -------------------------------------------------------------- calendar
|
|
523
|
+
server.tool(
|
|
524
|
+
'get_calendar_agenda',
|
|
525
|
+
`The agenda a person sees on /calendar — events in a date window with their attendees and organizer, from the desktop read model (GET /api/v2/events/calendar).
|
|
526
|
+
|
|
527
|
+
Deliberately the v2 path, not /api/v1/events: the calendar page reads v2 and gets attendees and organizer resolved, where the v1 list returns the bare event. An agent briefing someone on their day needs to know WHO is in the room, so this reads the same model the person is looking at.`,
|
|
528
|
+
{
|
|
529
|
+
start: z.string().describe('ISO date/time for the start of the window, e.g. 2026-08-20T00:00:00Z'),
|
|
530
|
+
end: z.string().describe('ISO date/time for the end of the window.'),
|
|
531
|
+
},
|
|
532
|
+
async ({ start, end }) => {
|
|
533
|
+
const raw = await api('GET', '/api/v2/events/calendar', { params: { start, end } });
|
|
534
|
+
const events = readCollection(raw);
|
|
535
|
+
return ok({
|
|
536
|
+
window: { start, end },
|
|
537
|
+
events: events.rows,
|
|
538
|
+
count: describeCount({
|
|
539
|
+
found: events.rows.length,
|
|
540
|
+
of: events.total,
|
|
541
|
+
noun: 'events in this window',
|
|
542
|
+
shapeRecognized: events.shapeRecognized,
|
|
543
|
+
}),
|
|
544
|
+
note:
|
|
545
|
+
events.shapeRecognized && events.rows.length === 0
|
|
546
|
+
? 'An empty window is a real answer — the calendar read succeeded and there is nothing scheduled between these times. Check the window before concluding the calendar is disconnected.'
|
|
547
|
+
: undefined,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// -------------------------------------------------------------- pipeline
|
|
553
|
+
server.tool(
|
|
554
|
+
'get_pipeline_board',
|
|
555
|
+
`The Deals list as the /opportunities page assembles it: every pipeline stage in order, with the deals sitting in each one.
|
|
556
|
+
|
|
557
|
+
search_opportunities returns deals as a flat list; this returns the BOARD, so "what is stuck in negotiation" and "which stage is empty" are answerable. An empty stage is reported as an empty stage rather than omitted — a missing column and a column with no deals mean very different things to a forecast.`,
|
|
558
|
+
{
|
|
559
|
+
limit: z.number().optional().describe('Max deals to place on the board (default 250).'),
|
|
560
|
+
},
|
|
561
|
+
async ({ limit = 250 }) => {
|
|
562
|
+
const parts = {};
|
|
563
|
+
const errors = {};
|
|
564
|
+
for (const [key, path, params] of [
|
|
565
|
+
['stages', '/api/v1/pipeline-stages', { limit: 100 }],
|
|
566
|
+
['opportunities', '/api/v1/opportunities', { limit }],
|
|
567
|
+
]) {
|
|
568
|
+
try {
|
|
569
|
+
parts[key] = readCollection(await api('GET', path, { params }));
|
|
570
|
+
} catch (err) {
|
|
571
|
+
errors[key] = String(err?.message || err);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!parts.stages || !parts.opportunities) {
|
|
576
|
+
return ok({
|
|
577
|
+
unavailable: true,
|
|
578
|
+
failedReads: errors,
|
|
579
|
+
warning:
|
|
580
|
+
'The board needs BOTH stages and deals. One side failed, so no board is returned — an agent must not infer an empty pipeline from this.',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const byStage = new Map();
|
|
585
|
+
for (const stage of parts.stages.rows) {
|
|
586
|
+
const id = stage?.id ?? stage?.stageId ?? stage?.name;
|
|
587
|
+
byStage.set(String(id), { stage, deals: [] });
|
|
588
|
+
}
|
|
589
|
+
let unplaced = 0;
|
|
590
|
+
for (const deal of parts.opportunities.rows) {
|
|
591
|
+
const key = String(deal?.stageId ?? deal?.stage_id ?? deal?.stage ?? '');
|
|
592
|
+
const bucket = byStage.get(key);
|
|
593
|
+
if (bucket) bucket.deals.push(deal);
|
|
594
|
+
else unplaced += 1;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return ok({
|
|
598
|
+
board: [...byStage.values()].map(({ stage, deals }) => ({
|
|
599
|
+
stage,
|
|
600
|
+
dealCount: deals.length,
|
|
601
|
+
deals,
|
|
602
|
+
})),
|
|
603
|
+
counts: {
|
|
604
|
+
stages: describeCount({
|
|
605
|
+
found: parts.stages.rows.length,
|
|
606
|
+
of: parts.stages.total,
|
|
607
|
+
noun: 'pipeline stages',
|
|
608
|
+
shapeRecognized: parts.stages.shapeRecognized,
|
|
609
|
+
}),
|
|
610
|
+
deals: describeCount({
|
|
611
|
+
found: parts.opportunities.rows.length,
|
|
612
|
+
of: parts.opportunities.total,
|
|
613
|
+
noun: 'deals placed on the board',
|
|
614
|
+
shapeRecognized: parts.opportunities.shapeRecognized,
|
|
615
|
+
truncated: parts.opportunities.total === null && parts.opportunities.rows.length >= limit,
|
|
616
|
+
}),
|
|
617
|
+
},
|
|
618
|
+
// Deals whose stage id matches no known stage would otherwise vanish
|
|
619
|
+
// from the board and quietly shrink the pipeline total.
|
|
620
|
+
...(unplaced > 0
|
|
621
|
+
? {
|
|
622
|
+
unplacedDeals: `${unplaced} of ${parts.opportunities.rows.length} deals reference a stage that is not in the stage list. They are NOT on the board above, so the board's deal count is short by that many.`,
|
|
623
|
+
}
|
|
624
|
+
: {}),
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
);
|
|
628
|
+
}
|