@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.
- package/README.md +170 -170
- package/bin/kmhub.mjs +896 -896
- package/coach-book-output-guard.mjs +760 -760
- package/index.mjs +57 -57
- package/package.json +56 -56
- package/prompts/briefing.md +29 -29
- package/prompts/luxury.md +70 -70
- package/prompts/play.md +49 -49
- package/prompts/run.md +36 -36
- package/prompts/setup.md +33 -33
- package/prompts/vs-booked.md +46 -46
- package/prompts/what-can-you-do.md +40 -40
- package/prompts.mjs +110 -109
- package/read-only-tools.json +143 -142
- package/remote.mjs +929 -929
- package/tools/balloon-costing.mjs +80 -80
- package/tools/booking-equipment.mjs +110 -110
- package/tools/bridges.mjs +54 -54
- package/tools/briefing.mjs +91 -91
- package/tools/calendar.mjs +170 -170
- package/tools/capabilities.mjs +155 -155
- package/tools/catalog.mjs +288 -288
- package/tools/clubs.mjs +176 -176
- package/tools/coach.mjs +771 -771
- package/tools/compare.mjs +76 -76
- package/tools/core.mjs +244 -244
- package/tools/crm.mjs +209 -209
- package/tools/dubsado.mjs +137 -137
- package/tools/exports.mjs +128 -128
- package/tools/fact-review.mjs +125 -125
- package/tools/flows.mjs +261 -261
- package/tools/forms.mjs +158 -158
- package/tools/gols.mjs +134 -134
- package/tools/hr.mjs +162 -162
- package/tools/knowledge.mjs +125 -125
- package/tools/marketing.mjs +396 -396
- package/tools/meta.mjs +245 -245
- package/tools/military.mjs +244 -244
- package/tools/money.mjs +235 -197
- package/tools/outreach.mjs +238 -238
- package/tools/pending.mjs +122 -122
- package/tools/photos.mjs +140 -140
- package/tools/plays.mjs +244 -244
- package/tools/profile.mjs +118 -118
- package/tools/radar.mjs +173 -173
- package/tools/recurring-invoices.mjs +149 -149
- package/tools/reengage.mjs +434 -434
- package/tools/schedules.mjs +55 -55
- package/tools/setup.mjs +168 -168
- package/tools/sops-bridges.mjs +86 -86
- package/tools/sops.mjs +314 -314
- package/tools/sourcing.mjs +268 -268
- package/tools/strategy.mjs +146 -146
- package/tools/studio.mjs +132 -132
- package/tools/venueradar.mjs +151 -151
- package/tools/voice.mjs +134 -134
- 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.
|
|
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
|
+
}
|