@kivimedia/kmhub 2.9.0 → 2.10.0

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.
Files changed (57) hide show
  1. package/README.md +170 -170
  2. package/bin/kmhub.mjs +896 -896
  3. package/coach-book-output-guard.mjs +760 -760
  4. package/index.mjs +57 -57
  5. package/package.json +56 -56
  6. package/prompts/briefing.md +29 -29
  7. package/prompts/luxury.md +70 -70
  8. package/prompts/play.md +49 -49
  9. package/prompts/run.md +36 -36
  10. package/prompts/setup.md +33 -33
  11. package/prompts/vs-booked.md +46 -46
  12. package/prompts/what-can-you-do.md +40 -40
  13. package/prompts.mjs +110 -109
  14. package/read-only-tools.json +143 -142
  15. package/remote.mjs +929 -929
  16. package/tools/balloon-costing.mjs +80 -80
  17. package/tools/booking-equipment.mjs +110 -110
  18. package/tools/bridges.mjs +54 -54
  19. package/tools/briefing.mjs +91 -91
  20. package/tools/calendar.mjs +170 -170
  21. package/tools/capabilities.mjs +155 -155
  22. package/tools/catalog.mjs +288 -288
  23. package/tools/clubs.mjs +176 -176
  24. package/tools/coach.mjs +771 -771
  25. package/tools/compare.mjs +76 -76
  26. package/tools/core.mjs +244 -244
  27. package/tools/crm.mjs +209 -209
  28. package/tools/dubsado.mjs +137 -137
  29. package/tools/exports.mjs +128 -128
  30. package/tools/fact-review.mjs +125 -125
  31. package/tools/flows.mjs +261 -261
  32. package/tools/forms.mjs +158 -158
  33. package/tools/gols.mjs +134 -134
  34. package/tools/hr.mjs +162 -162
  35. package/tools/knowledge.mjs +125 -125
  36. package/tools/marketing.mjs +396 -396
  37. package/tools/meta.mjs +245 -245
  38. package/tools/military.mjs +244 -244
  39. package/tools/money.mjs +235 -197
  40. package/tools/outreach.mjs +238 -238
  41. package/tools/pending.mjs +122 -122
  42. package/tools/photos.mjs +140 -140
  43. package/tools/plays.mjs +244 -244
  44. package/tools/profile.mjs +118 -118
  45. package/tools/radar.mjs +173 -173
  46. package/tools/recurring-invoices.mjs +149 -149
  47. package/tools/reengage.mjs +434 -434
  48. package/tools/schedules.mjs +55 -55
  49. package/tools/setup.mjs +168 -168
  50. package/tools/sops-bridges.mjs +86 -86
  51. package/tools/sops.mjs +314 -314
  52. package/tools/sourcing.mjs +268 -268
  53. package/tools/strategy.mjs +146 -146
  54. package/tools/studio.mjs +132 -132
  55. package/tools/venueradar.mjs +151 -151
  56. package/tools/voice.mjs +134 -134
  57. package/tools.mjs +407 -407
