@kivimedia/kmhub 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +170 -170
  2. package/bin/kmhub.mjs +896 -896
  3. package/coach-book-output-guard.mjs +760 -760
  4. package/index.mjs +57 -57
  5. package/package.json +56 -56
  6. package/prompts/briefing.md +29 -29
  7. package/prompts/luxury.md +70 -70
  8. package/prompts/play.md +49 -49
  9. package/prompts/run.md +36 -36
  10. package/prompts/setup.md +33 -33
  11. package/prompts/vs-booked.md +46 -46
  12. package/prompts/what-can-you-do.md +40 -40
  13. package/prompts.mjs +110 -109
  14. package/read-only-tools.json +143 -142
  15. package/remote.mjs +929 -929
  16. package/tools/balloon-costing.mjs +80 -80
  17. package/tools/booking-equipment.mjs +110 -110
  18. package/tools/bridges.mjs +54 -54
  19. package/tools/briefing.mjs +91 -91
  20. package/tools/calendar.mjs +170 -170
  21. package/tools/capabilities.mjs +155 -155
  22. package/tools/catalog.mjs +288 -288
  23. package/tools/clubs.mjs +176 -176
  24. package/tools/coach.mjs +771 -771
  25. package/tools/compare.mjs +76 -76
  26. package/tools/core.mjs +244 -244
  27. package/tools/crm.mjs +209 -209
  28. package/tools/dubsado.mjs +137 -137
  29. package/tools/exports.mjs +128 -128
  30. package/tools/fact-review.mjs +125 -125
  31. package/tools/flows.mjs +261 -261
  32. package/tools/forms.mjs +158 -158
  33. package/tools/gols.mjs +134 -134
  34. package/tools/hr.mjs +162 -162
  35. package/tools/knowledge.mjs +125 -125
  36. package/tools/marketing.mjs +396 -396
  37. package/tools/meta.mjs +245 -245
  38. package/tools/military.mjs +244 -244
  39. package/tools/money.mjs +235 -197
  40. package/tools/outreach.mjs +238 -238
  41. package/tools/pending.mjs +122 -122
  42. package/tools/photos.mjs +140 -140
  43. package/tools/plays.mjs +244 -244
  44. package/tools/profile.mjs +118 -118
  45. package/tools/radar.mjs +173 -173
  46. package/tools/recurring-invoices.mjs +149 -149
  47. package/tools/reengage.mjs +434 -434
  48. package/tools/schedules.mjs +55 -55
  49. package/tools/setup.mjs +168 -168
  50. package/tools/sops-bridges.mjs +86 -86
  51. package/tools/sops.mjs +314 -314
  52. package/tools/sourcing.mjs +268 -268
  53. package/tools/strategy.mjs +146 -146
  54. package/tools/studio.mjs +132 -132
  55. package/tools/venueradar.mjs +151 -151
  56. package/tools/voice.mjs +134 -134
  57. package/tools.mjs +407 -407
