@adrata/adrata-mcp 1.0.1 → 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 +5 -4
- 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
package/server.js
CHANGED
|
@@ -31,7 +31,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
31
31
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
32
32
|
import { z } from 'zod';
|
|
33
33
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
authenticate,
|
|
36
|
+
reauthenticate,
|
|
37
|
+
checkToolAccess,
|
|
38
|
+
assertOAuthIssuerMatchesTarget,
|
|
39
|
+
getValidAgentToken,
|
|
40
|
+
} from './access/auth.js';
|
|
35
41
|
import { TIERS } from './access/tiers.js';
|
|
36
42
|
import { findCompany, findPerson } from './tools/free-search.js';
|
|
37
43
|
import { applySecurityLayer } from './security.js';
|
|
@@ -44,9 +50,11 @@ import { connectWorkspace, disconnectWorkspace, getConnectionStatus, getValidTok
|
|
|
44
50
|
import { registerEnterpriseTools } from './tools/enterprise-tools.js';
|
|
45
51
|
import { registerEmailTools } from './tools/email-tools.js';
|
|
46
52
|
import { registerWorkBoardTools } from './tools/work-board-tools.js';
|
|
53
|
+
import { registerRoadmapTools } from './tools/roadmap-tools.js';
|
|
47
54
|
import { registerPaperTools } from './tools/paper-tools.js';
|
|
48
55
|
import { register as registerAlwaysLoadedTools } from './toolsets/revenue/always-loaded.js';
|
|
49
56
|
import { register as registerExtensibilityTools } from './toolsets/extensibility.js';
|
|
57
|
+
import { registerSpaceTools } from './toolsets/spaces.js';
|
|
50
58
|
import { getDemoAvailability, scheduleDemo, scheduleMeeting } from './tools/scheduling.js';
|
|
51
59
|
import { registerAnalytics } from './analytics.js';
|
|
52
60
|
import {
|
|
@@ -56,8 +64,11 @@ import {
|
|
|
56
64
|
governedWrite,
|
|
57
65
|
validateApiBridgeRequest,
|
|
58
66
|
} from './api-bridge.js';
|
|
59
|
-
import { buildToolAnnotations, checkDomainScope } from './tool-annotations.js';
|
|
67
|
+
import { buildToolAnnotations, checkDomainScope, shouldRegisterTool } from './tool-annotations.js';
|
|
60
68
|
import { executeMoneyWrite, capClientSide, CLIENT_PAGE_CAP } from './governance/money.js';
|
|
69
|
+
import { applyProductProfile, assertProductCapabilityRef } from './product-profile.js';
|
|
70
|
+
|
|
71
|
+
applyProductProfile();
|
|
61
72
|
|
|
62
73
|
let AUTH = authenticate();
|
|
63
74
|
const API_BASE = AUTH.apiUrl || process.env.ADRATA_API_URL || 'https://api.adrata.com';
|
|
@@ -132,6 +143,18 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
|
|
|
132
143
|
}
|
|
133
144
|
currentToken = freshToken;
|
|
134
145
|
auth.token = freshToken;
|
|
146
|
+
} else if (auth.source === 'agent_config') {
|
|
147
|
+
let freshToken;
|
|
148
|
+
try {
|
|
149
|
+
freshToken = await getValidAgentToken(API_BASE);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw refreshFailureError(err);
|
|
152
|
+
}
|
|
153
|
+
if (!freshToken) {
|
|
154
|
+
throw new Error('No shared adrata login session found. Run adrata login before using governed tools.');
|
|
155
|
+
}
|
|
156
|
+
currentToken = freshToken;
|
|
157
|
+
auth.token = freshToken;
|
|
135
158
|
}
|
|
136
159
|
|
|
137
160
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -168,13 +191,22 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
|
|
|
168
191
|
auth.token = refreshedToken;
|
|
169
192
|
res = await request(currentToken);
|
|
170
193
|
}
|
|
171
|
-
// The shared agent session (agent.json) has no refresh path inside the MCP —
|
|
172
|
-
// the @adrata agent owns rotation. An expired session needs a re-login, and
|
|
173
|
-
// that must not read like a generic API failure.
|
|
174
194
|
if (res.status === 401 && auth.source === 'agent_config') {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
195
|
+
let refreshedToken;
|
|
196
|
+
try {
|
|
197
|
+
refreshedToken = await getValidAgentToken(API_BASE, {
|
|
198
|
+
forceRefresh: true,
|
|
199
|
+
rejectedToken: currentToken,
|
|
200
|
+
});
|
|
201
|
+
} catch (err) {
|
|
202
|
+
throw refreshFailureError(err);
|
|
203
|
+
}
|
|
204
|
+
if (!refreshedToken) {
|
|
205
|
+
throw new Error('No shared adrata login session found. Run adrata login before using governed tools.');
|
|
206
|
+
}
|
|
207
|
+
currentToken = refreshedToken;
|
|
208
|
+
auth.token = refreshedToken;
|
|
209
|
+
res = await request(currentToken);
|
|
178
210
|
}
|
|
179
211
|
|
|
180
212
|
const text = await res.text();
|
|
@@ -277,6 +309,11 @@ const server = new McpServer({ name: SERVER_NAME, version: '1.0.0' });
|
|
|
277
309
|
|
|
278
310
|
const _originalTool = server.tool.bind(server);
|
|
279
311
|
server.tool = function gatedTool(name, ...rest) {
|
|
312
|
+
// Product-scoped servers do not merely refuse foreign tools after a model
|
|
313
|
+
// has already loaded their schemas. They omit them from tools/list entirely.
|
|
314
|
+
// The unprofiled Adrata server retains its full backwards-compatible surface.
|
|
315
|
+
if (!shouldRegisterTool(name)) return undefined;
|
|
316
|
+
|
|
280
317
|
// server.tool(name, [description], [schema], handler) — handler is always last.
|
|
281
318
|
const handler = rest[rest.length - 1];
|
|
282
319
|
const gatedHandler = async function gatedHandler(...handlerArgs) {
|
|
@@ -472,6 +509,81 @@ server.tool('switch_workspace',
|
|
|
472
509
|
|
|
473
510
|
// ===== ADRATA AGENT API BRIDGE =====
|
|
474
511
|
|
|
512
|
+
const PRODUCT_NAMESPACE = process.env.ADRATA_MCP_PRODUCT?.trim().toLowerCase() || null;
|
|
513
|
+
|
|
514
|
+
function assertProductCapability(capability, requested) {
|
|
515
|
+
const ref = capability?.ref;
|
|
516
|
+
if (!capability?.toolName || typeof ref !== 'string') {
|
|
517
|
+
throw new Error(`Adrata Cloud returned no exact capability for ${requested}`);
|
|
518
|
+
}
|
|
519
|
+
assertProductCapabilityRef(ref, PRODUCT_NAMESPACE);
|
|
520
|
+
return capability;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function resolveExactCapability(reference) {
|
|
524
|
+
const response = await api('GET', '/api/v1/ai/crm-tools/capabilities/describe', {
|
|
525
|
+
params: { ref: reference },
|
|
526
|
+
});
|
|
527
|
+
return assertProductCapability(response?.capability ?? response?.data?.capability, reference);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
server.tool('search_capabilities',
|
|
531
|
+
'Search the authorized capability registry without loading schemas into context. A branded server fixes the namespace to its product. Search is discovery only: describe one exact result before run_capability, and never execute a fuzzy result.',
|
|
532
|
+
{
|
|
533
|
+
query: z.string().optional().describe('Plain-language intent. Empty lists a bounded sample.'),
|
|
534
|
+
namespace: z.string().optional().describe('Optional only on the unprofiled server; a product profile cannot search another product.'),
|
|
535
|
+
limit: z.number().int().min(1).max(50).optional().describe('Bounded result count; defaults server-side.'),
|
|
536
|
+
},
|
|
537
|
+
async (args) => {
|
|
538
|
+
const requestedNamespace = args.namespace?.trim().toLowerCase();
|
|
539
|
+
if (PRODUCT_NAMESPACE && requestedNamespace && requestedNamespace !== PRODUCT_NAMESPACE) {
|
|
540
|
+
throw new Error(`${SERVER_NAME} search is fixed to the ${PRODUCT_NAMESPACE} namespace.`);
|
|
541
|
+
}
|
|
542
|
+
return ok(await api('GET', '/api/v1/ai/crm-tools/capabilities/search', {
|
|
543
|
+
params: {
|
|
544
|
+
q: args.query,
|
|
545
|
+
namespace: PRODUCT_NAMESPACE || requestedNamespace,
|
|
546
|
+
limit: args.limit,
|
|
547
|
+
},
|
|
548
|
+
}));
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
server.tool('describe_capability',
|
|
552
|
+
'Resolve one exact, versioned capability reference and return its input schema, risk, and execution metadata. This never executes the capability.',
|
|
553
|
+
{ ref: z.string().describe('Exact /product/domain/action reference; never a fuzzy search phrase.') },
|
|
554
|
+
async (args) => ok({ capability: await resolveExactCapability(args.ref) }));
|
|
555
|
+
|
|
556
|
+
server.tool('run_capability',
|
|
557
|
+
'Run one exact capability through the governed dispatcher. Defaults to dry-run. Live writes remain subject to server-bound approval, reason, spend, policy, and idempotency controls. Product profiles refuse references outside their namespace.',
|
|
558
|
+
{
|
|
559
|
+
ref: z.string().describe('Exact /product/domain/action reference returned by search/describe.'),
|
|
560
|
+
arguments: z.record(z.unknown()).optional(),
|
|
561
|
+
dryRun: z.boolean().optional().describe('Defaults true. Set false only after reviewing the exact schema and preview.'),
|
|
562
|
+
reason: z.string().optional(),
|
|
563
|
+
confirmationToken: z.string().optional().describe('Single-use server-bound token when the capability requires one.'),
|
|
564
|
+
confirmSpend: z.boolean().optional(),
|
|
565
|
+
idempotencyKey: z.string().optional().describe('Required by governed live writes/spend. Reuse the same key on retry.'),
|
|
566
|
+
},
|
|
567
|
+
async (args) => {
|
|
568
|
+
const capability = await resolveExactCapability(args.ref);
|
|
569
|
+
const body = {
|
|
570
|
+
toolName: capability.toolName,
|
|
571
|
+
capabilityRef: capability.ref,
|
|
572
|
+
capabilityVersion: capability.version,
|
|
573
|
+
arguments: args.arguments || {},
|
|
574
|
+
dryRun: args.dryRun !== false,
|
|
575
|
+
...(args.reason ? { reason: args.reason } : {}),
|
|
576
|
+
...(args.confirmationToken ? { confirmationToken: args.confirmationToken } : {}),
|
|
577
|
+
...(args.confirmSpend === true ? { confirmSpend: true } : {}),
|
|
578
|
+
...(args.idempotencyKey ? { idempotencyKey: args.idempotencyKey } : {}),
|
|
579
|
+
};
|
|
580
|
+
const headers = {};
|
|
581
|
+
if (args.idempotencyKey) headers['idempotency-key'] = args.idempotencyKey;
|
|
582
|
+
if (args.reason) headers['x-adrata-reason'] = args.reason;
|
|
583
|
+
if (args.confirmSpend === true) headers['x-adrata-approved'] = 'true';
|
|
584
|
+
return ok(await api('POST', '/api/v1/ai/crm-tools/execute', { body, headers }));
|
|
585
|
+
});
|
|
586
|
+
|
|
475
587
|
server.tool('adrata_api_catalog',
|
|
476
588
|
'Describe the governed Adrata API bridge for Claude Code/Codex. Use this to discover how to call any allowlisted platform API safely from the agent layer.',
|
|
477
589
|
{},
|
|
@@ -943,6 +1055,63 @@ server.tool('find_person',
|
|
|
943
1055
|
{ name: z.string().describe('Full name of the person to research (e.g. "Patrick Collison", "Jensen Huang")') },
|
|
944
1056
|
async (a) => findPerson(a.name));
|
|
945
1057
|
|
|
1058
|
+
// ===== GOVERNED WRITES (shared) =====
|
|
1059
|
+
//
|
|
1060
|
+
// The buyer-group tools below already route every write through `governedWrite`
|
|
1061
|
+
// — the same dryRun/approved/reason/idempotencyKey contract `adrata_api_request`
|
|
1062
|
+
// enforces. The core record tools did NOT, and they are the ones an agent
|
|
1063
|
+
// actually reaches for: measured against the local stack on 2026-08-19,
|
|
1064
|
+
// `create_person`, `update_person` and `delete_person` each executed live with
|
|
1065
|
+
// no preview, no approval, no audit reason and no idempotency key, while the
|
|
1066
|
+
// IDENTICAL `PATCH /api/v1/people/{id}` sent through `adrata_api_request`
|
|
1067
|
+
// previewed and refused. The purpose-built tool was strictly less safe than the
|
|
1068
|
+
// generic fallback it exists to replace.
|
|
1069
|
+
//
|
|
1070
|
+
// CLAUDE.md's workspace rules require live CRM writes to carry explicit
|
|
1071
|
+
// approval, an audit reason and an idempotency key. These definitions move that
|
|
1072
|
+
// from documentation into enforcement for companies, people and notes.
|
|
1073
|
+
|
|
1074
|
+
/** Governance fields every governed write tool accepts. */
|
|
1075
|
+
const governedWriteArgs = {
|
|
1076
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Returns a preview of the exact call instead of performing it. Set false to execute.'),
|
|
1077
|
+
approved: z.boolean().optional().describe('Required (true) for a live write. Records that the caller confirmed the mutation; it does not by itself grant scope.'),
|
|
1078
|
+
reason: z.string().optional().describe('Required for a live write. Recorded as the audit reason (X-Adrata-Reason).'),
|
|
1079
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Sent as Idempotency-Key so a retry cannot double-apply.'),
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* The sentence appended to a governed tool's description.
|
|
1084
|
+
*
|
|
1085
|
+
* It names the scope because that is the one thing the caller cannot guess and
|
|
1086
|
+
* the API will reject them for: a 403 `insufficient_scope` on `write:people`
|
|
1087
|
+
* reads like a bug unless the tool already said which scope it needed.
|
|
1088
|
+
*
|
|
1089
|
+
* Pass `null` when the route genuinely requires no scope. `/notes` is one:
|
|
1090
|
+
* it sits in the API's `GRANDFATHERED_UNMAPPED` debt register
|
|
1091
|
+
* (code/api/crates/middleware/src/scope_guard/grandfathered.rs), and there is
|
|
1092
|
+
* no `write:notes` in the scope catalogue at all. Naming one here would invent
|
|
1093
|
+
* a grantable scope that does not exist and send a caller hunting a consent
|
|
1094
|
+
* checkbox they will never find — so the note says what is actually true.
|
|
1095
|
+
*/
|
|
1096
|
+
function governedWriteNote(scope) {
|
|
1097
|
+
const base =
|
|
1098
|
+
' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey';
|
|
1099
|
+
return scope
|
|
1100
|
+
? `${base}, and the connection must hold ${scope} (connect_workspace with writeAccess:true).`
|
|
1101
|
+
: `${base}. This route currently requires no OAuth write scope, so approval and audit are the only gate.`;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** Shared handler: preview object on a dry run, tool payload on a live write. */
|
|
1105
|
+
async function runGovernedWrite(args, request, onSuccess = (r) => r) {
|
|
1106
|
+
try {
|
|
1107
|
+
const outcome = await governedWrite(api, args, request);
|
|
1108
|
+
if (outcome.dryRun) return ok(outcome.preview);
|
|
1109
|
+
return ok(onSuccess(outcome.result));
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
return ok({ error: true, message: err.message });
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
946
1115
|
// ===== COMPANIES =====
|
|
947
1116
|
|
|
948
1117
|
server.tool('search_companies', 'Search companies by name, domain, industry, or status. Returns customFields.',
|
|
@@ -952,16 +1121,27 @@ server.tool('search_companies', 'Search companies by name, domain, industry, or
|
|
|
952
1121
|
server.tool('get_company', 'Get full company details including customFields, lastAction, intelligence data.',
|
|
953
1122
|
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/companies/${a.id}`)));
|
|
954
1123
|
|
|
955
|
-
server.tool('create_company',
|
|
956
|
-
{ name: z.string(), domain: z.string().optional(), industry: z.string().optional(), website: z.string().optional(), status: z.string().optional(), customFields: z.record(z.unknown()).optional() },
|
|
957
|
-
async (
|
|
1124
|
+
server.tool('create_company', `Create a new company.${governedWriteNote('write:companies')}`,
|
|
1125
|
+
{ name: z.string(), domain: z.string().optional(), industry: z.string().optional(), website: z.string().optional(), status: z.string().optional(), customFields: z.record(z.unknown()).optional(), ...governedWriteArgs },
|
|
1126
|
+
async ({ dryRun, approved, reason, idempotencyKey, ...f }) => runGovernedWrite(
|
|
1127
|
+
{ dryRun, approved, reason, idempotencyKey },
|
|
1128
|
+
{ method: 'POST', path: '/api/v1/companies', body: body(f), preview: { entity: 'company', operation: 'create' } },
|
|
1129
|
+
));
|
|
958
1130
|
|
|
959
|
-
server.tool('update_company',
|
|
960
|
-
{ id: z.string(), name: z.string().optional(), industry: z.string().optional(), website: z.string().optional(), status: z.string().optional(), customFields: z.record(z.unknown()).optional() },
|
|
961
|
-
async ({ id, ...f }) =>
|
|
1131
|
+
server.tool('update_company', `Update company fields. customFields are JSONB-merged.${governedWriteNote('write:companies')}`,
|
|
1132
|
+
{ id: z.string(), name: z.string().optional(), domain: z.string().optional(), industry: z.string().optional(), website: z.string().optional(), status: z.string().optional(), customFields: z.record(z.unknown()).optional(), ...governedWriteArgs },
|
|
1133
|
+
async ({ id, dryRun, approved, reason, idempotencyKey, ...f }) => runGovernedWrite(
|
|
1134
|
+
{ dryRun, approved, reason, idempotencyKey },
|
|
1135
|
+
{ method: 'PATCH', path: `/api/v1/companies/${id}`, body: body(f), preview: { entity: 'company', entityId: id, operation: 'update', fields: Object.keys(body(f)) } },
|
|
1136
|
+
));
|
|
962
1137
|
|
|
963
|
-
server.tool('delete_company',
|
|
964
|
-
{ id: z.string()
|
|
1138
|
+
server.tool('delete_company', `Soft-delete a company.${governedWriteNote('write:companies')}`,
|
|
1139
|
+
{ id: z.string(), ...governedWriteArgs },
|
|
1140
|
+
async ({ id, ...g }) => runGovernedWrite(
|
|
1141
|
+
g,
|
|
1142
|
+
{ method: 'DELETE', path: `/api/v1/companies/${id}`, preview: { entity: 'company', entityId: id, operation: 'soft-delete' } },
|
|
1143
|
+
() => ({ deleted: id }),
|
|
1144
|
+
));
|
|
965
1145
|
|
|
966
1146
|
server.tool('get_company_people', 'List all people at a company.',
|
|
967
1147
|
{ companyId: z.string(), ...page },
|
|
@@ -993,7 +1173,7 @@ server.tool('search_people', 'Search people by name, email, company, title. Retu
|
|
|
993
1173
|
server.tool('get_person', 'Get full person details including customFields, lastAction, companyName.',
|
|
994
1174
|
{ id: z.string() }, async (a) => ok(await api('GET', `/api/v1/people/${a.id}`)));
|
|
995
1175
|
|
|
996
|
-
server.tool('create_person',
|
|
1176
|
+
server.tool('create_person', `Create a new person/contact. Set contactCategory:"Introducer" for someone you know who is NOT a sales target — they stay out of pipeline. Set customFields.researchOnly:true to suppress buyer-room and pursuit side effects on import.${governedWriteNote('write:people')}`,
|
|
997
1177
|
{
|
|
998
1178
|
name: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(),
|
|
999
1179
|
email: z.string().optional(), phone: z.string().optional(), title: z.string().optional(),
|
|
@@ -1004,15 +1184,27 @@ server.tool('create_person', 'Create a new person/contact. Set contactCategory:"
|
|
|
1004
1184
|
linkedinUrl: z.string().optional(),
|
|
1005
1185
|
customFields: z.record(z.unknown()).optional()
|
|
1006
1186
|
.describe('Set researchOnly:true to suppress automatic Room creation and pursuit side effects.'),
|
|
1187
|
+
...governedWriteArgs,
|
|
1007
1188
|
},
|
|
1008
|
-
async (
|
|
1189
|
+
async ({ dryRun, approved, reason, idempotencyKey, ...f }) => runGovernedWrite(
|
|
1190
|
+
{ dryRun, approved, reason, idempotencyKey },
|
|
1191
|
+
{ method: 'POST', path: '/api/v1/people', body: body(f), preview: { entity: 'person', operation: 'create' } },
|
|
1192
|
+
));
|
|
1009
1193
|
|
|
1010
|
-
server.tool('update_person',
|
|
1011
|
-
{ id: z.string(), name: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(), email: z.string().optional(), title: z.string().optional(), phone: z.string().optional(), status: z.string().optional(), contactCategory: z.enum(['Lead', 'Prospect', 'Introducer']).optional().describe('Lead/Prospect enter pipeline; Introducer is kept out of pipeline.'), linkedinUrl: z.string().optional(), customFields: z.record(z.unknown()).optional() },
|
|
1012
|
-
async ({ id, ...f }) =>
|
|
1194
|
+
server.tool('update_person', `Update person fields. customFields are JSONB-merged. Works for leads too. Change contactCategory to move someone between pipeline (Lead/Prospect) and known-relationship-only (Introducer).${governedWriteNote('write:people')}`,
|
|
1195
|
+
{ id: z.string(), name: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(), email: z.string().optional(), title: z.string().optional(), phone: z.string().optional(), companyId: z.string().optional(), status: z.string().optional(), contactCategory: z.enum(['Lead', 'Prospect', 'Introducer']).optional().describe('Lead/Prospect enter pipeline; Introducer is kept out of pipeline.'), linkedinUrl: z.string().optional(), customFields: z.record(z.unknown()).optional(), ...governedWriteArgs },
|
|
1196
|
+
async ({ id, dryRun, approved, reason, idempotencyKey, ...f }) => runGovernedWrite(
|
|
1197
|
+
{ dryRun, approved, reason, idempotencyKey },
|
|
1198
|
+
{ method: 'PATCH', path: `/api/v1/people/${id}`, body: body(f), preview: { entity: 'person', entityId: id, operation: 'update', fields: Object.keys(body(f)) } },
|
|
1199
|
+
));
|
|
1013
1200
|
|
|
1014
|
-
server.tool('delete_person',
|
|
1015
|
-
{ id: z.string()
|
|
1201
|
+
server.tool('delete_person', `Soft-delete a person.${governedWriteNote('write:people')}`,
|
|
1202
|
+
{ id: z.string(), ...governedWriteArgs },
|
|
1203
|
+
async ({ id, ...g }) => runGovernedWrite(
|
|
1204
|
+
g,
|
|
1205
|
+
{ method: 'DELETE', path: `/api/v1/people/${id}`, preview: { entity: 'person', entityId: id, operation: 'soft-delete' } },
|
|
1206
|
+
() => ({ deleted: id }),
|
|
1207
|
+
));
|
|
1016
1208
|
|
|
1017
1209
|
// ===== OPPORTUNITIES =====
|
|
1018
1210
|
|
|
@@ -1071,20 +1263,31 @@ server.tool('list_today_actions', 'List actions scheduled for today.',
|
|
|
1071
1263
|
// the API bound nowhere: create failed validation and update was a silent
|
|
1072
1264
|
// no-op that still answered 200. Send `content` and keep accepting `body` as
|
|
1073
1265
|
// the tool-facing argument name so existing agent prompts keep working.
|
|
1074
|
-
server.tool('create_note',
|
|
1075
|
-
{ body: z.string().describe('The note text.'), companyId: z.string().optional(), personId: z.string().optional(), opportunityId: z.string().optional() },
|
|
1076
|
-
async ({ body: text,
|
|
1266
|
+
server.tool('create_note', `Add a note to a person, company, or opportunity.${governedWriteNote(null)}`,
|
|
1267
|
+
{ body: z.string().describe('The note text.'), companyId: z.string().optional(), personId: z.string().optional(), opportunityId: z.string().optional(), ...governedWriteArgs },
|
|
1268
|
+
async ({ body: text, dryRun, approved, reason, idempotencyKey, ...rest }) => runGovernedWrite(
|
|
1269
|
+
{ dryRun, approved, reason, idempotencyKey },
|
|
1270
|
+
{ method: 'POST', path: '/api/v1/notes', body: { content: text, ...body(rest) }, preview: { entity: 'note', operation: 'create' } },
|
|
1271
|
+
));
|
|
1077
1272
|
|
|
1078
1273
|
server.tool('list_notes', 'List notes, filterable by entity.',
|
|
1079
1274
|
{ companyId: z.string().optional(), personId: z.string().optional(), opportunityId: z.string().optional(), search: z.string().optional(), ...page },
|
|
1080
1275
|
async (a) => ok(await api('GET', '/api/v1/notes', { params: { ...a, limit: a.limit || 25, page: a.page || 1 } })));
|
|
1081
1276
|
|
|
1082
|
-
server.tool('update_note',
|
|
1083
|
-
{ id: z.string(), body: z.string().describe('The new note text.') },
|
|
1084
|
-
async (
|
|
1277
|
+
server.tool('update_note', `Update note content.${governedWriteNote(null)}`,
|
|
1278
|
+
{ id: z.string(), body: z.string().describe('The new note text.'), ...governedWriteArgs },
|
|
1279
|
+
async ({ id, body: text, ...g }) => runGovernedWrite(
|
|
1280
|
+
g,
|
|
1281
|
+
{ method: 'PUT', path: `/api/v1/notes/${id}`, body: { content: text }, preview: { entity: 'note', entityId: id, operation: 'update' } },
|
|
1282
|
+
));
|
|
1085
1283
|
|
|
1086
|
-
server.tool('delete_note',
|
|
1087
|
-
{ id: z.string()
|
|
1284
|
+
server.tool('delete_note', `Delete a note.${governedWriteNote(null)}`,
|
|
1285
|
+
{ id: z.string(), ...governedWriteArgs },
|
|
1286
|
+
async ({ id, ...g }) => runGovernedWrite(
|
|
1287
|
+
g,
|
|
1288
|
+
{ method: 'DELETE', path: `/api/v1/notes/${id}`, preview: { entity: 'note', entityId: id, operation: 'delete' } },
|
|
1289
|
+
() => ({ deleted: id }),
|
|
1290
|
+
));
|
|
1088
1291
|
|
|
1089
1292
|
// ===== BUYER GROUPS =====
|
|
1090
1293
|
//
|
|
@@ -1094,27 +1297,10 @@ server.tool('delete_note', 'Delete a note.',
|
|
|
1094
1297
|
// enforces — before this, the typed tools bypassed it entirely and were the
|
|
1095
1298
|
// least governed path to the most destructive operation in the toolset.
|
|
1096
1299
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
reason: z.string().optional().describe('Required for a live write. Recorded as the audit reason (X-Adrata-Reason).'),
|
|
1102
|
-
idempotencyKey: z.string().optional().describe('Required for a live write. Sent as Idempotency-Key so a retry cannot double-apply.'),
|
|
1103
|
-
};
|
|
1104
|
-
|
|
1105
|
-
const GOVERNED_WRITE_NOTE =
|
|
1106
|
-
' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey, and the connection must hold write:buyer-groups (connect_workspace with writeAccess:true).';
|
|
1107
|
-
|
|
1108
|
-
/** Shared handler shape: preview object on a dry run, tool payload on a live write. */
|
|
1109
|
-
async function runGovernedBuyerGroupWrite(args, request, onSuccess) {
|
|
1110
|
-
try {
|
|
1111
|
-
const outcome = await governedWrite(api, args, request);
|
|
1112
|
-
if (outcome.dryRun) return ok(outcome.preview);
|
|
1113
|
-
return ok(onSuccess(outcome.result));
|
|
1114
|
-
} catch (err) {
|
|
1115
|
-
return ok({ error: true, message: err.message });
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1300
|
+
// `governedWriteArgs`, `governedWriteNote` and `runGovernedWrite` are defined
|
|
1301
|
+
// once above the COMPANIES section and shared by every governed write tool.
|
|
1302
|
+
const GOVERNED_WRITE_NOTE = governedWriteNote('write:buyer-groups');
|
|
1303
|
+
const runGovernedBuyerGroupWrite = runGovernedWrite;
|
|
1118
1304
|
|
|
1119
1305
|
server.tool('list_buyer_groups', 'List buyer groups — the decision-making units at companies.',
|
|
1120
1306
|
{ companyId: z.string().optional(), ...page },
|
|
@@ -1526,17 +1712,39 @@ server.tool('get_meeting_action_items', 'Get action items from a meeting.',
|
|
|
1526
1712
|
|
|
1527
1713
|
// ===== INTELLIGENCE =====
|
|
1528
1714
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1715
|
+
// Enrichment is the one write in this file that spends money on every call.
|
|
1716
|
+
// A person `collect` is 20 Coresignal credits and a company multi-source call
|
|
1717
|
+
// is 20 (see CLAUDE.md's credit table and
|
|
1718
|
+
// code/api/crates/integrations/src/coresignal/company_multi_source.rs), drawn
|
|
1719
|
+
// from a single unified pool. These two tools were ungoverned POSTs, so an
|
|
1720
|
+
// agent could fan enrichment across a list and spend the pool with no preview,
|
|
1721
|
+
// no approval and no idempotency key — meaning a retry re-bought the same
|
|
1722
|
+
// record. They are governed for cost, not just for audit.
|
|
1723
|
+
server.tool('enrich_company', `Trigger enrichment for a company — firmographic, news, competitors. SPENDS VENDOR CREDITS.${governedWriteNote('write:companies')}`,
|
|
1724
|
+
{ companyId: z.string(), ...governedWriteArgs },
|
|
1725
|
+
async ({ companyId, ...g }) => runGovernedWrite(
|
|
1726
|
+
g,
|
|
1727
|
+
{
|
|
1728
|
+
method: 'POST',
|
|
1729
|
+
path: `/api/v1/companies/${companyId}/enrich`,
|
|
1730
|
+
preview: { entity: 'company', entityId: companyId, operation: 'enrich', spendsVendorCredits: true },
|
|
1731
|
+
},
|
|
1732
|
+
));
|
|
1532
1733
|
|
|
1533
1734
|
server.tool('get_company_firmographics', 'Get employee count and revenue metadata for a company, including value/range, source, confidence, freshness, and missing fields.',
|
|
1534
1735
|
{ companyId: z.string() },
|
|
1535
1736
|
async (a) => ok(await api('GET', `/api/v1/companies/${a.companyId}/firmographics`)));
|
|
1536
1737
|
|
|
1537
|
-
server.tool('enrich_person',
|
|
1538
|
-
{ personId: z.string() },
|
|
1539
|
-
async (
|
|
1738
|
+
server.tool('enrich_person', `Trigger enrichment for a person — professional info, social profiles. SPENDS VENDOR CREDITS (a Coresignal collect is 20 credits per person).${governedWriteNote('write:people')}`,
|
|
1739
|
+
{ personId: z.string(), ...governedWriteArgs },
|
|
1740
|
+
async ({ personId, ...g }) => runGovernedWrite(
|
|
1741
|
+
g,
|
|
1742
|
+
{
|
|
1743
|
+
method: 'POST',
|
|
1744
|
+
path: `/api/v1/people/${personId}/enrich`,
|
|
1745
|
+
preview: { entity: 'person', entityId: personId, operation: 'enrich', spendsVendorCredits: true },
|
|
1746
|
+
},
|
|
1747
|
+
));
|
|
1540
1748
|
|
|
1541
1749
|
server.tool('get_intent_signals', 'Get buying intent signals for a company.',
|
|
1542
1750
|
{ companyId: z.string() },
|
|
@@ -2055,6 +2263,17 @@ registerWorkBoardTools(server, {
|
|
|
2055
2263
|
ok,
|
|
2056
2264
|
validateApiBridgeRequest,
|
|
2057
2265
|
buildMutationHeaders,
|
|
2266
|
+
getGrantedScope: () => loadTokens()?.scope,
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
// The containers above the cards, and the "add this to the roadmap" verb
|
|
2270
|
+
// (company/decisions/2026-08-06-spoq-roadmap-sync.md).
|
|
2271
|
+
registerRoadmapTools(server, {
|
|
2272
|
+
z,
|
|
2273
|
+
api,
|
|
2274
|
+
ok,
|
|
2275
|
+
validateApiBridgeRequest,
|
|
2276
|
+
buildMutationHeaders,
|
|
2058
2277
|
});
|
|
2059
2278
|
|
|
2060
2279
|
// ===== DEMO SCHEDULING: Cal.com integration (free tier) =====
|
|
@@ -2120,6 +2339,11 @@ registerAlwaysLoadedTools(server, api, AUTH);
|
|
|
2120
2339
|
// still gates activation on policy review.
|
|
2121
2340
|
registerExtensibilityTools(server, api, AUTH);
|
|
2122
2341
|
|
|
2342
|
+
// The nine spaces, as reads. These were written, tiered and then never loaded — exported from
|
|
2343
|
+
// their module and imported nowhere, so every one of them was dark. An agent asking "what's in
|
|
2344
|
+
// Service" got nothing, while the app answered it fine.
|
|
2345
|
+
registerSpaceTools(server, { z, api, ok });
|
|
2346
|
+
|
|
2123
2347
|
// ---------------------------------------------------------------------------
|
|
2124
2348
|
// Start
|
|
2125
2349
|
// ---------------------------------------------------------------------------
|
package/server.json
CHANGED
|
@@ -10,6 +10,12 @@ making*. Card counts do not answer it. Time does.
|
|
|
10
10
|
|
|
11
11
|
## Scope
|
|
12
12
|
|
|
13
|
+
Start with `audit_work_hub` when the question is whether the workspace can be
|
|
14
|
+
trusted as an operating ledger. It checks every visible full board for missing
|
|
15
|
+
owners, missing criteria, missing work types, unhandled active passes, stale
|
|
16
|
+
cards, and truncated reads. Use the board and roll-up reads below when you need
|
|
17
|
+
the narrative behind those named findings.
|
|
18
|
+
|
|
13
19
|
- One team's board → `get_work_board`.
|
|
14
20
|
- Everything, across every client → `get_work_board_rollup` with `rollupId:
|
|
15
21
|
"all"`. This one has no membership rows, so it always includes a board created
|
|
@@ -66,9 +72,11 @@ policy for its own cards.
|
|
|
66
72
|
|
|
67
73
|
## Do not equate tag schemes
|
|
68
74
|
|
|
69
|
-
Boards can use different vocabularies — severity, priority,
|
|
75
|
+
Boards can use different URGENCY vocabularies — severity, priority, impact. A P1
|
|
70
76
|
is not a Critical, and nothing in this product will translate one into the
|
|
71
|
-
other.
|
|
77
|
+
other. (`kind` is NOT one of them: what a card *is* — bug, story, chore — is a
|
|
78
|
+
separate field, orthogonal to how urgent it is, so a card can be a bug AND
|
|
79
|
+
Critical. Read it as `kind` on the card, never as the board's `tagScheme`.) Rank within each board's own scheme and report them as they are. If you
|
|
72
80
|
need a single ordering across boards, say what you ordered by and that it is
|
|
73
81
|
your own reading, not the boards'.
|
|
74
82
|
|
|
@@ -37,13 +37,19 @@ validates against. The same sentences, doing both jobs. A card without them
|
|
|
37
37
|
makes the agent guess and gives QA nothing to check, which is how a card gets
|
|
38
38
|
signed off on the wrong thing.
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
Acceptance criteria are first-class records, separate from the card's `body`.
|
|
41
|
+
Pass them to `create_work_item.acceptanceCriteria` as executable
|
|
42
|
+
where/given/when/then checks:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
[
|
|
46
|
+
{
|
|
47
|
+
"whereText": "Exports in the production-like staging workspace",
|
|
48
|
+
"givenText": "An account list contains 10,000 rows",
|
|
49
|
+
"whenText": "The seller starts a CSV export",
|
|
50
|
+
"thenText": "The download begins without a 504 and the button stays disabled until it does"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
47
53
|
```
|
|
48
54
|
|
|
49
55
|
Two rules that decide whether you have written them correctly:
|
|
@@ -56,13 +62,14 @@ Two rules that decide whether you have written them correctly:
|
|
|
56
62
|
one, ten means you should have filed two.
|
|
57
63
|
- **Do not invent them**, on exactly the terms you do not invent a repro. If the
|
|
58
64
|
reporter never said what "fixed" looks like, write "acceptance criteria
|
|
59
|
-
unknown — reporter did not state what fixed looks like"
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
unknown — reporter did not state what fixed looks like" in the body and file
|
|
66
|
+
it with `acceptanceCriteria` empty. That is a triage problem for a human, and
|
|
67
|
+
naming it is what gets it triaged; a made-up criterion is worse, because QA
|
|
68
|
+
will sign the card off against it.
|
|
62
69
|
|
|
63
70
|
Implementation steps are **not** criteria and never become their own cards:
|
|
64
|
-
"create a React hook", "rename the CSS variables" are
|
|
65
|
-
|
|
71
|
+
"create a React hook", "rename the CSS variables" are implementation notes in
|
|
72
|
+
the body. There are deliberately no sub-tasks on this board.
|
|
66
73
|
|
|
67
74
|
## Writing it
|
|
68
75
|
|
|
@@ -80,8 +87,8 @@ grep for.
|
|
|
80
87
|
the workspace serves, use that company's board.
|
|
81
88
|
2. `get_work_board` → find the Triage column (the first one) and read the
|
|
82
89
|
board's `tagScheme`.
|
|
83
|
-
3. `create_work_item` with the title, the body,
|
|
84
|
-
which one it belongs to.
|
|
90
|
+
3. `create_work_item` with the title, the body, `acceptanceCriteria`, and
|
|
91
|
+
`product` if you can tell which one it belongs to.
|
|
85
92
|
|
|
86
93
|
**Leave `assigneeUserId` empty.** Filing a card is not picking it up, and an
|
|
87
94
|
assignee put on at filing time is a name nobody agreed to. An unowned card in the
|
|
@@ -113,7 +120,8 @@ decisions someone has to make.
|
|
|
113
120
|
## Writes
|
|
114
121
|
|
|
115
122
|
`create_work_item` and `set_work_item_tag` are governed writes: they preview by
|
|
116
|
-
default. Show the human the card you are about to file — title, body,
|
|
123
|
+
default. Show the human the card you are about to file — title, body, criteria,
|
|
124
|
+
tag — and
|
|
117
125
|
file it only after they say yes, with `dryRun: false`, `approved: true`, a
|
|
118
126
|
`reason`, and an `idempotencyKey`. Reuse the same key on retry so a flaky
|
|
119
127
|
connection does not file the same incident twice.
|
|
@@ -24,7 +24,8 @@ board untouched is work nobody can see.
|
|
|
24
24
|
3. `get_work_board` for the card's board when you need the target column id —
|
|
25
25
|
`move_work_item` moves to a column id, and only `get_work_board` has them.
|
|
26
26
|
4. `get_work_item` for the full body, `get_work_item_history` for how it got
|
|
27
|
-
here,
|
|
27
|
+
here, `list_work_item_acceptance_criteria` for the executable definition of
|
|
28
|
+
done, and `get_work_item_comments` for what people have SAID about it.
|
|
28
29
|
**Read both before starting.** A card that has bounced out of a review column
|
|
29
30
|
twice was rejected for a reason: the history says WHEN it bounced, the
|
|
30
31
|
comments say WHY. Repeating a rejected approach is the most expensive mistake
|
|
@@ -102,9 +103,9 @@ Two things to carry back to the card:
|
|
|
102
103
|
learns to ignore them.
|
|
103
104
|
- if the fix is bigger than the card, do not quietly expand scope. Finish what
|
|
104
105
|
the card asked for and report the rest as a card that should exist. If you
|
|
105
|
-
file it yourself, file it the way `incident-to-card` says: a title, a body
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
file it yourself, file it the way `incident-to-card` says: a title, a body,
|
|
107
|
+
first-class **acceptance criteria** in where/when/then form, and no assignee.
|
|
108
|
+
A card you file from here arrives
|
|
108
109
|
attributed to you automatically (the server stamps the creator from your
|
|
109
110
|
session), so the next person at aligning can ask you what you meant; what it
|
|
110
111
|
must not arrive with is a list of outcomes nobody can check.
|