package/tools.mjs CHANGED
@@ -1,407 +1,407 @@
1
- /**
2
- * kmhub-mcp tool index (P5, families since 2.0.0).
3
- *
4
- * Single source of truth for the KM Hub MCP tools. Both transports import this:
5
- * - index.mjs -> local stdio server (one process = one org key from env)
6
- * - remote.mjs -> remote Streamable-HTTP server (one process = many orgs,
7
- * each request scoped by the caller's Bearer token)
8
- *
9
- * A tool is registered against an McpServer instance with a `call` function that
10
- * is already bound to a single org's KM Hub API token. That keeps every tenant's
11
- * data isolated: the token IS the scope, and one org can never reach another's rows.
12
- *
13
- * The tools themselves live one family per file in ./tools/, and this file only
14
- * discovers, orders and filters them. Two consequences worth knowing:
15
- *
16
- * 1. Adding a family means adding a file. Nothing in here has to change, so a
17
- * dozen people can add a dozen families without ever editing the same line.
18
- * The contract every family file follows is ./tools/README.md.
19
- * 2. Loading is defensive. A family that fails to import, declares itself badly,
20
- * or claims a tool name another family already took is skipped with a warning
21
- * on stderr - it never takes the live server down with it.
22
- *
23
- * PROFILES exist because tool schemas are a per-turn context tax: every tool this
24
- * server registers is spent out of the caller's context window on every single
25
- * turn, used or not. A profile is a smaller, honest tool set for a caller who only
26
- * wants one side of the workspace. `full` is the default and holds everything, so
27
- * an existing client sees no change.
28
- */
29
- import { readdirSync, readFileSync } from 'node:fs';
30
- import { z } from 'zod';
31
-
32
- export const SERVER_NAME = 'kmhub';
33
- export const SERVER_VERSION = '2.9.0';
34
-
35
- export const DEFAULT_BASE =
36
- process.env.KMHUB_API_BASE ||
37
- 'https://jpwbosrmkibsdgckowsm.supabase.co/functions/v1/kmhub-api';
38
-
39
- /**
40
- * Build a `call(method, path, body)` bound to one org's KM Hub API token.
41
- * Every request to the kmhub-api edge function carries this token, so the edge
42
- * function does the authoritative org scoping and scope-check (read vs write).
43
- */
44
- export function makeCaller(base, token) {
45
- return async function call(method, path, body) {
46
- const r = await fetch(`${base}${path}`, {
47
- method,
48
- headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
49
- body: body ? JSON.stringify(body) : undefined,
50
- });
51
- const text = await r.text();
52
- let data;
53
- try {
54
- data = JSON.parse(text);
55
- } catch {
56
- data = text;
57
- }
58
- return { ok: r.ok, status: r.status, data };
59
- };
60
- }
61
-
62
- // ---- shared response helpers ----------------------------------------------
63
- // Every family formats its answers through these, so a change here reaches all
64
- // of them at once - present and future.
65
-
66
- const BILLING_FALLBACK =
67
- 'To pick things back up, sign in at https://hub.kivimedia.co and restart the subscription under Settings > Billing. Once that is done, ask me again and it will just work.';
68
-
69
- /**
70
- * Format a kmhub-api result as an MCP tool response.
71
- *
72
- * A 402 (subscription_inactive) is rewritten into plain English on purpose. The
73
- * person on the other end of this is a working performer, not an engineer, and
74
- * "your subscription lapsed" is something they can act on where a JSON blob is not.
75
- * Every other status keeps the raw JSON body, which is what the model needs to
76
- * reason about validation errors and the like.
77
- */
78
- export function out(r) {
79
- const body = r && r.data && typeof r.data === 'object' ? r.data : {};
80
- const inactive = r && (r.status === 402 || body.error === 'subscription_inactive');
81
- if (inactive) {
82
- const detail = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : '';
83
- const url = typeof body.billing_url === 'string' && body.billing_url.trim() ? body.billing_url.trim() : '';
84
- const lines = [
85
- 'Your KM Hub subscription is not active at the moment, so I could not do that.',
86
- 'Nothing in your workspace has changed, and none of your data has gone anywhere.',
87
- ];
88
- if (detail) lines.push(detail);
89
- lines.push(url ? `To pick things back up, open ${url} and restart the subscription.` : BILLING_FALLBACK);
90
- return { content: [{ type: 'text', text: lines.join('\n\n') }], isError: true };
91
- }
92
- // The way back to the window (Freshdesk #271, Jackie): "I always have this problem
93
- // of not getting back to the correct window using Claude and KM Hub once it is
94
- // closed." A route that answers out of one page of the app says so in
95
- // `open_in_km_hub`, and that line is appended in plain text rather than left buried
96
- // in the JSON, so the model reliably shows her a link she can click twice.
97
- const link = typeof body.open_in_km_hub === 'string' ? body.open_in_km_hub.trim() : '';
98
- const json = JSON.stringify(r.data, null, 2);
99
- const payload = link ? `${json}\n\nOpen in KM Hub: ${link}` : json;
100
- return { content: [{ type: 'text', text: payload }], isError: !r.ok };
101
- }
102
-
103
- /** A plain prose answer. Defaults to a success result: not every "no" is an error. */
104
- export function text(message, isError = false) {
105
- return { content: [{ type: 'text', text: String(message) }], isError };
106
- }
107
-
108
- /** Build a query string from an object, dropping empty values. Returns '' or '?a=b'. */
109
- export function qs(params) {
110
- const q = new URLSearchParams();
111
- for (const [k, v] of Object.entries(params || {})) {
112
- if (v === undefined || v === null || v === '') continue;
113
- q.set(k, String(v));
114
- }
115
- const s = q.toString();
116
- return s ? `?${s}` : '';
117
- }
118
-
119
- // ---- profiles --------------------------------------------------------------
120
- //
121
- // A profile is a named, SMALLER tool set. This map is the authority for it, and
122
- // there are two rules. The second one is the one that used to be missing:
123
- //
124
- // 1. A profile lists the families it loads. `full` is '*' and loads everything.
125
- // 2. For a family this map PLACES (see PLACED below), the map is the whole
126
- // answer. Leaving a family out of a profile is a decision rather than an
127
- // oversight, and that family's own PROFILES export no longer overrides it.
128
- //
129
- // Rule 2 exists because without it the split was theatre. Six of the ten families
130
- // declared PROFILES ['*'] and pinned themselves into every profile, so `core` was
131
- // 31 of the 63 tools, `outreach` was 54, and a `content` profile returned 37 tools
132
- // and not one content tool, because no content family has ever existed. Eleven of
133
- // the fifteen family names this map used to list were phantoms that no file
134
- // declared. All of it advertised a saving that was not delivered, which is worse
135
- // than having no profiles at all: a caller who asked for less and silently got
136
- // nearly everything had no way to find that out.
137
- //
138
- // A family this map has never heard of keeps the self-service opt-in documented in
139
- // ./tools/README.md, so a new family still joins a profile without anyone editing
140
- // this file, and it always lands in `full` whatever it declares.
141
- //
142
- // What each profile is FOR, and what it actually costs (measured, not estimated):
143
- //
144
- // core 13 Orientation and the CRM floor: whose workspace this is, what is
145
- // waiting, the briefing, and the original ten tools. No plays here.
146
- // A play's task calls tools from every family at once, so a run
147
- // started on a narrow profile fails halfway through, which is a
148
- // worse outcome than not offering the play.
149
- // money The money side, whole. The read-only money tools, plus hr because
150
- // payroll is money owed to the people who did the work, plus crm
151
- // so you can open the client you are about to chase, plus knowledge
152
- // because the published ladder is the only legal source of a number.
153
- // outreach 51 The cold outreach machine: outreach and sourcing, plus crm to turn
154
- // a name into a record, knowledge to ground the words, and calendar
155
- // because you never offer a date you have not checked. This profile
156
- // saves the least of the three, and that is honest rather than
157
- // disappointing: the outreach job genuinely reaches most of the
158
- // workspace. `core` is the profile that buys real context back.
159
- // content The being-found side: SEO and the newsletter, plus crm and
160
- // knowledge, because content that does not know who buys or what
161
- // the business sounds like is generic content. This profile only
162
- // became real when the marketing family shipped (workstream B1);
163
- // before that the name existed in the installer and silently fell
164
- // back to `full`, which was a small lie told to anyone who used it.
165
- // full Everything, including the plays. The default.
166
- // coach Fully Booked Coach operations, client delivery, acquisition,
167
- // content requests, and approval previews. It exposes no send
168
- // or publish tool.
169
- //
170
- // An unknown profile name still gets `full` rather than an error, exactly as before.
171
-
172
- export const DEFAULT_PROFILE = 'full';
173
-
174
- export const PROFILES = {
175
- core: ['core', 'meta', 'briefing'],
176
- money: ['core', 'meta', 'briefing', 'crm', 'knowledge', 'money', 'hr'],
177
- outreach: ['core', 'meta', 'briefing', 'crm', 'calendar', 'knowledge', 'outreach', 'sourcing', 'marketing'],
178
- content: ['core', 'meta', 'briefing', 'crm', 'knowledge', 'marketing'],
179
- full: '*',
180
- coach: ['coach'],
181
- };
182
-
183
- /**
184
- * The families this map speaks for. Everything named in a narrow profile above was
185
- * placed by hand, so its own PROFILES export stops deciding where it lands. `plays`
186
- * has to be named here on its own, because it is placed too and placed nowhere but
187
- * `full`: without this line its own ['*'] would put it back into all three.
188
- */
189
- // Coach is a public pilot surface with an explicit safety review. New families
190
- // must not silently join it through a wildcard declaration.
191
- const CLOSED_PROFILES = new Set(['coach']);
192
-
193
- const PLACED = new Set([...Object.values(PROFILES).filter(Array.isArray).flat(), 'plays']);
194
-
195
- export const PROFILE_NAMES = Object.keys(PROFILES);
196
-
197
- /** Normalise a caller-supplied profile. Unknown or empty falls back to `full`, never throws. */
198
- export function resolveProfile(raw) {
199
- const p = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
200
- return Object.prototype.hasOwnProperty.call(PROFILES, p) ? p : DEFAULT_PROFILE;
201
- }
202
-
203
- // ---- family discovery ------------------------------------------------------
204
-
205
- const TOOLS_DIR = new URL('./tools/', import.meta.url);
206
-
207
- function warn(msg) {
208
- console.error(`kmhub-mcp: ${msg}`);
209
- }
210
-
211
- async function discoverFamilies() {
212
- let files;
213
- try {
214
- files = readdirSync(TOOLS_DIR)
215
- .filter((f) => f.endsWith('.mjs'))
216
- .sort();
217
- } catch (e) {
218
- warn(`could not read the tools/ directory: ${String(e?.message || e)}. No tools are available.`);
219
- return [];
220
- }
221
-
222
- const loaded = [];
223
- const seen = new Set();
224
- for (const file of files) {
225
- let mod;
226
- try {
227
- mod = await import(new URL(file, TOOLS_DIR).href);
228
- } catch (e) {
229
- warn(`skipping tools/${file}, it failed to load: ${String(e?.message || e)}`);
230
- continue;
231
- }
232
- const family = typeof mod?.FAMILY === 'string' ? mod.FAMILY.trim() : '';
233
- if (!family) {
234
- warn(`skipping tools/${file}, it does not export a FAMILY name.`);
235
- continue;
236
- }
237
- if (typeof mod.register !== 'function') {
238
- warn(`skipping tools/${file}, family '${family}' does not export a register() function.`);
239
- continue;
240
- }
241
- if (seen.has(family)) {
242
- warn(`skipping tools/${file}, family '${family}' is already declared by another file.`);
243
- continue;
244
- }
245
- seen.add(family);
246
- loaded.push({
247
- FAMILY: family,
248
- TOOLS: Array.isArray(mod.TOOLS) ? mod.TOOLS.filter((t) => typeof t === 'string' && t) : [],
249
- PROFILES: Array.isArray(mod.PROFILES) ? mod.PROFILES.filter((p) => typeof p === 'string' && p) : [],
250
- register: mod.register,
251
- file,
252
- });
253
- }
254
-
255
- // Deterministic order so tools/list is stable across processes and restarts:
256
- // core first (it is the original ten, and clients expect to see them first),
257
- // then alphabetical.
258
- loaded.sort((a, b) => {
259
- if (a.FAMILY === 'core') return -1;
260
- if (b.FAMILY === 'core') return 1;
261
- return a.FAMILY.localeCompare(b.FAMILY);
262
- });
263
- return loaded;
264
- }
265
-
266
- const FAMILIES = await discoverFamilies();
267
-
268
- export const FAMILY_NAMES = FAMILIES.map((f) => f.FAMILY);
269
-
270
- /**
271
- * Is this family part of this profile?
272
- *
273
- * `full` takes everything. Otherwise the map decides for every family it places,
274
- * and only a family the map has never heard of gets to opt itself in.
275
- */
276
- function inProfile(family, profile) {
277
- if (profile === DEFAULT_PROFILE) return true;
278
- const listed = PROFILES[profile];
279
- if (listed === '*') return true;
280
- if (Array.isArray(listed) && listed.includes(family.FAMILY)) return true;
281
- if (CLOSED_PROFILES.has(profile)) return false;
282
- if (PLACED.has(family.FAMILY)) return false;
283
- return family.PROFILES.includes('*') || family.PROFILES.includes(profile);
284
- }
285
-
286
- /** The family records a profile loads, in registration order. */
287
- export function familiesFor(profile) {
288
- const p = resolveProfile(profile);
289
- return FAMILIES.filter((f) => inProfile(f, p));
290
- }
291
-
292
- /** Declared tool names for a profile (deduped, in registration order). */
293
- export function toolNamesFor(profile) {
294
- const names = [];
295
- for (const f of familiesFor(profile)) {
296
- for (const t of f.TOOLS) if (!names.includes(t)) names.push(t);
297
- }
298
- return names;
299
- }
300
-
301
- /** Every declared tool name across every family. Kept for the health payload and startup logs. */
302
- export const TOOL_NAMES = toolNamesFor(DEFAULT_PROFILE);
303
-
304
- // ---- registration ----------------------------------------------------------
305
-
306
- /**
307
- * Hand a family a registrar rather than the raw McpServer, so one family can never
308
- * claim a tool name another family already took. A duplicate name would otherwise
309
- * make the SDK throw mid-request and cost the caller every tool, not just the
310
- * clashing one. `raw` is there for the rare family that needs the real server.
311
- */
312
- /**
313
- * The tools announced as read-only, from read-only-tools.json (generated from the tool
314
- * source by scripts/generate-read-only-tools.mjs - see there for the rule).
315
- *
316
- * 🚨 CODEX STOPS EVERY UNANNOTATED TOOL FOR AN APPROVAL. With no hint on any KM Hub tool,
317
- * a Codex session asked permission before `km_me`, and `codex exec` failed every call with
318
- * "MCP tool call requires approval". readOnlyHint: true is what lets a read run. Writes stay
319
- * unannotated on purpose, so the person still approves each one.
320
- *
321
- * A missing or broken file costs the hints, never the tools.
322
- */
323
- const READ_ONLY_TOOLS = (() => {
324
- try {
325
- const doc = JSON.parse(readFileSync(new URL('./read-only-tools.json', import.meta.url), 'utf8'));
326
- return new Set(Array.isArray(doc.tools) ? doc.tools : []);
327
- } catch (e) {
328
- warn(`read-only-tools.json could not be read (${String(e?.message || e)}). Every tool will be announced without a read-only hint.`);
329
- return new Set();
330
- }
331
- })();
332
-
333
- export function isReadOnlyTool(name) {
334
- return READ_ONLY_TOOLS.has(name);
335
- }
336
-
337
- function markReadOnly(name, registeredTool) {
338
- if (!registeredTool || !READ_ONLY_TOOLS.has(name) || typeof registeredTool.update !== 'function') return registeredTool;
339
- try {
340
- registeredTool.update({ annotations: { ...(registeredTool.annotations || {}), readOnlyHint: true } });
341
- } catch (e) {
342
- warn(`could not mark '${name}' read-only (${String(e?.message || e)}). It still works; hosts will ask before running it.`);
343
- }
344
- return registeredTool;
345
- }
346
-
347
- function guardedRegistrar(server, taken, family, registered) {
348
- const claim = (name) => {
349
- if (typeof name !== 'string' || !name.trim()) {
350
- warn(`family '${family}' tried to register a tool with no name. Ignored.`);
351
- return false;
352
- }
353
- const owner = taken.get(name);
354
- if (owner) {
355
- warn(`tool name collision on '${name}': family '${owner}' already registered it, so family '${family}' does not get it.`);
356
- return false;
357
- }
358
- taken.set(name, family);
359
- registered.push(name);
360
- return true;
361
- };
362
- return {
363
- tool: (name, ...rest) => (claim(name) ? markReadOnly(name, server.tool(name, ...rest)) : undefined),
364
- registerTool: (name, ...rest) => (claim(name) ? markReadOnly(name, server.registerTool(name, ...rest)) : undefined),
365
- prompt: (...args) => server.prompt(...args),
366
- resource: (...args) => server.resource(...args),
367
- raw: server,
368
- };
369
- }
370
-
371
- /**
372
- * Register the KM Hub tools for one profile on an McpServer.
373
- *
374
- * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
375
- * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
376
- * already bound to a single org's API token
377
- * @param {{ profile?: string }} [opts] profile name; unknown or absent means `full`
378
- * @returns {{ profile: string, families: string[], tools: string[] }} what actually registered
379
- */
380
- export function registerTools(server, call, opts = {}) {
381
- const profile = resolveProfile(opts.profile);
382
- const families = familiesFor(profile);
383
- const taken = new Map();
384
- const registered = [];
385
-
386
- for (const family of families) {
387
- const helpers = {
388
- out,
389
- text,
390
- qs,
391
- z,
392
- profile,
393
- family: family.FAMILY,
394
- SERVER_NAME,
395
- SERVER_VERSION,
396
- DEFAULT_BASE,
397
- };
398
- try {
399
- family.register(guardedRegistrar(server, taken, family.FAMILY, registered), call, helpers);
400
- } catch (e) {
401
- // One bad family must not cost the caller the other nine.
402
- warn(`family '${family.FAMILY}' failed to register: ${String(e?.message || e)}. Its tools are unavailable; the rest still work.`);
403
- }
404
- }
405
-
406
- return { profile, families: families.map((f) => f.FAMILY), tools: registered };
407
- }
1
+ /**
2
+ * kmhub-mcp tool index (P5, families since 2.0.0).
3
+ *
4
+ * Single source of truth for the KM Hub MCP tools. Both transports import this:
5
+ * - index.mjs -> local stdio server (one process = one org key from env)
6
+ * - remote.mjs -> remote Streamable-HTTP server (one process = many orgs,
7
+ * each request scoped by the caller's Bearer token)
8
+ *
9
+ * A tool is registered against an McpServer instance with a `call` function that
10
+ * is already bound to a single org's KM Hub API token. That keeps every tenant's
11
+ * data isolated: the token IS the scope, and one org can never reach another's rows.
12
+ *
13
+ * The tools themselves live one family per file in ./tools/, and this file only
14
+ * discovers, orders and filters them. Two consequences worth knowing:
15
+ *
16
+ * 1. Adding a family means adding a file. Nothing in here has to change, so a
17
+ * dozen people can add a dozen families without ever editing the same line.
18
+ * The contract every family file follows is ./tools/README.md.
19
+ * 2. Loading is defensive. A family that fails to import, declares itself badly,
20
+ * or claims a tool name another family already took is skipped with a warning
21
+ * on stderr - it never takes the live server down with it.
22
+ *
23
+ * PROFILES exist because tool schemas are a per-turn context tax: every tool this
24
+ * server registers is spent out of the caller's context window on every single
25
+ * turn, used or not. A profile is a smaller, honest tool set for a caller who only
26
+ * wants one side of the workspace. `full` is the default and holds everything, so
27
+ * an existing client sees no change.
28
+ */
29
+ import { readdirSync, readFileSync } from 'node:fs';
30
+ import { z } from 'zod';
31
+
32
+ export const SERVER_NAME = 'kmhub';
33
+ export const SERVER_VERSION = '2.10.0';
34
+
35
+ export const DEFAULT_BASE =
36
+ process.env.KMHUB_API_BASE ||
37
+ 'https://jpwbosrmkibsdgckowsm.supabase.co/functions/v1/kmhub-api';
38
+
39
+ /**
40
+ * Build a `call(method, path, body)` bound to one org's KM Hub API token.
41
+ * Every request to the kmhub-api edge function carries this token, so the edge
42
+ * function does the authoritative org scoping and scope-check (read vs write).
43
+ */
44
+ export function makeCaller(base, token) {
45
+ return async function call(method, path, body) {
46
+ const r = await fetch(`${base}${path}`, {
47
+ method,
48
+ headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
49
+ body: body ? JSON.stringify(body) : undefined,
50
+ });
51
+ const text = await r.text();
52
+ let data;
53
+ try {
54
+ data = JSON.parse(text);
55
+ } catch {
56
+ data = text;
57
+ }
58
+ return { ok: r.ok, status: r.status, data };
59
+ };
60
+ }
61
+
62
+ // ---- shared response helpers ----------------------------------------------
63
+ // Every family formats its answers through these, so a change here reaches all
64
+ // of them at once - present and future.
65
+
66
+ const BILLING_FALLBACK =
67
+ 'To pick things back up, sign in at https://hub.kivimedia.co and restart the subscription under Settings > Billing. Once that is done, ask me again and it will just work.';
68
+
69
+ /**
70
+ * Format a kmhub-api result as an MCP tool response.
71
+ *
72
+ * A 402 (subscription_inactive) is rewritten into plain English on purpose. The
73
+ * person on the other end of this is a working performer, not an engineer, and
74
+ * "your subscription lapsed" is something they can act on where a JSON blob is not.
75
+ * Every other status keeps the raw JSON body, which is what the model needs to
76
+ * reason about validation errors and the like.
77
+ */
78
+ export function out(r) {
79
+ const body = r && r.data && typeof r.data === 'object' ? r.data : {};
80
+ const inactive = r && (r.status === 402 || body.error === 'subscription_inactive');
81
+ if (inactive) {
82
+ const detail = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : '';
83
+ const url = typeof body.billing_url === 'string' && body.billing_url.trim() ? body.billing_url.trim() : '';
84
+ const lines = [
85
+ 'Your KM Hub subscription is not active at the moment, so I could not do that.',
86
+ 'Nothing in your workspace has changed, and none of your data has gone anywhere.',
87
+ ];
88
+ if (detail) lines.push(detail);
89
+ lines.push(url ? `To pick things back up, open ${url} and restart the subscription.` : BILLING_FALLBACK);
90
+ return { content: [{ type: 'text', text: lines.join('\n\n') }], isError: true };
91
+ }
92
+ // The way back to the window (Freshdesk #271, Jackie): "I always have this problem
93
+ // of not getting back to the correct window using Claude and KM Hub once it is
94
+ // closed." A route that answers out of one page of the app says so in
95
+ // `open_in_km_hub`, and that line is appended in plain text rather than left buried
96
+ // in the JSON, so the model reliably shows her a link she can click twice.
97
+ const link = typeof body.open_in_km_hub === 'string' ? body.open_in_km_hub.trim() : '';
98
+ const json = JSON.stringify(r.data, null, 2);
99
+ const payload = link ? `${json}\n\nOpen in KM Hub: ${link}` : json;
100
+ return { content: [{ type: 'text', text: payload }], isError: !r.ok };
101
+ }
102
+
103
+ /** A plain prose answer. Defaults to a success result: not every "no" is an error. */
104
+ export function text(message, isError = false) {
105
+ return { content: [{ type: 'text', text: String(message) }], isError };
106
+ }
107
+
108
+ /** Build a query string from an object, dropping empty values. Returns '' or '?a=b'. */
109
+ export function qs(params) {
110
+ const q = new URLSearchParams();
111
+ for (const [k, v] of Object.entries(params || {})) {
112
+ if (v === undefined || v === null || v === '') continue;
113
+ q.set(k, String(v));
114
+ }
115
+ const s = q.toString();
116
+ return s ? `?${s}` : '';
117
+ }
118
+
119
+ // ---- profiles --------------------------------------------------------------
120
+ //
121
+ // A profile is a named, SMALLER tool set. This map is the authority for it, and
122
+ // there are two rules. The second one is the one that used to be missing:
123
+ //
124
+ // 1. A profile lists the families it loads. `full` is '*' and loads everything.
125
+ // 2. For a family this map PLACES (see PLACED below), the map is the whole
126
+ // answer. Leaving a family out of a profile is a decision rather than an
127
+ // oversight, and that family's own PROFILES export no longer overrides it.
128
+ //
129
+ // Rule 2 exists because without it the split was theatre. Six of the ten families
130
+ // declared PROFILES ['*'] and pinned themselves into every profile, so `core` was
131
+ // 31 of the 63 tools, `outreach` was 54, and a `content` profile returned 37 tools
132
+ // and not one content tool, because no content family has ever existed. Eleven of
133
+ // the fifteen family names this map used to list were phantoms that no file
134
+ // declared. All of it advertised a saving that was not delivered, which is worse
135
+ // than having no profiles at all: a caller who asked for less and silently got
136
+ // nearly everything had no way to find that out.
137
+ //
138
+ // A family this map has never heard of keeps the self-service opt-in documented in
139
+ // ./tools/README.md, so a new family still joins a profile without anyone editing
140
+ // this file, and it always lands in `full` whatever it declares.
141
+ //
142
+ // What each profile is FOR, and what it actually costs (measured, not estimated):
143
+ //
144
+ // core 13 Orientation and the CRM floor: whose workspace this is, what is
145
+ // waiting, the briefing, and the original ten tools. No plays here.
146
+ // A play's task calls tools from every family at once, so a run
147
+ // started on a narrow profile fails halfway through, which is a
148
+ // worse outcome than not offering the play.
149
+ // money The money side, whole. The read-only money tools, plus hr because
150
+ // payroll is money owed to the people who did the work, plus crm
151
+ // so you can open the client you are about to chase, plus knowledge
152
+ // because the published ladder is the only legal source of a number.
153
+ // outreach 51 The cold outreach machine: outreach and sourcing, plus crm to turn
154
+ // a name into a record, knowledge to ground the words, and calendar
155
+ // because you never offer a date you have not checked. This profile
156
+ // saves the least of the three, and that is honest rather than
157
+ // disappointing: the outreach job genuinely reaches most of the
158
+ // workspace. `core` is the profile that buys real context back.
159
+ // content The being-found side: SEO and the newsletter, plus crm and
160
+ // knowledge, because content that does not know who buys or what
161
+ // the business sounds like is generic content. This profile only
162
+ // became real when the marketing family shipped (workstream B1);
163
+ // before that the name existed in the installer and silently fell
164
+ // back to `full`, which was a small lie told to anyone who used it.
165
+ // full Everything, including the plays. The default.
166
+ // coach Fully Booked Coach operations, client delivery, acquisition,
167
+ // content requests, and approval previews. It exposes no send
168
+ // or publish tool.
169
+ //
170
+ // An unknown profile name still gets `full` rather than an error, exactly as before.
171
+
172
+ export const DEFAULT_PROFILE = 'full';
173
+
174
+ export const PROFILES = {
175
+ core: ['core', 'meta', 'briefing'],
176
+ money: ['core', 'meta', 'briefing', 'crm', 'knowledge', 'money', 'hr'],
177
+ outreach: ['core', 'meta', 'briefing', 'crm', 'calendar', 'knowledge', 'outreach', 'sourcing', 'marketing'],
178
+ content: ['core', 'meta', 'briefing', 'crm', 'knowledge', 'marketing'],
179
+ full: '*',
180
+ coach: ['coach'],
181
+ };
182
+
183
+ /**
184
+ * The families this map speaks for. Everything named in a narrow profile above was
185
+ * placed by hand, so its own PROFILES export stops deciding where it lands. `plays`
186
+ * has to be named here on its own, because it is placed too and placed nowhere but
187
+ * `full`: without this line its own ['*'] would put it back into all three.
188
+ */
189
+ // Coach is a public pilot surface with an explicit safety review. New families
190
+ // must not silently join it through a wildcard declaration.
191
+ const CLOSED_PROFILES = new Set(['coach']);
192
+
193
+ const PLACED = new Set([...Object.values(PROFILES).filter(Array.isArray).flat(), 'plays']);
194
+
195
+ export const PROFILE_NAMES = Object.keys(PROFILES);
196
+
197
+ /** Normalise a caller-supplied profile. Unknown or empty falls back to `full`, never throws. */
198
+ export function resolveProfile(raw) {
199
+ const p = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
200
+ return Object.prototype.hasOwnProperty.call(PROFILES, p) ? p : DEFAULT_PROFILE;
201
+ }
202
+
203
+ // ---- family discovery ------------------------------------------------------
204
+
205
+ const TOOLS_DIR = new URL('./tools/', import.meta.url);
206
+
207
+ function warn(msg) {
208
+ console.error(`kmhub-mcp: ${msg}`);
209
+ }
210
+
211
+ async function discoverFamilies() {
212
+ let files;
213
+ try {
214
+ files = readdirSync(TOOLS_DIR)
215
+ .filter((f) => f.endsWith('.mjs'))
216
+ .sort();
217
+ } catch (e) {
218
+ warn(`could not read the tools/ directory: ${String(e?.message || e)}. No tools are available.`);
219
+ return [];
220
+ }
221
+
222
+ const loaded = [];
223
+ const seen = new Set();
224
+ for (const file of files) {
225
+ let mod;
226
+ try {
227
+ mod = await import(new URL(file, TOOLS_DIR).href);
228
+ } catch (e) {
229
+ warn(`skipping tools/${file}, it failed to load: ${String(e?.message || e)}`);
230
+ continue;
231
+ }
232
+ const family = typeof mod?.FAMILY === 'string' ? mod.FAMILY.trim() : '';
233
+ if (!family) {
234
+ warn(`skipping tools/${file}, it does not export a FAMILY name.`);
235
+ continue;
236
+ }
237
+ if (typeof mod.register !== 'function') {
238
+ warn(`skipping tools/${file}, family '${family}' does not export a register() function.`);
239
+ continue;
240
+ }
241
+ if (seen.has(family)) {
242
+ warn(`skipping tools/${file}, family '${family}' is already declared by another file.`);
243
+ continue;
244
+ }
245
+ seen.add(family);
246
+ loaded.push({
247
+ FAMILY: family,
248
+ TOOLS: Array.isArray(mod.TOOLS) ? mod.TOOLS.filter((t) => typeof t === 'string' && t) : [],
249
+ PROFILES: Array.isArray(mod.PROFILES) ? mod.PROFILES.filter((p) => typeof p === 'string' && p) : [],
250
+ register: mod.register,
251
+ file,
252
+ });
253
+ }
254
+
255
+ // Deterministic order so tools/list is stable across processes and restarts:
256
+ // core first (it is the original ten, and clients expect to see them first),
257
+ // then alphabetical.
258
+ loaded.sort((a, b) => {
259
+ if (a.FAMILY === 'core') return -1;
260
+ if (b.FAMILY === 'core') return 1;
261
+ return a.FAMILY.localeCompare(b.FAMILY);
262
+ });
263
+ return loaded;
264
+ }
265
+
266
+ const FAMILIES = await discoverFamilies();
267
+
268
+ export const FAMILY_NAMES = FAMILIES.map((f) => f.FAMILY);
269
+
270
+ /**
271
+ * Is this family part of this profile?
272
+ *
273
+ * `full` takes everything. Otherwise the map decides for every family it places,
274
+ * and only a family the map has never heard of gets to opt itself in.
275
+ */
276
+ function inProfile(family, profile) {
277
+ if (profile === DEFAULT_PROFILE) return true;
278
+ const listed = PROFILES[profile];
279
+ if (listed === '*') return true;
280
+ if (Array.isArray(listed) && listed.includes(family.FAMILY)) return true;
281
+ if (CLOSED_PROFILES.has(profile)) return false;
282
+ if (PLACED.has(family.FAMILY)) return false;
283
+ return family.PROFILES.includes('*') || family.PROFILES.includes(profile);
284
+ }
285
+
286
+ /** The family records a profile loads, in registration order. */
287
+ export function familiesFor(profile) {
288
+ const p = resolveProfile(profile);
289
+ return FAMILIES.filter((f) => inProfile(f, p));
290
+ }
291
+
292
+ /** Declared tool names for a profile (deduped, in registration order). */
293
+ export function toolNamesFor(profile) {
294
+ const names = [];
295
+ for (const f of familiesFor(profile)) {
296
+ for (const t of f.TOOLS) if (!names.includes(t)) names.push(t);
297
+ }
298
+ return names;
299
+ }
300
+
301
+ /** Every declared tool name across every family. Kept for the health payload and startup logs. */
302
+ export const TOOL_NAMES = toolNamesFor(DEFAULT_PROFILE);
303
+
304
+ // ---- registration ----------------------------------------------------------
305
+
306
+ /**
307
+ * Hand a family a registrar rather than the raw McpServer, so one family can never
308
+ * claim a tool name another family already took. A duplicate name would otherwise
309
+ * make the SDK throw mid-request and cost the caller every tool, not just the
310
+ * clashing one. `raw` is there for the rare family that needs the real server.
311
+ */
312
+ /**
313
+ * The tools announced as read-only, from read-only-tools.json (generated from the tool
314
+ * source by scripts/generate-read-only-tools.mjs - see there for the rule).
315
+ *
316
+ * 🚨 CODEX STOPS EVERY UNANNOTATED TOOL FOR AN APPROVAL. With no hint on any KM Hub tool,
317
+ * a Codex session asked permission before `km_me`, and `codex exec` failed every call with
318
+ * "MCP tool call requires approval". readOnlyHint: true is what lets a read run. Writes stay
319
+ * unannotated on purpose, so the person still approves each one.
320
+ *
321
+ * A missing or broken file costs the hints, never the tools.
322
+ */
323
+ const READ_ONLY_TOOLS = (() => {
324
+ try {
325
+ const doc = JSON.parse(readFileSync(new URL('./read-only-tools.json', import.meta.url), 'utf8'));
326
+ return new Set(Array.isArray(doc.tools) ? doc.tools : []);
327
+ } catch (e) {
328
+ warn(`read-only-tools.json could not be read (${String(e?.message || e)}). Every tool will be announced without a read-only hint.`);
329
+ return new Set();
330
+ }
331
+ })();
332
+
333
+ export function isReadOnlyTool(name) {
334
+ return READ_ONLY_TOOLS.has(name);
335
+ }
336
+
337
+ function markReadOnly(name, registeredTool) {
338
+ if (!registeredTool || !READ_ONLY_TOOLS.has(name) || typeof registeredTool.update !== 'function') return registeredTool;
339
+ try {
340
+ registeredTool.update({ annotations: { ...(registeredTool.annotations || {}), readOnlyHint: true } });
341
+ } catch (e) {
342
+ warn(`could not mark '${name}' read-only (${String(e?.message || e)}). It still works; hosts will ask before running it.`);
343
+ }
344
+ return registeredTool;
345
+ }
346
+
347
+ function guardedRegistrar(server, taken, family, registered) {
348
+ const claim = (name) => {
349
+ if (typeof name !== 'string' || !name.trim()) {
350
+ warn(`family '${family}' tried to register a tool with no name. Ignored.`);
351
+ return false;
352
+ }
353
+ const owner = taken.get(name);
354
+ if (owner) {
355
+ warn(`tool name collision on '${name}': family '${owner}' already registered it, so family '${family}' does not get it.`);
356
+ return false;
357
+ }
358
+ taken.set(name, family);
359
+ registered.push(name);
360
+ return true;
361
+ };
362
+ return {
363
+ tool: (name, ...rest) => (claim(name) ? markReadOnly(name, server.tool(name, ...rest)) : undefined),
364
+ registerTool: (name, ...rest) => (claim(name) ? markReadOnly(name, server.registerTool(name, ...rest)) : undefined),
365
+ prompt: (...args) => server.prompt(...args),
366
+ resource: (...args) => server.resource(...args),
367
+ raw: server,
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Register the KM Hub tools for one profile on an McpServer.
373
+ *
374
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
375
+ * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
376
+ * already bound to a single org's API token
377
+ * @param {{ profile?: string }} [opts] profile name; unknown or absent means `full`
378
+ * @returns {{ profile: string, families: string[], tools: string[] }} what actually registered
379
+ */
380
+ export function registerTools(server, call, opts = {}) {
381
+ const profile = resolveProfile(opts.profile);
382
+ const families = familiesFor(profile);
383
+ const taken = new Map();
384
+ const registered = [];
385
+
386
+ for (const family of families) {
387
+ const helpers = {
388
+ out,
389
+ text,
390
+ qs,
391
+ z,
392
+ profile,
393
+ family: family.FAMILY,
394
+ SERVER_NAME,
395
+ SERVER_VERSION,
396
+ DEFAULT_BASE,
397
+ };
398
+ try {
399
+ family.register(guardedRegistrar(server, taken, family.FAMILY, registered), call, helpers);
400
+ } catch (e) {
401
+ // One bad family must not cost the caller the other nine.
402
+ warn(`family '${family.FAMILY}' failed to register: ${String(e?.message || e)}. Its tools are unavailable; the rest still work.`);
403
+ }
404
+ }
405
+
406
+ return { profile, families: families.map((f) => f.FAMILY), tools: registered };
407
+ }