package/remote.mjs CHANGED
@@ -1,929 +1,929 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Remote Streamable HTTP MCP server for KMHub.
4
- *
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.
9
- */
10
- import http from 'node:http';
11
- import { createHash, createHmac, randomUUID } from 'node:crypto';
12
- import { isIP } from 'node:net';
13
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
15
- import { registerPrompts } from './prompts.mjs';
16
- import {
17
- DEFAULT_BASE,
18
- DEFAULT_PROFILE,
19
- FAMILY_NAMES,
20
- PROFILE_NAMES,
21
- SERVER_NAME,
22
- SERVER_VERSION,
23
- TOOL_NAMES,
24
- makeCaller,
25
- registerTools,
26
- resolveProfile,
27
- toolNamesFor,
28
- } from './tools.mjs';
29
-
30
- const PORT = Number(process.env.PORT || 8830);
31
- const HOST = process.env.HOST || '0.0.0.0';
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);
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
- }
180
-
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();
186
-
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) {
224
- const now = Date.now();
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);
230
- }
231
- bucket.count += 1;
232
- if (bucket.count > limit) {
233
- return Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
234
- }
235
- return 0;
236
- }
237
-
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
-
257
- setInterval(() => {
258
- const now = Date.now();
259
- evictExpiredAndBound(buckets, now);
260
- evictExpiredAndBound(oauthBuckets, now);
261
- evictExpiredAndBound(introspectTokenBuckets, now);
262
- }, WINDOW_MS).unref();
263
-
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
- });
272
- res.end(body);
273
- }
274
-
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
-
315
- function sendRpcError(res, status, code, message, extraHeaders = {}) {
316
- sendJson(res, status, {
317
- jsonrpc: '2.0',
318
- error: { code, message },
319
- id: null,
320
- }, extraHeaders);
321
- }
322
-
323
- function bearerToken(req) {
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
- };
537
- }
538
-
539
- function requestProfile(req, url) {
540
- const fromQuery = url.searchParams.get('profile');
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
- );
547
- }
548
-
549
- async function handleMcpPost(req, res, caller, profile) {
550
- const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
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
- });
559
-
560
- res.on('close', () => {
561
- transport.close().catch(() => {});
562
- server.close().catch(() => {});
563
- });
564
-
565
- try {
566
- await server.connect(transport);
567
- await transport.handleRequest(req, res);
568
- } catch {
569
- console.error('kmhub-mcp remote: MCP request failed');
570
- if (!res.headersSent) {
571
- sendRpcError(res, 500, -32603, 'Internal server error');
572
- }
573
- }
574
- }
575
-
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
-
741
- const httpServer = http.createServer(async (req, res) => {
742
- const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
743
- const path = url.pathname.replace(/\/+$/, '') || '/';
744
-
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
-
784
- if (req.method === 'GET' && (path === '/health' || path === '/healthz')) {
785
- return sendJson(res, 200, {
786
- ok: true,
787
- server: SERVER_NAME,
788
- version: SERVER_VERSION,
789
- transport: 'streamable-http',
790
- tools: TOOL_NAMES.length,
791
- families: FAMILY_NAMES,
792
- default_profile: DEFAULT_PROFILE,
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
- },
803
- });
804
- }
805
-
806
- if (path === '/mcp') {
807
- if (req.method === 'GET' || req.method === 'DELETE') {
808
- return sendRpcError(
809
- res,
810
- 405,
811
- -32000,
812
- 'Method not allowed: this server is stateless (POST /mcp only).',
813
- { allow: 'POST' },
814
- );
815
- }
816
- if (req.method !== 'POST') {
817
- return sendRpcError(res, 405, -32000, 'Method not allowed.', {
818
- allow: 'POST',
819
- });
820
- }
821
-
822
- const token = bearerToken(req);
823
- if (!token) {
824
- return sendRpcError(
825
- res,
826
- 401,
827
- -32001,
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') },
896
- );
897
- }
898
-
899
- const retryAfter = rateLimited(token);
900
- if (retryAfter) {
901
- return sendRpcError(
902
- res,
903
- 429,
904
- -32002,
905
- `Rate limit exceeded. Retry after ${retryAfter}s.`,
906
- { 'retry-after': String(retryAfter) },
907
- );
908
- }
909
-
910
- return handleMcpPost(req, res, caller, profile);
911
- }
912
-
913
- return sendRpcError(res, 404, -32601, `Not found: ${req.method} ${path}`);
914
- });
915
-
916
- httpServer.listen(PORT, HOST, () => {
917
- console.error(
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}.`,
921
- );
922
- });
923
-
924
- for (const signal of ['SIGINT', 'SIGTERM']) {
925
- process.on(signal, () => {
926
- httpServer.close(() => process.exit(0));
927
- setTimeout(() => process.exit(0), 3000).unref();
928
- });
929
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Remote Streamable HTTP MCP server for KMHub.
4
+ *
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.
9
+ */
10
+ import http from 'node:http';
11
+ import { createHash, createHmac, randomUUID } from 'node:crypto';
12
+ import { isIP } from 'node:net';
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
15
+ import { registerPrompts } from './prompts.mjs';
16
+ import {
17
+ DEFAULT_BASE,
18
+ DEFAULT_PROFILE,
19
+ FAMILY_NAMES,
20
+ PROFILE_NAMES,
21
+ SERVER_NAME,
22
+ SERVER_VERSION,
23
+ TOOL_NAMES,
24
+ makeCaller,
25
+ registerTools,
26
+ resolveProfile,
27
+ toolNamesFor,
28
+ } from './tools.mjs';
29
+
30
+ const PORT = Number(process.env.PORT || 8830);
31
+ const HOST = process.env.HOST || '0.0.0.0';
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);
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
+ }
180
+
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();
186
+
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) {
224
+ const now = Date.now();
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);
230
+ }
231
+ bucket.count += 1;
232
+ if (bucket.count > limit) {
233
+ return Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
234
+ }
235
+ return 0;
236
+ }
237
+
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
+
257
+ setInterval(() => {
258
+ const now = Date.now();
259
+ evictExpiredAndBound(buckets, now);
260
+ evictExpiredAndBound(oauthBuckets, now);
261
+ evictExpiredAndBound(introspectTokenBuckets, now);
262
+ }, WINDOW_MS).unref();
263
+
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
+ });
272
+ res.end(body);
273
+ }
274
+
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
+
315
+ function sendRpcError(res, status, code, message, extraHeaders = {}) {
316
+ sendJson(res, status, {
317
+ jsonrpc: '2.0',
318
+ error: { code, message },
319
+ id: null,
320
+ }, extraHeaders);
321
+ }
322
+
323
+ function bearerToken(req) {
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
+ };
537
+ }
538
+
539
+ function requestProfile(req, url) {
540
+ const fromQuery = url.searchParams.get('profile');
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
+ );
547
+ }
548
+
549
+ async function handleMcpPost(req, res, caller, profile) {
550
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
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
+ });
559
+
560
+ res.on('close', () => {
561
+ transport.close().catch(() => {});
562
+ server.close().catch(() => {});
563
+ });
564
+
565
+ try {
566
+ await server.connect(transport);
567
+ await transport.handleRequest(req, res);
568
+ } catch {
569
+ console.error('kmhub-mcp remote: MCP request failed');
570
+ if (!res.headersSent) {
571
+ sendRpcError(res, 500, -32603, 'Internal server error');
572
+ }
573
+ }
574
+ }
575
+
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
+
741
+ const httpServer = http.createServer(async (req, res) => {
742
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
743
+ const path = url.pathname.replace(/\/+$/, '') || '/';
744
+
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
+
784
+ if (req.method === 'GET' && (path === '/health' || path === '/healthz')) {
785
+ return sendJson(res, 200, {
786
+ ok: true,
787
+ server: SERVER_NAME,
788
+ version: SERVER_VERSION,
789
+ transport: 'streamable-http',
790
+ tools: TOOL_NAMES.length,
791
+ families: FAMILY_NAMES,
792
+ default_profile: DEFAULT_PROFILE,
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
+ },
803
+ });
804
+ }
805
+
806
+ if (path === '/mcp') {
807
+ if (req.method === 'GET' || req.method === 'DELETE') {
808
+ return sendRpcError(
809
+ res,
810
+ 405,
811
+ -32000,
812
+ 'Method not allowed: this server is stateless (POST /mcp only).',
813
+ { allow: 'POST' },
814
+ );
815
+ }
816
+ if (req.method !== 'POST') {
817
+ return sendRpcError(res, 405, -32000, 'Method not allowed.', {
818
+ allow: 'POST',
819
+ });
820
+ }
821
+
822
+ const token = bearerToken(req);
823
+ if (!token) {
824
+ return sendRpcError(
825
+ res,
826
+ 401,
827
+ -32001,
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') },
896
+ );
897
+ }
898
+
899
+ const retryAfter = rateLimited(token);
900
+ if (retryAfter) {
901
+ return sendRpcError(
902
+ res,
903
+ 429,
904
+ -32002,
905
+ `Rate limit exceeded. Retry after ${retryAfter}s.`,
906
+ { 'retry-after': String(retryAfter) },
907
+ );
908
+ }
909
+
910
+ return handleMcpPost(req, res, caller, profile);
911
+ }
912
+
913
+ return sendRpcError(res, 404, -32601, `Not found: ${req.method} ${path}`);
914
+ });
915
+
916
+ httpServer.listen(PORT, HOST, () => {
917
+ console.error(
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}.`,
921
+ );
922
+ });
923
+
924
+ for (const signal of ['SIGINT', 'SIGTERM']) {
925
+ process.on(signal, () => {
926
+ httpServer.close(() => process.exit(0));
927
+ setTimeout(() => process.exit(0), 3000).unref();
928
+ });
929
+ }