@kivimedia/kmhub 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -170
- package/bin/kmhub.mjs +896 -896
- package/coach-book-output-guard.mjs +760 -760
- package/index.mjs +57 -57
- package/package.json +56 -56
- package/prompts/briefing.md +29 -29
- package/prompts/luxury.md +70 -70
- package/prompts/play.md +49 -49
- package/prompts/run.md +36 -36
- package/prompts/setup.md +33 -33
- package/prompts/vs-booked.md +46 -46
- package/prompts/what-can-you-do.md +40 -40
- package/prompts.mjs +110 -109
- package/read-only-tools.json +143 -142
- package/remote.mjs +929 -929
- package/tools/balloon-costing.mjs +80 -80
- package/tools/booking-equipment.mjs +110 -110
- package/tools/bridges.mjs +54 -54
- package/tools/briefing.mjs +91 -91
- package/tools/calendar.mjs +170 -170
- package/tools/capabilities.mjs +155 -155
- package/tools/catalog.mjs +288 -288
- package/tools/clubs.mjs +176 -176
- package/tools/coach.mjs +771 -771
- package/tools/compare.mjs +76 -76
- package/tools/core.mjs +244 -244
- package/tools/crm.mjs +209 -209
- package/tools/dubsado.mjs +137 -137
- package/tools/exports.mjs +128 -128
- package/tools/fact-review.mjs +125 -125
- package/tools/flows.mjs +261 -261
- package/tools/forms.mjs +158 -158
- package/tools/gols.mjs +134 -134
- package/tools/hr.mjs +162 -162
- package/tools/knowledge.mjs +125 -125
- package/tools/marketing.mjs +396 -396
- package/tools/meta.mjs +245 -245
- package/tools/military.mjs +244 -244
- package/tools/money.mjs +235 -197
- package/tools/outreach.mjs +238 -238
- package/tools/pending.mjs +122 -122
- package/tools/photos.mjs +140 -140
- package/tools/plays.mjs +244 -244
- package/tools/profile.mjs +118 -118
- package/tools/radar.mjs +173 -173
- package/tools/recurring-invoices.mjs +149 -149
- package/tools/reengage.mjs +434 -434
- package/tools/schedules.mjs +55 -55
- package/tools/setup.mjs +168 -168
- package/tools/sops-bridges.mjs +86 -86
- package/tools/sops.mjs +314 -314
- package/tools/sourcing.mjs +268 -268
- package/tools/strategy.mjs +146 -146
- package/tools/studio.mjs +132 -132
- package/tools/venueradar.mjs +151 -151
- package/tools/voice.mjs +134 -134
- package/tools.mjs +407 -407
package/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
|
+
}
|