@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.
@@ -11,8 +11,8 @@
11
11
  * 2. Tag each tool with a domain "pack" (crm, email, calendar, enrichment,
12
12
  * ...) so a client can request least-privilege scopes. When
13
13
  * ADRATA_MCP_ENABLED_DOMAINS is set, tools outside the enabled packs are
14
- * refused at dispatch a lightweight scoped-tool-pack enforcement that
15
- * avoids shipping N separate servers.
14
+ * omitted from registration by the server and refused at dispatch as
15
+ * defense in depth. A model never pays context for foreign product tools.
16
16
  *
17
17
  * Pure/stateless so it is unit-testable.
18
18
  */
@@ -39,10 +39,15 @@ const DOMAIN_OVERRIDES = {
39
39
  get_current_user: 'workspace',
40
40
 
41
41
  // Governed API bridge.
42
- adrata_api_catalog: 'bridge',
43
- adrata_api_request: 'bridge',
44
- adrata_ai_tool_catalog: 'bridge',
45
- adrata_ai_tool_execute: 'bridge',
42
+ // Broad backwards-compatible escape hatches stay on the unprofiled Adrata
43
+ // server. Product profiles use the namespace-bound capability bridge below.
44
+ adrata_api_catalog: 'legacy_bridge',
45
+ adrata_api_request: 'legacy_bridge',
46
+ adrata_ai_tool_catalog: 'legacy_bridge',
47
+ adrata_ai_tool_execute: 'legacy_bridge',
48
+ search_capabilities: 'bridge',
49
+ describe_capability: 'bridge',
50
+ run_capability: 'bridge',
46
51
 
47
52
  // Enrichment / external intelligence.
48
53
  enrich_company: 'enrichment',
@@ -105,9 +110,43 @@ const DOMAIN_OVERRIDES = {
105
110
 
106
111
  // Adrata Cloud: the bitemporal record substrate read.
107
112
  get_cloud_records: 'cloud',
113
+
114
+ // Starfield roadmap and future application families.
115
+ list_work_scopes: 'board',
116
+ add_to_roadmap: 'board',
117
+ audit_work_hub: 'board',
118
+ set_work_board_archived: 'board',
119
+ list_forms: 'forms',
120
+ get_form: 'forms',
121
+ create_form: 'forms',
122
+ update_form: 'forms',
123
+ publish_form: 'forms',
124
+ list_surveys: 'surveys',
125
+ get_survey: 'surveys',
126
+ create_survey: 'surveys',
127
+ publish_survey: 'surveys',
128
+
129
+ // Workspace membership is administration, never the general catch-all.
130
+ list_users: 'admin',
131
+ get_user: 'admin',
108
132
  };
109
133
 
110
134
  const DOMAIN_PREFIX_RULES = [
135
+ // Starfield's work boards. FIRST, because several of these names also contain
136
+ // tokens claimed by later rules and the first match wins.
137
+ //
138
+ // Without this rule all fourteen board tools resolved to the `general`
139
+ // catch-all along with sixty unrelated ones, which made the scoped-pack
140
+ // mechanism unable to express the one scope somebody actually wants: a
141
+ // coding agent that reads and actions the build board and reaches nothing
142
+ // else. `ADRATA_MCP_ENABLED_DOMAINS=general` would have granted 74 tools
143
+ // including CRM deletes. Naming the pack does not narrow anything on its own
144
+ // — `checkDomainScope` is inert unless the env var is set — it just makes the
145
+ // narrow scope expressible.
146
+ [/(work_item|work_board)/, 'board'],
147
+ [/(^|_)(form|forms)(_|$)/, 'forms'],
148
+ [/(^|_)(survey|surveys)(_|$)/, 'surveys'],
149
+ [/(workflow|automation)/, 'workflows'],
111
150
  [/^(create_email_account|list_email_accounts|warmup_email|get_email_health)/, 'infra'],
112
151
  [/^(search_domains|purchase_domain|setup_domain|verify_domain|list_domains)/, 'infra'],
113
152
  [/^(create_sequence|list_sequences|get_sequence|add_sequence_step|activate_sequence|pause_sequence|get_sequence_analytics|enroll_contacts|list_campaigns|get_campaign)/, 'sequences'],
@@ -151,6 +190,11 @@ export function checkDomainScope(name) {
151
190
  return { allowed: false, reason: 'domain_not_in_scope', domain, enabled: [...enabled] };
152
191
  }
153
192
 
193
+ /** Whether a scoped server should advertise/register this tool at all. */
194
+ export function shouldRegisterTool(name) {
195
+ return checkDomainScope(name).allowed;
196
+ }
197
+
154
198
  // ---------------------------------------------------------------------------
155
199
  // Behaviour classification (readOnly / destructive / idempotent / openWorld)
156
200
  // ---------------------------------------------------------------------------
@@ -192,6 +236,12 @@ const IDEMPOTENT_WRITES = new Set([
192
236
  'add_to_company_list', 'find_or_create_person', 'find_or_create_company',
193
237
  'save_memory', 'manage_custom_fields', 'build_pursuit_command_center',
194
238
  'update_buyer_group', 'update_buyer_group_member',
239
+ // Every Starfield board write is governed by a required idempotency key.
240
+ // Retrying the same key replays; it never appends a second transition,
241
+ // comment, criterion, or card.
242
+ 'move_work_item', 'set_work_board_column_wip_limit', 'set_work_item_tag', 'set_work_item_kind',
243
+ 'create_work_item', 'comment_on_work_item', 'flag_work_item',
244
+ 'add_work_item_acceptance_criterion',
195
245
  ]);
196
246
 
197
247
  // Non-read tools that create new state / have side effects each call.
@@ -219,7 +269,7 @@ function isReadOnly(name) {
219
269
  if (isDestructive(name) || IDEMPOTENT_WRITES.has(name) || NON_IDEMPOTENT_WRITES.has(name)) {
220
270
  return false;
221
271
  }
222
- return /^(get_|list_|search_|find_(?!or_create)|count_|describe_|check_|inspect_|rank_|score_|who_am_i|recall|workspace_status|morning_brief|export_data)/.test(name)
272
+ return /^(get_|list_|audit_|search_|find_(?!or_create)|count_|describe_|check_|inspect_|rank_|score_|who_am_i|recall|workspace_status|morning_brief|export_data)/.test(name)
223
273
  || name.endsWith('_catalog')
224
274
  || name.endsWith('_analytics')
225
275
  || name.endsWith('_status');
package/tools/billing.js CHANGED
@@ -141,7 +141,11 @@ export function registerBillingTools(server, { api, ok, auth }) {
141
141
  plan: auth.tier,
142
142
  status: 'active',
143
143
  message: 'Subscription details are available in your Adrata dashboard.',
144
- dashboard_url: 'https://app.adrata.com/settings/billing',
144
+ // Was `https://app.adrata.com/settings/billing`. That host has no DNS
145
+ // record (verified NXDOMAIN, 2026-08-18), so this fallback — which
146
+ // fires precisely when the billing endpoint is unavailable, i.e. when
147
+ // the user most needs a working link — handed them a dead URL.
148
+ dashboard_url: 'https://adrata.com/settings/billing',
145
149
  });
146
150
  }
147
151
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Roadmap tools for the Adrata MCP server — the "add this to the roadmap" verb.
3
+ *
4
+ * The design these implement is `company/decisions/2026-08-06-spoq-roadmap-sync.md`:
5
+ * the roadmap is ONE truth per field across two stores. Git (`spoq/epics/`)
6
+ * owns strategic commitment — that an epic exists, its slug, its title, its
7
+ * tier, its prose. Starfield owns operational state — features, cards,
8
+ * columns, owners. No field has two writers, so the stores cannot disagree
9
+ * about anything either one owns.
10
+ *
11
+ * That split is why "add this to the roadmap" is deliberately TWO acts:
12
+ *
13
+ * 1. `add_to_roadmap` (here) creates a PROPOSED epic-level scope through
14
+ * `POST /api/v1/work-scopes` — the same endpoint the app uses — so the
15
+ * idea is on the board and the Roadmap page the moment a human approves
16
+ * the write.
17
+ * 2. The git half is the AGENT'S work, not this server's: write
18
+ * `spoq/epics/backlog/<slug>/EPIC.md` and open a PR. This server talks
19
+ * to the API over HTTP from wherever it is installed; it has no
20
+ * checkout, and pretending a database row alone changed the roadmap of
21
+ * record would be exactly the lie the decision record exists to prevent.
22
+ * The tool's response says so instead, every time.
23
+ *
24
+ * The scope this tool creates carries no `source_roadmap_slug` — that column
25
+ * is the importer's, set only when the epic's PR merges to main and the
26
+ * import reconciles the tree. A scope born here is a Starfield-owned proposal
27
+ * until git confirms it, which is the true state of an idea whose PR has not
28
+ * landed.
29
+ *
30
+ * Writes follow the same governed contract as every write on this server:
31
+ * dryRun defaults to TRUE; a live write needs dryRun:false AND approved:true
32
+ * AND a reason AND an idempotencyKey.
33
+ */
34
+
35
+ /**
36
+ * Derive the spoq directory slug the agent should use for the git half.
37
+ *
38
+ * Deliberately the same shape the 373 existing directories use: lowercase,
39
+ * hyphen-separated, ASCII. This is a SUGGESTION for the response text — the
40
+ * agent may adjust it — so it aims for "obviously right most of the time",
41
+ * not for a normative spec of slugs.
42
+ */
43
+ export function suggestSlug(title) {
44
+ return String(title ?? '')
45
+ .toLowerCase()
46
+ .replace(/['’]/g, '')
47
+ .replace(/[^a-z0-9]+/g, '-')
48
+ .replace(/^-+|-+$/g, '')
49
+ .slice(0, 80);
50
+ }
51
+
52
+ /**
53
+ * The instruction that keeps the two halves of "add to the roadmap" together.
54
+ *
55
+ * Returned on every successful create AND on every dry-run preview, because
56
+ * the agent deciding whether to ask for approval is the same agent that must
57
+ * plan the PR. One omission here and the board and the repo drift on day one.
58
+ */
59
+ export function gitHalfInstructions(title) {
60
+ const slug = suggestSlug(title) || '<slug>';
61
+ return (
62
+ `This scope is a Starfield-side proposal only — the roadmap of record is the git tree. ` +
63
+ `To make it a strategic commitment: create spoq/epics/backlog/${slug}/EPIC.md ` +
64
+ `(the directory basename is the epic's identity; keep it stable), write the epic as prose ` +
65
+ `with a "## Status" heading, and open a PR. When that PR merges to main, the importer ` +
66
+ `links the merged slug to this scope by exact title match — so keep the EPIC.md title ` +
67
+ `byte-identical to this scope's title, or expect two visible rows to merge by hand later.`
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Register the roadmap tools.
73
+ *
74
+ * @param {McpServer} server - the MCP server instance (already tier-gated)
75
+ * @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
76
+ */
77
+ export function registerRoadmapTools(
78
+ server,
79
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
80
+ ) {
81
+ server.tool(
82
+ 'list_work_scopes',
83
+ `The containers ABOVE the cards: every initiative, epic, and feature in the workspace, flat, each with its parent id and its counted progress (cards / shipped / in QA). This is the roadmap as Starfield sees it — an epic whose source_roadmap_slug is set was imported from spoq/epics/ in the repo and its title and status are owned by git (change them through a PR, not here); a scope with no slug was born in Starfield.
84
+
85
+ Progress is COUNTS, never a percentage: "7 of 12 shipped" is honest where "58%" is a feeling. A scope with zero cards is a real state (a commitment nobody has broken down yet), not an error.
86
+
87
+ Use this before add_to_roadmap: if an epic with the same intent already exists, the roadmap needs your card or feature under it, not a duplicate epic beside it.`,
88
+ {},
89
+ async () => {
90
+ const data = await api('GET', '/api/v1/work-scopes');
91
+ const scopes = data?.data || [];
92
+ return ok({
93
+ count: scopes.length,
94
+ scopes,
95
+ note:
96
+ scopes.length === 0
97
+ ? 'No scopes yet. Either the workspace has no roadmap containers, or the work_scopes schema has not been deployed here.'
98
+ : undefined,
99
+ });
100
+ }
101
+ );
102
+
103
+ server.tool(
104
+ 'add_to_roadmap',
105
+ `Add an idea to the roadmap as a PROPOSED epic — the "add this to the roadmap" verb. Creates an epic-level scope (status: proposed, i.e. below the line) through the same endpoint the app uses, so it appears on the Roadmap page immediately.
106
+
107
+ THIS IS HALF THE JOB, AND THE TOOL WILL SAY SO. The roadmap of record is the git tree (spoq/epics/ — see company/decisions/2026-08-06-spoq-roadmap-sync.md): a database row makes the idea visible, a merged PR makes it a commitment. The response tells you the exact spoq/ path to create; if you are an agent with a checkout, plan on writing the EPIC.md and opening the PR as your next step, with the SAME title.
108
+
109
+ DO NOT use this for work that belongs under an existing epic — check list_work_scopes first and file a card (create_work_item) or ask a human to add a feature instead. A roadmap of near-duplicate epics is how a roadmap stops being read.${''}
110
+
111
+ Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey (reuse the SAME key on retry — the server replays instead of creating a second epic).`,
112
+ {
113
+ title: z
114
+ .string()
115
+ .describe(
116
+ 'What the epic is, as a statement of the outcome. This exact string becomes the scope title AND should be the EPIC.md title in the PR — the importer adopts this scope by byte-identical title match when the PR merges.'
117
+ ),
118
+ parentScopeId: z
119
+ .string()
120
+ .optional()
121
+ .describe(
122
+ 'Initiative to hang this epic under, from list_work_scopes. OMIT unless one genuinely claims it — most epics are parentless and that is legal forever.'
123
+ ),
124
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to create it.'),
125
+ approved: z.boolean().optional().describe('Required true for a live create.'),
126
+ reason: z
127
+ .string()
128
+ .optional()
129
+ .describe(
130
+ 'Required for a live create: why this belongs on the roadmap — the ask or evidence that produced it, not "add epic".'
131
+ ),
132
+ idempotencyKey: z.string().optional().describe('Required for a live create.'),
133
+ },
134
+ async (args) => {
135
+ const path = '/api/v1/work-scopes';
136
+ const preview = validateApiBridgeRequest({
137
+ method: 'POST',
138
+ path,
139
+ dryRun: args.dryRun,
140
+ approved: args.approved,
141
+ reason: args.reason,
142
+ idempotencyKey: args.idempotencyKey,
143
+ });
144
+ if (preview?.dryRun) {
145
+ return ok({
146
+ ...preview,
147
+ wouldCreate: {
148
+ level: 'epic',
149
+ title: args.title,
150
+ parentScopeId: args.parentScopeId,
151
+ },
152
+ gitHalf: gitHalfInstructions(args.title),
153
+ });
154
+ }
155
+
156
+ let data;
157
+ try {
158
+ data = await api('POST', path, {
159
+ body: {
160
+ title: args.title,
161
+ level: 'epic',
162
+ parentScopeId: args.parentScopeId,
163
+ },
164
+ headers: buildMutationHeaders(args),
165
+ });
166
+ } catch (error) {
167
+ // The work-scopes routes land with PR #1819. Against an API without
168
+ // them the failure is a bare 404, which reads as "you did something
169
+ // wrong" when the truth is "this deployment cannot do this yet" —
170
+ // name the real situation instead of letting the agent retry a wall.
171
+ if (/404/.test(String(error?.message ?? ''))) {
172
+ return ok({
173
+ error: true,
174
+ message:
175
+ 'This API deployment has no /api/v1/work-scopes routes yet (they land with the work_scopes schema, PR #1819). ' +
176
+ 'The git half still works: ' +
177
+ gitHalfInstructions(args.title),
178
+ });
179
+ }
180
+ throw error;
181
+ }
182
+ return ok({
183
+ created: true,
184
+ scope: data?.data,
185
+ gitHalf: gitHalfInstructions(args.title),
186
+ });
187
+ }
188
+ );
189
+ }
190
+
191
+ /** Tool names registered here, for the tier map. */
192
+ export const ROADMAP_TOOL_NAMES = ['list_work_scopes', 'add_to_roadmap'];