@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/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 { authenticate, reauthenticate, checkToolAccess, assertOAuthIssuerMatchesTarget } from './access/auth.js';
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
- throw new Error(
176
- 'The shared `adrata login` session was rejected (401) — it may have expired. Run `adrata login` (or `@adrata login`) to sign in again.',
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', 'Create a new 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 (a) => ok(await api('POST', '/api/v1/companies', { body: a })));
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', 'Update company fields. customFields are JSONB-merged.',
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 }) => ok(await api('PATCH', `/api/v1/companies/${id}`, { body: body(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', 'Soft-delete a company.',
964
- { id: z.string() }, async (a) => { await api('DELETE', `/api/v1/companies/${a.id}`); return ok({ deleted: a.id }); });
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', '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.',
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 (a) => ok(await api('POST', '/api/v1/people', { body: a })));
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', '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).',
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 }) => ok(await api('PATCH', `/api/v1/people/${id}`, { body: body(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', 'Soft-delete a person.',
1015
- { id: z.string() }, async (a) => { await api('DELETE', `/api/v1/people/${a.id}`); return ok({ deleted: a.id }); });
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', 'Add a note to a person, company, or opportunity.',
1075
- { body: z.string().describe('The note text.'), companyId: z.string().optional(), personId: z.string().optional(), opportunityId: z.string().optional() },
1076
- async ({ body: text, ...rest }) => ok(await api('POST', '/api/v1/notes', { body: { content: text, ...rest } })));
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', 'Update note content.',
1083
- { id: z.string(), body: z.string().describe('The new note text.') },
1084
- async (a) => ok(await api('PUT', `/api/v1/notes/${a.id}`, { body: { content: a.body } })));
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', 'Delete a note.',
1087
- { id: z.string() }, async (a) => { await api('DELETE', `/api/v1/notes/${a.id}`); return ok({ deleted: a.id }); });
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
- /** Governance fields every buyer-group write tool accepts. */
1098
- const governedWriteArgs = {
1099
- dryRun: z.boolean().optional().describe('Defaults to true. Returns a preview of the exact call instead of performing it. Set false to execute.'),
1100
- 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.'),
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
- server.tool('enrich_company', 'Trigger enrichment for a company firmographic, news, competitors.',
1530
- { companyId: z.string() },
1531
- async (a) => ok(await api('POST', `/api/v1/companies/${a.companyId}/enrich`)));
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', 'Trigger enrichment for a person — professional info, social profiles.',
1538
- { personId: z.string() },
1539
- async (a) => ok(await api('POST', `/api/v1/people/${a.personId}/enrich`)));
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
@@ -35,12 +35,6 @@
35
35
  ]
36
36
  }
37
37
  ],
38
- "remotes": [
39
- {
40
- "type": "streamable-http",
41
- "url": "https://mcp.adrata.com/mcp"
42
- }
43
- ],
44
38
  "_meta": {
45
39
  "com.adrata": {
46
40
  "authorization": {
@@ -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, kind, impact. A P1
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. Rank within each board's own scheme and report them as they are. If you
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
- **There is no acceptance-criteria field.** They live in the card's `body`, under
41
- an `Acceptance criteria` heading, one checkable outcome per line:
42
-
43
- ```
44
- Acceptance criteria
45
- - Exporting 10,000 rows completes without a 504.
46
- - The export button is disabled until the download starts.
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" and file it. That is a
60
- triage problem for a human, and naming it is what gets it triaged; a made-up
61
- criterion is worse, because QA will sign the card off against it.
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 lines in this body, in a
65
- checklist under the criteria. There are deliberately no sub-tasks on this board.
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, and `product` if you can tell
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, tag — and
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, and `get_work_item_comments` for what people have SAID about it.
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
- carrying **acceptance criteria** what would prove it done, one checkable
107
- outcome per line — and no assignee. A card you file from here arrives
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.