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