@kivimedia/kmhub 2.0.0 → 2.9.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 (55) hide show
  1. package/README.md +6 -5
  2. package/bin/kmhub.mjs +20 -7
  3. package/coach-book-output-guard.mjs +760 -0
  4. package/index.mjs +2 -0
  5. package/package.json +8 -3
  6. package/prompts/briefing.md +29 -0
  7. package/prompts/luxury.md +70 -0
  8. package/prompts/play.md +49 -0
  9. package/prompts/run.md +36 -0
  10. package/prompts/setup.md +33 -0
  11. package/prompts/vs-booked.md +46 -0
  12. package/prompts/what-can-you-do.md +40 -0
  13. package/prompts.mjs +109 -0
  14. package/read-only-tools.json +142 -0
  15. package/remote.mjs +815 -99
  16. package/tools/balloon-costing.mjs +80 -0
  17. package/tools/booking-equipment.mjs +110 -0
  18. package/tools/bridges.mjs +54 -0
  19. package/tools/calendar.mjs +9 -0
  20. package/tools/capabilities.mjs +155 -0
  21. package/tools/catalog.mjs +288 -0
  22. package/tools/clubs.mjs +176 -0
  23. package/tools/coach.mjs +771 -0
  24. package/tools/compare.mjs +76 -0
  25. package/tools/core.mjs +21 -0
  26. package/tools/crm.mjs +12 -3
  27. package/tools/dubsado.mjs +137 -0
  28. package/tools/exports.mjs +128 -0
  29. package/tools/fact-review.mjs +125 -0
  30. package/tools/flows.mjs +261 -0
  31. package/tools/forms.mjs +158 -0
  32. package/tools/gols.mjs +134 -0
  33. package/tools/hr.mjs +162 -0
  34. package/tools/knowledge.mjs +4 -3
  35. package/tools/marketing.mjs +396 -0
  36. package/tools/meta.mjs +2 -2
  37. package/tools/military.mjs +244 -0
  38. package/tools/outreach.mjs +27 -4
  39. package/tools/pending.mjs +122 -0
  40. package/tools/photos.mjs +140 -0
  41. package/tools/plays.mjs +1 -1
  42. package/tools/profile.mjs +118 -0
  43. package/tools/radar.mjs +173 -0
  44. package/tools/recurring-invoices.mjs +149 -0
  45. package/tools/reengage.mjs +434 -0
  46. package/tools/schedules.mjs +55 -0
  47. package/tools/setup.mjs +168 -0
  48. package/tools/sops-bridges.mjs +86 -0
  49. package/tools/sops.mjs +314 -0
  50. package/tools/sourcing.mjs +50 -2
  51. package/tools/strategy.mjs +146 -0
  52. package/tools/studio.mjs +132 -0
  53. package/tools/venueradar.mjs +151 -0
  54. package/tools/voice.mjs +134 -0
  55. package/tools.mjs +70 -12
package/remote.mjs CHANGED
@@ -1,45 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * kmhub-mcp REMOTE (P5) - a Streamable-HTTP MCP server for KM Hub power users.
3
+ * Remote Streamable HTTP MCP server for KMHub.
4
4
  *
5
- * ONE process serves MANY orgs. There is no server-wide API key. Instead every
6
- * request authenticates itself with a KM Hub org API key sent as a Bearer token:
7
- *
8
- * Authorization: Bearer kmh_live_xxxxxxxx
9
- *
10
- * That token is bound to a fresh, per-request McpServer whose tools call kmhub-api
11
- * with exactly that token. The kmhub-api edge function then does the authoritative
12
- * org scoping + scope check (read vs write), so one caller can never reach another
13
- * tenant's rows. This is the multi-tenant twin of the local stdio server (index.mjs);
14
- * both share the SAME tool definitions from tools.mjs.
15
- *
16
- * Transport: MCP Streamable HTTP, STATELESS mode (sessionIdGenerator: undefined) -
17
- * each POST /mcp is a self-contained request/response, so there is no session state
18
- * to leak across tenants. Deployed on the VPS as pm2 process `kmhub-mcp`; see
19
- * DEPLOY-VPS.md.
20
- *
21
- * Endpoints:
22
- * GET /health -> { ok: true, ... } (no auth; for pm2 / nginx / uptime checks)
23
- * POST /mcp -> MCP Streamable HTTP (Bearer token required)
24
- * GET /mcp -> 405 (no server-initiated SSE stream in stateless mode)
25
- *
26
- * Tool profiles: a caller can load a subset of the tools instead of all of them, which
27
- * costs the model far fewer tool schemas per turn. Two ways to ask, both per-request so
28
- * the server stays stateless and two tenants can hold different profiles at the same time:
29
- * POST /mcp?profile=money (query string, wins if both are present)
30
- * X-KMHub-Profile: money (header)
31
- * Known profiles: core, outreach, money, content, full. Anything else falls back to full
32
- * rather than erroring, because a typo should never cost someone their tools.
33
- *
34
- * Env:
35
- * PORT listen port (default 8830)
36
- * HOST bind address (default 0.0.0.0)
37
- * KMHUB_API_BASE override the kmhub-api base URL (default = the shared one)
38
- * MCP_RATE_LIMIT max POST /mcp requests per token per minute (default 120)
5
+ * Generic API keys keep their existing behavior. Fully Booked Coach OAuth uses
6
+ * an opaque access token only at this resource server. The token is introspected
7
+ * with the dedicated broker, then replaced with a 15-second internal assertion
8
+ * before any request reaches kmhub-api.
39
9
  */
40
10
  import http from 'node:http';
