@kivimedia/kmhub 2.0.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.
package/tools.mjs ADDED
@@ -0,0 +1,349 @@
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 } from 'node:fs';
30
+ import { z } from 'zod';
31
+
32
+ export const SERVER_NAME = 'kmhub';
33
+ export const SERVER_VERSION = '2.0.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
+ return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }], isError: !r.ok };
93
+ }
94
+
95
+ /** A plain prose answer. Defaults to a success result: not every "no" is an error. */
96
+ export function text(message, isError = false) {
97
+ return { content: [{ type: 'text', text: String(message) }], isError };
98
+ }
99
+
100
+ /** Build a query string from an object, dropping empty values. Returns '' or '?a=b'. */
101
+ export function qs(params) {
102
+ const q = new URLSearchParams();
103
+ for (const [k, v] of Object.entries(params || {})) {
104
+ if (v === undefined || v === null || v === '') continue;
105
+ q.set(k, String(v));
106
+ }
107
+ const s = q.toString();
108
+ return s ? `?${s}` : '';
109
+ }
110
+
111
+ // ---- profiles --------------------------------------------------------------
112
+ //
113
+ // A profile is a named, SMALLER tool set. This map is the authority for it, and
114
+ // there are two rules. The second one is the one that used to be missing:
115
+ //
116
+ // 1. A profile lists the families it loads. `full` is '*' and loads everything.
117
+ // 2. For a family this map PLACES (see PLACED below), the map is the whole
118
+ // answer. Leaving a family out of a profile is a decision rather than an
119
+ // oversight, and that family's own PROFILES export no longer overrides it.
120
+ //
121
+ // Rule 2 exists because without it the split was theatre. Six of the ten families
122
+ // declared PROFILES ['*'] and pinned themselves into every profile, so `core` was
123
+ // 31 of the 63 tools, `outreach` was 54, and a `content` profile returned 37 tools
124
+ // and not one content tool, because no content family has ever existed. Eleven of
125
+ // the fifteen family names this map used to list were phantoms that no file
126
+ // declared. All of it advertised a saving that was not delivered, which is worse
127
+ // than having no profiles at all: a caller who asked for less and silently got
128
+ // nearly everything had no way to find that out.
129
+ //
130
+ // A family this map has never heard of keeps the self-service opt-in documented in
131
+ // ./tools/README.md, so a new family still joins a profile without anyone editing
132
+ // this file, and it always lands in `full` whatever it declares.
133
+ //
134
+ // What each profile is FOR, and what it actually costs (measured, not estimated):
135
+ //
136
+ // core 13 Orientation and the CRM floor: whose workspace this is, what is
137
+ // waiting, the briefing, and the original ten tools. No plays here.
138
+ // A play's task calls tools from every family at once, so a run
139
+ // started on a narrow profile fails halfway through, which is a
140
+ // worse outcome than not offering the play.
141
+ // money 38 The money side, whole. The nine read-only money tools, plus crm
142
+ // so you can open the client you are about to chase, plus knowledge
143
+ // because the published ladder is the only legal source of a number.
144
+ // outreach 51 The cold outreach machine: outreach and sourcing, plus crm to turn
145
+ // a name into a record, knowledge to ground the words, and calendar
146
+ // because you never offer a date you have not checked. This profile
147
+ // saves the least of the three, and that is honest rather than
148
+ // disappointing: the outreach job genuinely reaches most of the
149
+ // workspace. `core` is the profile that buys real context back.
150
+ // full 63 Everything, including the plays. The default.
151
+ //
152
+ // There is deliberately no `content` profile. There are no content tools. An older
153
+ // client that still asks for one gets `full` rather than an error, exactly as any
154
+ // other unknown name does.
155
+
156
+ export const DEFAULT_PROFILE = 'full';
157
+
158
+ export const PROFILES = {
159
+ core: ['core', 'meta', 'briefing'],
160
+ money: ['core', 'meta', 'briefing', 'crm', 'knowledge', 'money'],
161
+ outreach: ['core', 'meta', 'briefing', 'crm', 'calendar', 'knowledge', 'outreach', 'sourcing'],
162
+ full: '*',
163
+ };
164
+
165
+ /**
166
+ * The families this map speaks for. Everything named in a narrow profile above was
167
+ * placed by hand, so its own PROFILES export stops deciding where it lands. `plays`
168
+ * has to be named here on its own, because it is placed too and placed nowhere but
169
+ * `full`: without this line its own ['*'] would put it back into all three.
170
+ */
171
+ const PLACED = new Set([...Object.values(PROFILES).filter(Array.isArray).flat(), 'plays']);
172
+
173
+ export const PROFILE_NAMES = Object.keys(PROFILES);
174
+
175
+ /** Normalise a caller-supplied profile. Unknown or empty falls back to `full`, never throws. */
176
+ export function resolveProfile(raw) {
177
+ const p = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
178
+ return Object.prototype.hasOwnProperty.call(PROFILES, p) ? p : DEFAULT_PROFILE;
179
+ }
180
+
181
+ // ---- family discovery ------------------------------------------------------
182
+
183
+ const TOOLS_DIR = new URL('./tools/', import.meta.url);
184
+
185
+ function warn(msg) {
186
+ console.error(`kmhub-mcp: ${msg}`);
187
+ }
188
+
189
+ async function discoverFamilies() {
190
+ let files;
191
+ try {
192
+ files = readdirSync(TOOLS_DIR)
193
+ .filter((f) => f.endsWith('.mjs'))
194
+ .sort();
195
+ } catch (e) {
196
+ warn(`could not read the tools/ directory: ${String(e?.message || e)}. No tools are available.`);
197
+ return [];
198
+ }
199
+
200
+ const loaded = [];
201
+ const seen = new Set();
202
+ for (const file of files) {
203
+ let mod;
204
+ try {
205
+ mod = await import(new URL(file, TOOLS_DIR).href);
206
+ } catch (e) {
207
+ warn(`skipping tools/${file}, it failed to load: ${String(e?.message || e)}`);
208
+ continue;
209
+ }
210
+ const family = typeof mod?.FAMILY === 'string' ? mod.FAMILY.trim() : '';
211
+ if (!family) {
212
+ warn(`skipping tools/${file}, it does not export a FAMILY name.`);
213
+ continue;
214
+ }
215
+ if (typeof mod.register !== 'function') {
216
+ warn(`skipping tools/${file}, family '${family}' does not export a register() function.`);
217
+ continue;
218
+ }
219
+ if (seen.has(family)) {
220
+ warn(`skipping tools/${file}, family '${family}' is already declared by another file.`);
221
+ continue;
222
+ }
223
+ seen.add(family);
224
+ loaded.push({
225
+ FAMILY: family,
226
+ TOOLS: Array.isArray(mod.TOOLS) ? mod.TOOLS.filter((t) => typeof t === 'string' && t) : [],
227
+ PROFILES: Array.isArray(mod.PROFILES) ? mod.PROFILES.filter((p) => typeof p === 'string' && p) : [],
228
+ register: mod.register,
229
+ file,
230
+ });
231
+ }
232
+
233
+ // Deterministic order so tools/list is stable across processes and restarts:
234
+ // core first (it is the original ten, and clients expect to see them first),
235
+ // then alphabetical.
236
+ loaded.sort((a, b) => {
237
+ if (a.FAMILY === 'core') return -1;
238
+ if (b.FAMILY === 'core') return 1;
239
+ return a.FAMILY.localeCompare(b.FAMILY);
240
+ });
241
+ return loaded;
242
+ }
243
+
244
+ const FAMILIES = await discoverFamilies();
245
+
246
+ export const FAMILY_NAMES = FAMILIES.map((f) => f.FAMILY);
247
+
248
+ /**
249
+ * Is this family part of this profile?
250
+ *
251
+ * `full` takes everything. Otherwise the map decides for every family it places,
252
+ * and only a family the map has never heard of gets to opt itself in.
253
+ */
254
+ function inProfile(family, profile) {
255
+ if (profile === DEFAULT_PROFILE) return true;
256
+ const listed = PROFILES[profile];
257
+ if (listed === '*') return true;
258
+ if (Array.isArray(listed) && listed.includes(family.FAMILY)) return true;
259
+ if (PLACED.has(family.FAMILY)) return false;
260
+ return family.PROFILES.includes('*') || family.PROFILES.includes(profile);
261
+ }
262
+
263
+ /** The family records a profile loads, in registration order. */
264
+ export function familiesFor(profile) {
265
+ const p = resolveProfile(profile);
266
+ return FAMILIES.filter((f) => inProfile(f, p));
267
+ }
268
+
269
+ /** Declared tool names for a profile (deduped, in registration order). */
270
+ export function toolNamesFor(profile) {
271
+ const names = [];
272
+ for (const f of familiesFor(profile)) {
273
+ for (const t of f.TOOLS) if (!names.includes(t)) names.push(t);
274
+ }
275
+ return names;
276
+ }
277
+
278
+ /** Every declared tool name across every family. Kept for the health payload and startup logs. */
279
+ export const TOOL_NAMES = toolNamesFor(DEFAULT_PROFILE);
280
+
281
+ // ---- registration ----------------------------------------------------------
282
+
283
+ /**
284
+ * Hand a family a registrar rather than the raw McpServer, so one family can never
285
+ * claim a tool name another family already took. A duplicate name would otherwise
286
+ * make the SDK throw mid-request and cost the caller every tool, not just the
287
+ * clashing one. `raw` is there for the rare family that needs the real server.
288
+ */
289
+ function guardedRegistrar(server, taken, family, registered) {
290
+ const claim = (name) => {
291
+ if (typeof name !== 'string' || !name.trim()) {
292
+ warn(`family '${family}' tried to register a tool with no name. Ignored.`);
293
+ return false;
294
+ }
295
+ const owner = taken.get(name);
296
+ if (owner) {
297
+ warn(`tool name collision on '${name}': family '${owner}' already registered it, so family '${family}' does not get it.`);
298
+ return false;
299
+ }
300
+ taken.set(name, family);
301
+ registered.push(name);
302
+ return true;
303
+ };
304
+ return {
305
+ tool: (name, ...rest) => (claim(name) ? server.tool(name, ...rest) : undefined),
306
+ registerTool: (name, ...rest) => (claim(name) ? server.registerTool(name, ...rest) : undefined),
307
+ prompt: (...args) => server.prompt(...args),
308
+ resource: (...args) => server.resource(...args),
309
+ raw: server,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Register the KM Hub tools for one profile on an McpServer.
315
+ *
316
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
317
+ * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
318
+ * already bound to a single org's API token
319
+ * @param {{ profile?: string }} [opts] profile name; unknown or absent means `full`
320
+ * @returns {{ profile: string, families: string[], tools: string[] }} what actually registered
321
+ */
322
+ export function registerTools(server, call, opts = {}) {
323
+ const profile = resolveProfile(opts.profile);
324
+ const families = familiesFor(profile);
325
+ const taken = new Map();
326
+ const registered = [];
327
+
328
+ for (const family of families) {
329
+ const helpers = {
330
+ out,
331
+ text,
332
+ qs,
333
+ z,
334
+ profile,
335
+ family: family.FAMILY,
336
+ SERVER_NAME,
337
+ SERVER_VERSION,
338
+ DEFAULT_BASE,
339
+ };
340
+ try {
341
+ family.register(guardedRegistrar(server, taken, family.FAMILY, registered), call, helpers);
342
+ } catch (e) {
343
+ // One bad family must not cost the caller the other nine.
344
+ warn(`family '${family.FAMILY}' failed to register: ${String(e?.message || e)}. Its tools are unavailable; the rest still work.`);
345
+ }
346
+ }
347
+
348
+ return { profile, families: families.map((f) => f.FAMILY), tools: registered };
349
+ }