11
+ import { createHash, createHmac, randomUUID } from 'node:crypto';
12
+ import { isIP } from 'node:net';
41
13
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
42
14
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
15
+ import { registerPrompts } from './prompts.mjs';
43
16
  import {
44
17
  DEFAULT_BASE,
45
18
  DEFAULT_PROFILE,
@@ -56,75 +29,534 @@ import {
56
29
 
57
30
  const PORT = Number(process.env.PORT || 8830);
58
31
  const HOST = process.env.HOST || '0.0.0.0';
59
- const BASE = DEFAULT_BASE;
60
- const RATE_LIMIT = Number(process.env.MCP_RATE_LIMIT || 120); // requests / token / minute
32
+ const EXPECTED_API_BASE = 'https://jpwbosrmkibsdgckowsm.supabase.co/functions/v1/kmhub-api';
33
+ const EXPECTED_BROKER_BASE = 'https://jpwbosrmkibsdgckowsm.supabase.co/functions/v1/coach-oauth-broker';
34
+ const EXPECTED_CONSENT_URL = 'https://hub.kivimedia.co/oauth/consent';
35
+ const BASE = process.env.KMHUB_API_BASE || DEFAULT_BASE;
36
+ const EXPECTED_PUBLIC_ORIGIN = 'https://mcp.kivimedia.co';
37
+ const PUBLIC_ORIGIN = (
38
+ process.env.MCP_PUBLIC_ORIGIN || EXPECTED_PUBLIC_ORIGIN
39
+ ).replace(/\/+$/, '');
40
+ const RESOURCE_URL = `${PUBLIC_ORIGIN}/mcp`;
41
+ const RESOURCE_METADATA_URL = `${PUBLIC_ORIGIN}/.well-known/oauth-protected-resource/mcp`;
42
+ const CONSENT_URL = process.env.KMHUB_COACH_CONSENT_URL || EXPECTED_CONSENT_URL;
43
+ const BROKER_BASE = (
44
+ process.env.COACH_OAUTH_BROKER_URL
45
+ || EXPECTED_BROKER_BASE
46
+ ).replace(/\/+$/, '');
47
+ const BROKER_SECRET = process.env.COACH_OAUTH_BROKER_SECRET || '';
48
+ const INTERNAL_SECRET = process.env.COACH_MCP_INTERNAL_SECRET || '';
49
+ const COACH_SCOPE = 'coach:operate';
50
+ const WORKSPACE_SCOPE = 'workspace:operate';
51
+
52
+ /*
53
+ * The scope-to-profile pairing. 🚨 The fourth copy of something the database owns
54
+ * (kmhub_oauth_profile_for_scope, migration 667), mirrored by the broker and by
55
+ * kmhub-api. All four must agree: this end decides what to ask for, the far end decides
56
+ * what to accept, and a disagreement means a connector that authorizes cleanly and then
57
+ * cannot call anything.
58
+ */
59
+ const PROFILE_BY_SCOPE = new Map([
60
+ [COACH_SCOPE, 'coach'],
61
+ [WORKSPACE_SCOPE, 'workspace'],
62
+ ]);
63
+ const profileForScope = (scope) => PROFILE_BY_SCOPE.get(scope) || null;
64
+
65
+ /*
66
+ * The internal assertion's audience names the profile, so kmhub-api can refuse a coach
67
+ * assertion presented as a workspace one. Both ends build this string the same way.
68
+ */
69
+ const internalAudienceFor = (profile) => `${BASE.replace(/\/+$/, '')}/${profile}`;
70
+
71
+ /*
72
+ * What a fresh connection asks for when the client does not name a scope. Workspace,
73
+ * because that is what 56 of the 58 workspaces need; a coach client names its own scope
74
+ * and is unaffected. Safe to change because no OAuth connection has ever been completed.
75
+ */
76
+ const DEFAULT_SCOPE = WORKSPACE_SCOPE;
77
+
78
+ const COACH_ASSERTION_PREFIX = 'kmh_coach_int_';
79
+ const ACCESS_TOKEN_PATTERN = /^kmh_(?:coach|ws)_at_[0-9a-f]{64}$/;
80
+ const API_KEY_PATTERN = /^kmh_live_[a-f0-9]+$/i;
81
+ // Two OAuth client families since migration 763: Claude and ChatGPT (developer-mode
82
+ // connectors). The redirect is the security anchor - a code can only ever go back to a
83
+ // claude.ai / claude.com / chatgpt.com callback. Mirrors kmhub_oauth_redirect_family in the
84
+ // database and oauthRedirectFamily in supabase/functions/_shared/coach-oauth.ts.
85
+ const CLIENT_ID_PATTERN = /^kmh_(?:claude|chatgpt)_[0-9a-f]{36}$/;
86
+ const OFFICIAL_REDIRECTS = new Set([
87
+ 'https://claude.ai/api/mcp/auth_callback',
88
+ 'https://claude.com/api/mcp/auth_callback',
89
+ // ChatGPT's one stable callback. Its per-connector form is refused (migration 764).
90
+ 'https://chatgpt.com/connector_platform_oauth_redirect',
91
+ ]);
92
+
93
+ function isApprovedRedirect(uri) {
94
+ return typeof uri === 'string' && OFFICIAL_REDIRECTS.has(uri);
95
+ }
96
+
97
+ /**
98
+ * One known scope out of whatever the client asked for. Claude asks for exactly one;
99
+ * ChatGPT may ask for every scope the metadata lists ("workspace:operate coach:operate"),
100
+ * or add OIDC ones. Workspace wins when present (it is what 56 of 58 workspaces need),
101
+ * then coach, and an empty or unknown-only request falls back to the default. The broker
102
+ * and the database still check the result against the client's own scope list.
103
+ */
104
+ function normalizeRequestedScope(raw) {
105
+ const asked = typeof raw === 'string' ? raw.split(/\s+/).filter(Boolean) : [];
106
+ if (asked.includes(WORKSPACE_SCOPE)) return WORKSPACE_SCOPE;
107
+ if (asked.includes(COACH_SCOPE)) return COACH_SCOPE;
108
+ return DEFAULT_SCOPE;
109
+ }
110
+
111
+ /** The resource indicator, tolerating one trailing slash on the URL a person typed. */
112
+ function canonicalResource(raw) {
113
+ if (raw === RESOURCE_URL || raw === RESOURCE_URL + '/') return RESOURCE_URL;
114
+ return raw;
115
+ }
116
+ const RATE_LIMIT = Number(process.env.MCP_RATE_LIMIT || 120);
61
117
  const WINDOW_MS = 60_000;
118
+ const MAX_OAUTH_BODY_BYTES = 32 * 1024;
119
+ const REQUESTED_MAX_RATE_BUCKETS = Number(process.env.MCP_MAX_RATE_BUCKETS || 5000);
120
+ const RATE_BUCKET_CAP_VALID = (
121
+ Number.isInteger(REQUESTED_MAX_RATE_BUCKETS)
122
+ && REQUESTED_MAX_RATE_BUCKETS >= 100
123
+ && REQUESTED_MAX_RATE_BUCKETS <= 100000
124
+ );
125
+ const MAX_RATE_BUCKETS = RATE_BUCKET_CAP_VALID ? REQUESTED_MAX_RATE_BUCKETS : 5000;
126
+ const TRUSTED_PROXY_SOURCES = new Set(
127
+ (process.env.MCP_TRUSTED_PROXY_SOURCES || '127.0.0.1,::1')
128
+ .split(',')
129
+ .map((value) => value.trim())
130
+ .filter(Boolean),
131
+ );
132
+ const TRUSTED_PROXY_HEADER = (
133
+ process.env.MCP_TRUSTED_PROXY_HEADER || 'x-forwarded-for'
134
+ ).toLowerCase();
135
+ const OAUTH_ROUTE_LIMITS = {
136
+ register: 10,
137
+ authorize: 60,
138
+ token: 60,
139
+ revoke: 60,
140
+ introspect: 240,
141
+ };
142
+ // All ChatGPT users reach this server from OpenAI's egress addresses, so a per-source cap on
143
+ // token checks would make every ChatGPT workspace share one small bucket. The per-TOKEN cap
144
+ // stays at OAUTH_ROUTE_LIMITS.introspect; the per-source ceiling for this route is wider.
145
+ //
146
+ // 🚨 Wider, not unbounded. 6000 sat above MAX_RATE_BUCKETS, and the per-token buckets lived
147
+ // in the shared map, so one address sending fresh junk tokens evicted its own source bucket
148
+ // (it never reached the cap) and every other route's buckets with it: 20000 of 20000 junk
149
+ // checks reached the broker in a replay (review of 5cf8b4ad). The ceiling now stays well
150
+ // under the cap, and per-token buckets live in their own map where churn evicts only them.
151
+ const OAUTH_SOURCE_LIMITS = {
152
+ introspect: 1200,
153
+ };
154
+ const TRUSTED_PROXY_CONFIG_VALID = (
155
+ TRUSTED_PROXY_SOURCES.size > 0
156
+ && Array.from(TRUSTED_PROXY_SOURCES).every((value) => isIP(value) !== 0)
157
+ && /^[a-z0-9-]{1,64}$/.test(TRUSTED_PROXY_HEADER)
158
+ );
159
+ const OAUTH_CONFIGURED = (
160
+ PUBLIC_ORIGIN === EXPECTED_PUBLIC_ORIGIN
161
+ && CONSENT_URL === EXPECTED_CONSENT_URL
162
+ && BROKER_BASE === EXPECTED_BROKER_BASE
163
+ && BASE === EXPECTED_API_BASE
164
+ && BROKER_SECRET.length >= 32
165
+ && INTERNAL_SECRET.length >= 32
166
+ && BROKER_SECRET !== INTERNAL_SECRET
167
+ && RATE_BUCKET_CAP_VALID
168
+ && TRUSTED_PROXY_CONFIG_VALID
169
+ );
170
+ const REQUIRE_PRODUCTION_OAUTH = (
171
+ process.env.NODE_ENV === 'production'
172
+ || process.env.MCP_REQUIRE_OAUTH_CONFIG === 'true'
173
+ );
174
+ if (REQUIRE_PRODUCTION_OAUTH && !OAUTH_CONFIGURED) {
175
+ console.error(
176
+ 'kmhub-mcp remote: production OAuth configuration is invalid or incomplete',
177
+ );
178
+ process.exit(1);
179
+ }
62
180
 
63
- // ---- light in-memory per-token rate limiter -------------------------------
64
- // First line of defense at the MCP layer. kmhub-api still enforces the
65
- // authoritative per-key limit (60 reads / 20 writes per minute) downstream.
66
- const buckets = new Map(); // token -> { count, resetAt }
181
+ const buckets = new Map();
182
+ const oauthBuckets = new Map();
183
+ // Per-token introspect buckets only. Kept apart from oauthBuckets so a flood of fresh tokens
184
+ // can evict nothing but its own kind (see OAUTH_SOURCE_LIMITS).
185
+ const introspectTokenBuckets = new Map();
67
186
 
68
- function rateLimited(token) {
187
+ function tokenFingerprint(token) {
188
+ return createHash('sha256').update(token, 'utf8').digest('hex');
189
+ }
190
+
191
+ function normalizeIp(value) {
192
+ const trimmed = String(value || '').trim();
193
+ if (trimmed.startsWith('::ffff:') && isIP(trimmed.slice(7)) === 4) {
194
+ return trimmed.slice(7);
195
+ }
196
+ return trimmed;
197
+ }
198
+
199
+ function requestSourceIp(req) {
200
+ const socketIp = normalizeIp(req.socket.remoteAddress);
201
+ if (!TRUSTED_PROXY_SOURCES.has(socketIp)) return socketIp || 'unknown';
202
+ const headerValue = req.headers[TRUSTED_PROXY_HEADER];
203
+ const forwarded = (Array.isArray(headerValue) ? headerValue.join(',') : headerValue || '')
204
+ .split(',')
205
+ .map((value) => normalizeIp(value));
206
+ for (let index = forwarded.length - 1; index >= 0; index -= 1) {
207
+ if (isIP(forwarded[index])) return forwarded[index];
208
+ }
209
+ return socketIp || 'unknown';
210
+ }
211
+
212
+ function evictExpiredAndBound(rateMap, now) {
213
+ for (const [key, bucket] of rateMap) {
214
+ if (now >= bucket.resetAt) rateMap.delete(key);
215
+ }
216
+ while (rateMap.size >= MAX_RATE_BUCKETS) {
217
+ const oldest = rateMap.keys().next().value;
218
+ if (oldest === undefined) break;
219
+ rateMap.delete(oldest);
220
+ }
221
+ }
222
+
223
+ function hitWindow(rateMap, key, limit) {
69
224
  const now = Date.now();
70
- let b = buckets.get(token);
71
- if (!b || now >= b.resetAt) {
72
- b = { count: 0, resetAt: now + WINDOW_MS };
73
- buckets.set(token, b);
225
+ let bucket = rateMap.get(key);
226
+ if (!bucket || now >= bucket.resetAt) {
227
+ evictExpiredAndBound(rateMap, now);
228
+ bucket = { count: 0, resetAt: now + WINDOW_MS };
229
+ rateMap.set(key, bucket);
74
230
  }
75
- b.count += 1;
76
- if (b.count > RATE_LIMIT) {
77
- return Math.max(1, Math.ceil((b.resetAt - now) / 1000)); // retry-after seconds
231
+ bucket.count += 1;
232
+ if (bucket.count > limit) {
233
+ return Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
78
234
  }
79
235
  return 0;
80
236
  }
81
237
 
82
- // Periodically evict stale buckets so memory does not grow unbounded.
238
+ function rateLimited(token) {
239
+ return hitWindow(buckets, tokenFingerprint(token), RATE_LIMIT);
240
+ }
241
+
242
+ function oauthAbuseLimited(req, route, clientFingerprint = 'anonymous') {
243
+ const source = requestSourceIp(req);
244
+ const limit = OAUTH_ROUTE_LIMITS[route] || OAUTH_ROUTE_LIMITS.token;
245
+ const sourceLimit = OAUTH_SOURCE_LIMITS[route] || limit;
246
+ const sourceKey = tokenFingerprint(route + '\n' + source);
247
+ const sourceRetryAfter = hitWindow(oauthBuckets, sourceKey, sourceLimit);
248
+ if (sourceRetryAfter) return sourceRetryAfter;
249
+
250
+ const fingerprintKey = tokenFingerprint(
251
+ route + '\n' + source + '\n' + clientFingerprint,
252
+ );
253
+ const fingerprintMap = route === 'introspect' ? introspectTokenBuckets : oauthBuckets;
254
+ return hitWindow(fingerprintMap, fingerprintKey, limit);
255
+ }
256
+
83
257
  setInterval(() => {
84
258
  const now = Date.now();
85
- for (const [token, b] of buckets) if (now >= b.resetAt) buckets.delete(token);
259
+ evictExpiredAndBound(buckets, now);
260
+ evictExpiredAndBound(oauthBuckets, now);
261
+ evictExpiredAndBound(introspectTokenBuckets, now);
86
262
  }, WINDOW_MS).unref();
87
263
 
88
- // ---- helpers --------------------------------------------------------------
89
- function sendJson(res, status, obj) {
90
- const body = JSON.stringify(obj);
91
- res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
264
+ function sendJson(res, status, value, extraHeaders = {}) {
265
+ const body = JSON.stringify(value);
266
+ res.writeHead(status, {
267
+ 'content-type': 'application/json',
268
+ 'content-length': Buffer.byteLength(body),
269
+ 'x-content-type-options': 'nosniff',
270
+ ...extraHeaders,
271
+ });
92
272
  res.end(body);
93
273
  }
94
274
 
95
- // A well-formed JSON-RPC error response (id null: the request never reached a handler).
275
+ function sendOAuthJson(res, status, value, extraHeaders = {}) {
276
+ sendJson(res, status, value, {
277
+ 'cache-control': 'no-store',
278
+ pragma: 'no-cache',
279
+ ...extraHeaders,
280
+ });
281
+ }
282
+
283
+ function sendOAuthRateLimit(res, retryAfter) {
284
+ return sendOAuthJson(
285
+ res,
286
+ 429,
287
+ {
288
+ error: 'temporarily_unavailable',
289
+ error_description: 'Too many OAuth requests. Retry shortly.',
290
+ },
291
+ { 'retry-after': String(retryAfter) },
292
+ );
293
+ }
294
+
295
+ function redirectAuthorizationError(
296
+ res,
297
+ redirectUri,
298
+ state,
299
+ error,
300
+ description,
301
+ ) {
302
+ const location = new URL(redirectUri);
303
+ location.searchParams.set('error', error);
304
+ if (description) location.searchParams.set('error_description', description);
305
+ location.searchParams.set('state', state);
306
+ location.searchParams.set('iss', PUBLIC_ORIGIN);
307
+ res.writeHead(302, {
308
+ location: location.toString(),
309
+ 'cache-control': 'no-store',
310
+ pragma: 'no-cache',
311
+ });
312
+ res.end();
313
+ }
314
+
96
315
  function sendRpcError(res, status, code, message, extraHeaders = {}) {
97
- const body = JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null });
98
- res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), ...extraHeaders });
99
- res.end(body);
316
+ sendJson(res, status, {
317
+ jsonrpc: '2.0',
318
+ error: { code, message },
319
+ id: null,
320
+ }, extraHeaders);
100
321
  }
101
322
 
102
323
  function bearerToken(req) {
103
- const h = req.headers['authorization'] || req.headers['Authorization'];
104
- if (!h || typeof h !== 'string') return null;
105
- const m = h.match(/^Bearer\s+(.+)$/i);
106
- return m ? m[1].trim() : null;
324
+ const header = req.headers.authorization;
325
+ if (!header || typeof header !== 'string') return null;
326
+ const match = header.match(/^Bearer\s+(.+)$/i);
327
+ return match ? match[1].trim() : null;
328
+ }
329
+
330
+ function oauthChallenge(error = '') {
331
+ const detail = error ? `, error="${error}"` : '';
332
+ return `Bearer realm="kmhub-mcp", resource_metadata="${RESOURCE_METADATA_URL}", scope="${DEFAULT_SCOPE}"${detail}`;
333
+ }
334
+
335
+ function authorizationServerMetadata() {
336
+ return {
337
+ issuer: PUBLIC_ORIGIN,
338
+ authorization_endpoint: `${PUBLIC_ORIGIN}/authorize`,
339
+ token_endpoint: `${PUBLIC_ORIGIN}/token`,
340
+ registration_endpoint: `${PUBLIC_ORIGIN}/register`,
341
+ revocation_endpoint: `${PUBLIC_ORIGIN}/revoke`,
342
+ response_types_supported: ['code'],
343
+ grant_types_supported: ['authorization_code', 'refresh_token'],
344
+ token_endpoint_auth_methods_supported: ['none'],
345
+ code_challenge_methods_supported: ['S256'],
346
+ scopes_supported: [WORKSPACE_SCOPE, COACH_SCOPE],
347
+ authorization_response_iss_parameter_supported: true,
348
+ };
349
+ }
350
+
351
+ function protectedResourceMetadata() {
352
+ return {
353
+ resource: RESOURCE_URL,
354
+ authorization_servers: [PUBLIC_ORIGIN],
355
+ scopes_supported: [WORKSPACE_SCOPE, COACH_SCOPE],
356
+ bearer_methods_supported: ['header'],
357
+ // This is the name Claude shows in its Add Connector dialog. It is the whole
358
+ // workspace now, not one product inside it.
359
+ resource_name: 'KM Hub by Kivi Media',
360
+ };
361
+ }
362
+
363
+ async function readRequestBody(req, maxBytes = MAX_OAUTH_BODY_BYTES) {
364
+ const declaredLength = Number(req.headers['content-length'] || 0);
365
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
366
+ const error = new Error('request_too_large');
367
+ error.status = 413;
368
+ throw error;
369
+ }
370
+
371
+ const chunks = [];
372
+ let size = 0;
373
+ for await (const chunk of req) {
374
+ size += chunk.length;
375
+ if (size > maxBytes) {
376
+ const error = new Error('request_too_large');
377
+ error.status = 413;
378
+ throw error;
379
+ }
380
+ chunks.push(chunk);
381
+ }
382
+ return Buffer.concat(chunks).toString('utf8');
383
+ }
384
+
385
+ async function brokerRequest(action, body) {
386
+ if (BROKER_SECRET.length < 32) {
387
+ return { ok: false, status: 503, data: { error: 'broker_unavailable' } };
388
+ }
389
+ try {
390
+ const response = await fetch(`${BROKER_BASE}/${action}`, {
391
+ method: 'POST',
392
+ headers: {
393
+ 'content-type': 'application/json',
394
+ accept: 'application/json',
395
+ 'x-kmhub-broker-secret': BROKER_SECRET,
396
+ },
397
+ body: JSON.stringify(body),
398
+ signal: AbortSignal.timeout(8000),
399
+ });
400
+ const text = await response.text();
401
+ let data;
402
+ try {
403
+ data = text ? JSON.parse(text) : {};
404
+ } catch {
405
+ data = { error: 'broker_invalid_response' };
406
+ }
407
+ return { ok: response.ok, status: response.status, data };
408
+ } catch {
409
+ return { ok: false, status: 503, data: { error: 'broker_unavailable' } };
410
+ }
411
+ }
412
+
413
+ function validUuid(value) {
414
+ return typeof value === 'string'
415
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
416
+ }
417
+
418
+ async function introspectCoachToken(accessToken) {
419
+ const result = await brokerRequest('introspect', {
420
+ access_token: accessToken,
421
+ resource: RESOURCE_URL,
422
+ });
423
+ if (!result.ok) return { verdict: 'unavailable' };
424
+ const binding = result.data;
425
+ if (!binding || binding.active !== true) return { verdict: 'denied' };
426
+ if (
427
+ !validUuid(binding.grant_id)
428
+ || !validUuid(binding.connection_id)
429
+ || !validUuid(binding.user_id)
430
+ || !validUuid(binding.org_id)
431
+ || typeof binding.client_id !== 'string'
432
+ || !CLIENT_ID_PATTERN.test(binding.client_id)
433
+ || profileForScope(binding.scope) === null
434
+ || binding.profile !== profileForScope(binding.scope)
435
+ || binding.resource !== RESOURCE_URL
436
+ || typeof binding.expires_at !== 'string'
437
+ || Date.parse(binding.expires_at) <= Date.now()
438
+ ) {
439
+ return { verdict: 'denied' };
440
+ }
441
+ return { verdict: 'allowed', binding };
442
+ }
443
+
444
+ function base64urlJson(value) {
445
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
446
+ }
447
+
448
+ function canonicalInternalTarget(path) {
449
+ if (
450
+ typeof path !== 'string'
451
+ || !path.startsWith('/')
452
+ || path.startsWith('//')
453
+ || path.includes('#')
454
+ || path.includes('://')
455
+ ) {
456
+ return null;
457
+ }
458
+ try {
459
+ const parsed = new URL(path, 'https://kmhub-internal.invalid');
460
+ if (parsed.origin !== 'https://kmhub-internal.invalid') return null;
461
+ return parsed.pathname + parsed.search;
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+
467
+ function signInternalAssertion(binding, method, target, rawBody) {
468
+ if (INTERNAL_SECRET.length < 32) return null;
469
+ const issuedAt = Math.floor(Date.now() / 1000);
470
+ const header = base64urlJson({
471
+ alg: 'HS256',
472
+ typ: 'JWT',
473
+ kid: 'coach-mcp-v1',
474
+ });
475
+ const payload = base64urlJson({
476
+ iss: PUBLIC_ORIGIN,
477
+ aud: internalAudienceFor(binding.profile),
478
+ sub: binding.user_id,
479
+ org_id: binding.org_id,
480
+ grant_id: binding.grant_id,
481
+ connection_id: binding.connection_id,
482
+ client_id: binding.client_id,
483
+ resource: RESOURCE_URL,
484
+ scope: binding.scope,
485
+ profile: binding.profile,
486
+ method: method.toUpperCase(),
487
+ path: target,
488
+ body_sha256: createHash('sha256').update(rawBody, 'utf8').digest('hex'),
489
+ iat: issuedAt,
490
+ exp: issuedAt + 15,
491
+ jti: randomUUID(),
492
+ });
493
+ const signingInput = header + '.' + payload;
494
+ const signature = createHmac('sha256', INTERNAL_SECRET)
495
+ .update(signingInput, 'utf8')
496
+ .digest('base64url');
497
+ return COACH_ASSERTION_PREFIX + signingInput + '.' + signature;
498
+ }
499
+
500
+ function makeInternalCaller(binding) {
501
+ return async function call(method, path, body) {
502
+ const target = canonicalInternalTarget(path);
503
+ if (!target) {
504
+ return {
505
+ ok: false,
506
+ status: 400,
507
+ data: { error: 'invalid_internal_route' },
508
+ };
509
+ }
510
+ const hasBody = body !== undefined && body !== null;
511
+ const rawBody = hasBody ? JSON.stringify(body) : '';
512
+ const assertion = signInternalAssertion(binding, method, target, rawBody);
513
+ if (!assertion) {
514
+ return {
515
+ ok: false,
516
+ status: 503,
517
+ data: { error: 'coach_authorization_unavailable' },
518
+ };
519
+ }
520
+ const response = await fetch(BASE.replace(/\/+$/, '') + target, {
521
+ method,
522
+ headers: {
523
+ Authorization: 'Bearer ' + assertion,
524
+ 'content-type': 'application/json',
525
+ },
526
+ body: hasBody ? rawBody : undefined,
527
+ });
528
+ const text = await response.text();
529
+ let data;
530
+ try {
531
+ data = JSON.parse(text);
532
+ } catch {
533
+ data = text;
534
+ }
535
+ return { ok: response.ok, status: response.status, data };
536
+ };
107
537
  }
108
538
 
109
- // Which tool profile this ONE request asked for. Query string beats header; an
110
- // unknown or missing value resolves to `full`, never an error. Derived per request
111
- // and passed down by argument - nothing about the profile is ever stored, so the
112
- // server stays stateless and two tenants can hold two different profiles at once.
113
539
  function requestProfile(req, url) {
114
540
  const fromQuery = url.searchParams.get('profile');
115
- const raw = req.headers['x-kmhub-profile'];
116
- const fromHeader = Array.isArray(raw) ? raw[0] : raw;
117
- return resolveProfile((fromQuery && fromQuery.trim()) || (typeof fromHeader === 'string' ? fromHeader.trim() : ''));
541
+ const rawHeader = req.headers['x-kmhub-profile'];
542
+ const fromHeader = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
543
+ return resolveProfile(
544
+ (fromQuery && fromQuery.trim())
545
+ || (typeof fromHeader === 'string' ? fromHeader.trim() : ''),
546
+ );
118
547
  }
119
548
 
120
- // ---- one MCP request, scoped to one org's token ---------------------------
121
- async function handleMcpPost(req, res, token, profile) {
549
+ async function handleMcpPost(req, res, caller, profile) {
122
550
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
123
- registerTools(server, makeCaller(BASE, token), { profile });
124
-
125
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
551
+ registerTools(server, caller, { profile });
552
+ // The commands ship WITH the connector, not beside it. A file on disk cannot tell
553
+ // anyone whether the connector landed; a prompt listed by this server can only be
554
+ // there because it did. See prompts.mjs for the install this rule was written from.
555
+ registerPrompts(server);
556
+ const transport = new StreamableHTTPServerTransport({
557
+ sessionIdGenerator: undefined,
558
+ });
126
559
 
127
- // Tear the per-request server + transport down when the response closes.
128
560
  res.on('close', () => {
129
561
  transport.close().catch(() => {});
130
562
  server.close().catch(() => {});
@@ -132,22 +564,223 @@ async function handleMcpPost(req, res, token, profile) {
132
564
 
133
565
  try {
134
566
  await server.connect(transport);
135
- // Let the transport read + parse the request body and write the response.
136
567
  await transport.handleRequest(req, res);
137
- } catch (err) {
138
- console.error('kmhub-mcp remote: request error', err);
568
+ } catch {
569
+ console.error('kmhub-mcp remote: MCP request failed');
139
570
  if (!res.headersSent) {
140
571
  sendRpcError(res, 500, -32603, 'Internal server error');
141
572
  }
142
573
  }
143
574
  }
144
575
 
145
- // ---- router ---------------------------------------------------------------
576
+ async function handleRegister(req, res) {
577
+ let metadata;
578
+ try {
579
+ const raw = await readRequestBody(req);
580
+ metadata = JSON.parse(raw);
581
+ } catch (error) {
582
+ const status = error?.status === 413 ? 413 : 400;
583
+ return sendOAuthJson(res, status, { error: 'invalid_client_metadata' });
584
+ }
585
+ const registerFingerprint = [
586
+ typeof metadata?.client_name === 'string' ? metadata.client_name : 'unknown',
587
+ Array.isArray(metadata?.redirect_uris)
588
+ ? metadata.redirect_uris.filter((value) => typeof value === 'string').sort().join(',')
589
+ : 'none',
590
+ String(req.headers['user-agent'] || ''),
591
+ ].join('|');
592
+ const retryAfter = oauthAbuseLimited(req, 'register', registerFingerprint);
593
+ if (retryAfter) return sendOAuthRateLimit(res, retryAfter);
594
+
595
+ const result = await brokerRequest('register', metadata);
596
+ return sendOAuthJson(res, result.status, result.data);
597
+ }
598
+
599
+ async function handleAuthorize(req, url, res) {
600
+ const parameters = url.searchParams;
601
+ const redirectUri = parameters.get('redirect_uri');
602
+ const state = parameters.get('state');
603
+ const clientId = parameters.get('client_id') || 'missing-client';
604
+ const retryAfter = oauthAbuseLimited(req, 'authorize', clientId);
605
+ if (retryAfter) return sendOAuthRateLimit(res, retryAfter);
606
+
607
+ if (!redirectUri || !isApprovedRedirect(redirectUri) || !state) {
608
+ return sendOAuthJson(res, 400, {
609
+ error: 'invalid_request',
610
+ error_description: 'The authorization request has no approved redirect or state.',
611
+ });
612
+ }
613
+
614
+ const resource = canonicalResource(parameters.get('resource'));
615
+ const scope = normalizeRequestedScope(parameters.get('scope'));
616
+ if (
617
+ parameters.get('response_type') !== 'code'
618
+ || !parameters.get('client_id')
619
+ || resource !== RESOURCE_URL
620
+ || profileForScope(scope) === null
621
+ || !parameters.get('code_challenge')
622
+ || parameters.get('code_challenge_method') !== 'S256'
623
+ ) {
624
+ return redirectAuthorizationError(
625
+ res,
626
+ redirectUri,
627
+ state,
628
+ 'invalid_request',
629
+ 'The authorization request is incomplete or unsupported.',
630
+ );
631
+ }
632
+
633
+ const result = await brokerRequest('authorize', {
634
+ client_id: parameters.get('client_id'),
635
+ redirect_uri: redirectUri,
636
+ state,
637
+ resource,
638
+ scope,
639
+ code_challenge: parameters.get('code_challenge'),
640
+ code_challenge_method: parameters.get('code_challenge_method'),
641
+ request_partition_hash: tokenFingerprint(
642
+ 'authorize-partition\n' + requestSourceIp(req),
643
+ ),
644
+ });
645
+ if (!result.ok || typeof result.data?.request_token !== 'string') {
646
+ const error = result.data?.error === 'invalid_client'
647
+ ? 'unauthorized_client'
648
+ : 'server_error';
649
+ return redirectAuthorizationError(
650
+ res,
651
+ redirectUri,
652
+ state,
653
+ error,
654
+ 'KMHub could not start this authorization request.',
655
+ );
656
+ }
657
+
658
+ const location = new URL(CONSENT_URL);
659
+ location.searchParams.set('request_token', result.data.request_token);
660
+ res.writeHead(302, {
661
+ location: location.toString(),
662
+ 'cache-control': 'no-store',
663
+ pragma: 'no-cache',
664
+ });
665
+ res.end();
666
+ }
667
+
668
+ async function parseForm(req) {
669
+ const raw = await readRequestBody(req);
670
+ return new URLSearchParams(raw);
671
+ }
672
+
673
+ async function handleToken(req, res) {
674
+ let form;
675
+ try {
676
+ form = await parseForm(req);
677
+ } catch (error) {
678
+ const status = error?.status === 413 ? 413 : 400;
679
+ return sendOAuthJson(res, status, { error: 'invalid_request' });
680
+ }
681
+
682
+ const grantType = form.get('grant_type');
683
+ const payload = {
684
+ grant_type: grantType,
685
+ client_id: form.get('client_id'),
686
+ resource: canonicalResource(form.get('resource')),
687
+ };
688
+ if (grantType === 'authorization_code') {
689
+ Object.assign(payload, {
690
+ code: form.get('code'),
691
+ redirect_uri: form.get('redirect_uri'),
692
+ code_verifier: form.get('code_verifier'),
693
+ });
694
+ } else if (grantType === 'refresh_token') {
695
+ // The scope is NOT forwarded on a refresh: the broker reads it off the refresh token,
696
+ // which is the grant the database actually stands behind. A client re-sending every
697
+ // scope it saw in the metadata (ChatGPT may) would otherwise be refused.
698
+ Object.assign(payload, {
699
+ refresh_token: form.get('refresh_token'),
700
+ });
701
+ }
702
+
703
+ const retryAfter = oauthAbuseLimited(
704
+ req,
705
+ 'token',
706
+ typeof payload.client_id === 'string' ? payload.client_id : 'missing-client',
707
+ );
708
+ if (retryAfter) return sendOAuthRateLimit(res, retryAfter);
709
+
710
+ const result = await brokerRequest('token', payload);
711
+ return sendOAuthJson(res, result.status, result.data);
712
+ }
713
+
714
+ async function handleRevoke(req, res) {
715
+ let form;
716
+ try {
717
+ form = await parseForm(req);
718
+ } catch (error) {
719
+ const status = error?.status === 413 ? 413 : 400;
720
+ return sendOAuthJson(res, status, { error: 'invalid_request' });
721
+ }
722
+ const clientId = form.get('client_id') || 'missing-client';
723
+ const retryAfter = oauthAbuseLimited(req, 'revoke', clientId);
724
+ if (retryAfter) return sendOAuthRateLimit(res, retryAfter);
725
+
726
+ const result = await brokerRequest('revoke', {
727
+ client_id: form.get('client_id'),
728
+ token: form.get('token'),
729
+ token_type_hint: form.get('token_type_hint'),
730
+ });
731
+ if (result.status === 200) {
732
+ res.writeHead(200, {
733
+ 'cache-control': 'no-store',
734
+ pragma: 'no-cache',
735
+ });
736
+ return res.end();
737
+ }
738
+ return sendOAuthJson(res, result.status, result.data);
739
+ }
740
+
146
741
  const httpServer = http.createServer(async (req, res) => {
147
742
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
148
743
  const path = url.pathname.replace(/\/+$/, '') || '/';
149
744
 
150
- // Health: no auth, cheap, used by pm2 / nginx / uptime monitors.
745
+ if (
746
+ req.method === 'GET'
747
+ && (
748
+ path === '/.well-known/oauth-protected-resource'
749
+ || path === '/.well-known/oauth-protected-resource/mcp'
750
+ )
751
+ ) {
752
+ return sendJson(res, 200, protectedResourceMetadata(), {
753
+ 'access-control-allow-origin': '*',
754
+ 'cache-control': 'public, max-age=300',
755
+ });
756
+ }
757
+
758
+ if (
759
+ req.method === 'GET'
760
+ && (
761
+ path === '/.well-known/oauth-authorization-server'
762
+ || path === '/.well-known/openid-configuration'
763
+ )
764
+ ) {
765
+ return sendJson(res, 200, authorizationServerMetadata(), {
766
+ 'access-control-allow-origin': '*',
767
+ 'cache-control': 'public, max-age=300',
768
+ });
769
+ }
770
+
771
+ if (req.method === 'POST' && path === '/register') {
772
+ return handleRegister(req, res);
773
+ }
774
+ if (req.method === 'GET' && path === '/authorize') {
775
+ return handleAuthorize(req, url, res);
776
+ }
777
+ if (req.method === 'POST' && path === '/token') {
778
+ return handleToken(req, res);
779
+ }
780
+ if (req.method === 'POST' && path === '/revoke') {
781
+ return handleRevoke(req, res);
782
+ }
783
+
151
784
  if (req.method === 'GET' && (path === '/health' || path === '/healthz')) {
152
785
  return sendJson(res, 200, {
153
786
  ok: true,
@@ -157,19 +790,33 @@ const httpServer = http.createServer(async (req, res) => {
157
790
  tools: TOOL_NAMES.length,
158
791
  families: FAMILY_NAMES,
159
792
  default_profile: DEFAULT_PROFILE,
160
- profiles: Object.fromEntries(PROFILE_NAMES.map((p) => [p, toolNamesFor(p).length])),
793
+ profiles: Object.fromEntries(
794
+ PROFILE_NAMES.map((profile) => [profile, toolNamesFor(profile).length]),
795
+ ),
796
+ oauth: {
797
+ enabled: OAUTH_CONFIGURED,
798
+ authorization_server: OAUTH_CONFIGURED ? PUBLIC_ORIGIN : null,
799
+ resource: OAUTH_CONFIGURED ? RESOURCE_URL : null,
800
+ resource_metadata: OAUTH_CONFIGURED ? RESOURCE_METADATA_URL : null,
801
+ token_passthrough: false,
802
+ },
161
803
  });
162
804
  }
163
805
 
164
806
  if (path === '/mcp') {
165
- // Streamable HTTP stateless: only POST carries JSON-RPC. GET (server SSE) is unused.
166
807
  if (req.method === 'GET' || req.method === 'DELETE') {
167
- return sendRpcError(res, 405, -32000, 'Method not allowed: this server is stateless (POST /mcp only).', {
168
- allow: 'POST',
169
- });
808
+ return sendRpcError(
809
+ res,
810
+ 405,
811
+ -32000,
812
+ 'Method not allowed: this server is stateless (POST /mcp only).',
813
+ { allow: 'POST' },
814
+ );
170
815
  }
171
816
  if (req.method !== 'POST') {
172
- return sendRpcError(res, 405, -32000, 'Method not allowed.', { allow: 'POST' });
817
+ return sendRpcError(res, 405, -32000, 'Method not allowed.', {
818
+ allow: 'POST',
819
+ });
173
820
  }
174
821
 
175
822
  const token = bearerToken(req);
@@ -178,19 +825,89 @@ const httpServer = http.createServer(async (req, res) => {
178
825
  res,
179
826
  401,
180
827
  -32001,
181
- 'Missing KM Hub API key. Send it as: Authorization: Bearer kmh_live_...',
182
- { 'www-authenticate': 'Bearer realm="kmhub-mcp"' },
828
+ 'Sign in to KMHub to connect this MCP server.',
829
+ { 'www-authenticate': oauthChallenge() },
830
+ );
831
+ }
832
+
833
+ let profile;
834
+ let caller;
835
+ if (API_KEY_PATTERN.test(token)) {
836
+ profile = requestProfile(req, url);
837
+ caller = makeCaller(BASE, token);
838
+ } else if (ACCESS_TOKEN_PATTERN.test(token)) {
839
+ // Set from the introspected binding once it is known, a few lines below. Left
840
+ // undefined here on purpose: the old code pinned it to 'coach' before anyone had
841
+ // said which product this token was for.
842
+ profile = null;
843
+ const introRetryAfter = oauthAbuseLimited(
844
+ req,
845
+ 'introspect',
846
+ tokenFingerprint('introspect-token\n' + token),
847
+ );
848
+ if (introRetryAfter) {
849
+ return sendRpcError(
850
+ res,
851
+ 429,
852
+ -32002,
853
+ 'Too many coach authorization checks. Retry shortly.',
854
+ { 'retry-after': String(introRetryAfter) },
855
+ );
856
+ }
857
+ const introspection = await introspectCoachToken(token);
858
+ if (introspection.verdict === 'denied') {
859
+ return sendRpcError(
860
+ res,
861
+ 401,
862
+ -32001,
863
+ 'The Fully Booked Coach connection is invalid or expired.',
864
+ { 'www-authenticate': oauthChallenge('invalid_token') },
865
+ );
866
+ }
867
+ if (introspection.verdict !== 'allowed') {
868
+ return sendRpcError(
869
+ res,
870
+ 503,
871
+ -32004,
872
+ 'KMHub could not verify the coach connection. Retry shortly.',
873
+ );
874
+ }
875
+ if (INTERNAL_SECRET.length < 32) {
876
+ return sendRpcError(
877
+ res,
878
+ 503,
879
+ -32004,
880
+ 'KMHub coach authorization is not configured.',
881
+ );
882
+ }
883
+ // The tool surface follows the grant. A workspace connection gets the full set,
884
+ // the same as an API key for the same workspace; coach keeps its own bounded
885
+ // profile. introspectCoachToken has already refused any binding whose profile and
886
+ // scope disagree, so this value is the one the database stood behind.
887
+ profile = introspection.binding.profile === 'coach' ? 'coach' : DEFAULT_PROFILE;
888
+ caller = makeInternalCaller(introspection.binding);
889
+ } else {
890
+ return sendRpcError(
891
+ res,
892
+ 401,
893
+ -32001,
894
+ 'The KMHub credential is not recognized.',
895
+ { 'www-authenticate': oauthChallenge('invalid_token') },
183
896
  );
184
897
  }
185
898
 
186
899
  const retryAfter = rateLimited(token);
187
900
  if (retryAfter) {
188
- return sendRpcError(res, 429, -32002, `Rate limit exceeded. Retry after ${retryAfter}s.`, {
189
- 'retry-after': String(retryAfter),
190
- });
901
+ return sendRpcError(
902
+ res,
903
+ 429,
904
+ -32002,
905
+ `Rate limit exceeded. Retry after ${retryAfter}s.`,
906
+ { 'retry-after': String(retryAfter) },
907
+ );
191
908
  }
192
909
 
193
- return handleMcpPost(req, res, token, requestProfile(req, url));
910
+ return handleMcpPost(req, res, caller, profile);
194
911
  }
195
912
 
196
913
  return sendRpcError(res, 404, -32601, `Not found: ${req.method} ${path}`);
@@ -198,15 +915,14 @@ const httpServer = http.createServer(async (req, res) => {
198
915
 
199
916
  httpServer.listen(PORT, HOST, () => {
200
917
  console.error(
201
- `kmhub-mcp remote ${SERVER_VERSION}: listening on http://${HOST}:${PORT} (POST /mcp, GET /health). ` +
202
- `Families: ${FAMILY_NAMES.join(', ')}. Profiles: ${PROFILE_NAMES.join(', ')} (default ${DEFAULT_PROFILE}). ` +
203
- `Tools: ${TOOL_NAMES.join(', ')}.`,
918
+ `kmhub-mcp remote ${SERVER_VERSION}: listening on http://${HOST}:${PORT}. `
919
+ + `Families: ${FAMILY_NAMES.join(', ')}. Profiles: ${PROFILE_NAMES.join(', ')}. `
920
+ + `Tools: ${TOOL_NAMES.length}.`,
204
921
  );
205
922
  });
206
923
 
207
- // Graceful shutdown for pm2 restarts.
208
- for (const sig of ['SIGINT', 'SIGTERM']) {
209
- process.on(sig, () => {
924
+ for (const signal of ['SIGINT', 'SIGTERM']) {
925
+ process.on(signal, () => {
210
926
  httpServer.close(() => process.exit(0));
211
927
  setTimeout(() => process.exit(0), 3000).unref();
212
928
  });