@gethmy/mcp 2.4.4 → 2.4.7
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/dist/cli.js +56 -12
- package/dist/index.js +56 -12
- package/dist/lib/api-client.js +29 -1
- package/dist/lib/config.js +5 -1
- package/package.json +1 -1
- package/src/__tests__/memory-audit.test.ts +83 -1
- package/src/__tests__/remote-routing.test.ts +285 -0
- package/src/api-client.ts +40 -1
- package/src/memory-audit.ts +36 -16
- package/src/remote.ts +318 -56
package/src/remote.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
18
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
19
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
19
20
|
import { serve } from "bun";
|
|
20
21
|
import { Hono } from "hono";
|
|
21
22
|
import { cors } from "hono/cors";
|
|
@@ -42,12 +43,52 @@ interface TokenInfo {
|
|
|
42
43
|
source: "api_key" | "oauth";
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
// Tiny TTL cache for /v1/auth/context. A refresh storm (Claude rotates a
|
|
47
|
+
// token, fires several queued tool calls in parallel) would otherwise hammer
|
|
48
|
+
// harmony-api with identical lookups. 30s is short enough that revocation
|
|
49
|
+
// propagates quickly, long enough to absorb any normal burst.
|
|
50
|
+
const TOKEN_CACHE_TTL_MS = 30_000;
|
|
51
|
+
const TOKEN_CACHE_MAX = 1000;
|
|
52
|
+
const tokenCache = new Map<string, { info: TokenInfo; expiresAt: number }>();
|
|
53
|
+
|
|
54
|
+
async function tokenFingerprint(token: string): Promise<string> {
|
|
55
|
+
const data = new TextEncoder().encode(token);
|
|
56
|
+
const buf = await crypto.subtle.digest("SHA-256", data);
|
|
57
|
+
return Array.from(new Uint8Array(buf))
|
|
58
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
59
|
+
.join("");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function evictTokenCacheLru(): void {
|
|
63
|
+
if (tokenCache.size <= TOKEN_CACHE_MAX) return;
|
|
64
|
+
const oldest = tokenCache.keys().next().value;
|
|
65
|
+
if (oldest) tokenCache.delete(oldest);
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
async function validateToken(token: string): Promise<TokenInfo | null> {
|
|
69
|
+
const fp = await tokenFingerprint(token);
|
|
70
|
+
const cached = tokenCache.get(fp);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (cached && cached.expiresAt > now) {
|
|
73
|
+
// Refresh LRU position
|
|
74
|
+
tokenCache.delete(fp);
|
|
75
|
+
tokenCache.set(fp, cached);
|
|
76
|
+
return cached.info;
|
|
77
|
+
}
|
|
78
|
+
|
|
46
79
|
try {
|
|
47
80
|
const response = await fetch(`${HARMONY_API_URL}/v1/auth/context`, {
|
|
48
81
|
headers: { "X-API-Key": token },
|
|
49
82
|
});
|
|
50
|
-
if (!response.ok)
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
// Negative-cache 401s briefly so a flood of bad tokens doesn't
|
|
85
|
+
// pummel harmony-api. Other status codes (5xx, network) bypass the
|
|
86
|
+
// cache so transient failures self-heal on next call.
|
|
87
|
+
if (response.status === 401) {
|
|
88
|
+
tokenCache.delete(fp);
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
51
92
|
|
|
52
93
|
const data = (await response.json()) as {
|
|
53
94
|
userId: string;
|
|
@@ -55,16 +96,27 @@ async function validateToken(token: string): Promise<TokenInfo | null> {
|
|
|
55
96
|
workspaceId: string | null;
|
|
56
97
|
};
|
|
57
98
|
if (data.source === "jwt") return null; // JWT not allowed on MCP endpoint
|
|
58
|
-
|
|
99
|
+
const info: TokenInfo = {
|
|
59
100
|
userId: data.userId,
|
|
60
101
|
workspaceId: data.workspaceId,
|
|
61
102
|
source: data.source,
|
|
62
103
|
};
|
|
104
|
+
tokenCache.set(fp, { info, expiresAt: now + TOKEN_CACHE_TTL_MS });
|
|
105
|
+
evictTokenCacheLru();
|
|
106
|
+
return info;
|
|
63
107
|
} catch {
|
|
64
108
|
return null;
|
|
65
109
|
}
|
|
66
110
|
}
|
|
67
111
|
|
|
112
|
+
function invalidateTokenCache(token: string): void {
|
|
113
|
+
// Fire-and-forget — just clear if we already had it. Used after a bearer
|
|
114
|
+
// is rejected by harmony-api so the next attempt re-validates fresh.
|
|
115
|
+
tokenFingerprint(token)
|
|
116
|
+
.then((fp) => tokenCache.delete(fp))
|
|
117
|
+
.catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
|
|
68
120
|
// For legacy api_keys: fall back to workspaces list to pick the first one.
|
|
69
121
|
// OAuth tokens already have workspaceId bound at grant time.
|
|
70
122
|
async function resolveWorkspaceForLegacyKey(
|
|
@@ -90,21 +142,39 @@ async function resolveWorkspaceForLegacyKey(
|
|
|
90
142
|
interface McpSession {
|
|
91
143
|
transport: WebStandardStreamableHTTPServerTransport;
|
|
92
144
|
server: Server;
|
|
145
|
+
client: HarmonyApiClient;
|
|
93
146
|
apiKey: string;
|
|
147
|
+
// Bound at session creation. Re-checked on every token hot-swap so a leaked
|
|
148
|
+
// session ID can't be paired with a different user's bearer to ride someone
|
|
149
|
+
// else's session.
|
|
150
|
+
userId: string;
|
|
94
151
|
activeWorkspaceId: string | null;
|
|
95
152
|
activeProjectId: string | null;
|
|
96
153
|
createdAt: number;
|
|
154
|
+
// Bumped on every request that touches the session. Drives stale-session GC
|
|
155
|
+
// so a session that's actively rotating tokens stays alive past the access
|
|
156
|
+
// token TTL — auth happens per-request via the Bearer header, the session
|
|
157
|
+
// itself is just a transport handle.
|
|
158
|
+
lastUsedAt: number;
|
|
159
|
+
// Set by HarmonyApiClient.onUnauthorized when the API rejects the cached
|
|
160
|
+
// token mid-session. The HTTP layer reads this after transport.handleRequest
|
|
161
|
+
// returns and converts the response to an HTTP 401 challenge so the OAuth
|
|
162
|
+
// client refreshes instead of caching a JSON-RPC error forever.
|
|
163
|
+
unauthorized: boolean;
|
|
97
164
|
}
|
|
98
165
|
|
|
99
166
|
const sessions = new Map<string, McpSession>();
|
|
100
167
|
|
|
101
|
-
//
|
|
168
|
+
// Stale-session GC. Uses lastUsedAt (sliding window) instead of createdAt so
|
|
169
|
+
// long-lived clients that refresh OAuth tokens periodically aren't killed at
|
|
170
|
+
// the 1h mark just because their session was created an hour ago. 24h is an
|
|
171
|
+
// upper bound — clients that go truly idle that long can re-handshake cheaply.
|
|
172
|
+
const SESSION_IDLE_MAX_MS = 24 * 60 * 60 * 1000;
|
|
102
173
|
setInterval(
|
|
103
174
|
() => {
|
|
104
175
|
const now = Date.now();
|
|
105
|
-
const maxAge = 60 * 60 * 1000; // 1 hour
|
|
106
176
|
for (const [id, session] of sessions) {
|
|
107
|
-
if (now - session.
|
|
177
|
+
if (now - session.lastUsedAt > SESSION_IDLE_MAX_MS) {
|
|
108
178
|
session.transport.close().catch(() => {});
|
|
109
179
|
sessions.delete(id);
|
|
110
180
|
}
|
|
@@ -114,9 +184,23 @@ setInterval(
|
|
|
114
184
|
);
|
|
115
185
|
|
|
116
186
|
function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
|
|
187
|
+
// unauthorized flag lives on a mutable holder so the HarmonyApiClient
|
|
188
|
+
// callback can flip it without a circular reference between the client and
|
|
189
|
+
// the session struct it lives on.
|
|
190
|
+
const authState = { unauthorized: false };
|
|
191
|
+
|
|
192
|
+
// Forward declare so onsessioninitialized can register the session.
|
|
193
|
+
let sessionRef: McpSession;
|
|
194
|
+
|
|
117
195
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
118
196
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
119
197
|
enableJsonResponse: true,
|
|
198
|
+
onsessioninitialized: (sid: string) => {
|
|
199
|
+
sessions.set(sid, sessionRef);
|
|
200
|
+
console.log(
|
|
201
|
+
`[mcp] session=${sid} init user=${keyInfo.userId} src=${keyInfo.source}`,
|
|
202
|
+
);
|
|
203
|
+
},
|
|
120
204
|
});
|
|
121
205
|
|
|
122
206
|
const server = new Server(
|
|
@@ -124,17 +208,39 @@ function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
|
|
|
124
208
|
{ capabilities: { tools: {}, resources: {} } },
|
|
125
209
|
);
|
|
126
210
|
|
|
211
|
+
// Create per-session API client. onUnauthorized fires when harmony-api
|
|
212
|
+
// returns 401 — we mark the session so the HTTP layer can surface a real
|
|
213
|
+
// 401 + WWW-Authenticate challenge to the OAuth client.
|
|
214
|
+
const client = new HarmonyApiClient({
|
|
215
|
+
apiKey,
|
|
216
|
+
apiUrl: HARMONY_API_URL,
|
|
217
|
+
onUnauthorized: () => {
|
|
218
|
+
authState.unauthorized = true;
|
|
219
|
+
// Drop any cached "OK" entry for this bearer so future validations
|
|
220
|
+
// re-hit harmony-api and see the rejection.
|
|
221
|
+
invalidateTokenCache(client.getApiKey());
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const now = Date.now();
|
|
127
226
|
const session: McpSession = {
|
|
128
227
|
transport,
|
|
129
228
|
server,
|
|
229
|
+
client,
|
|
130
230
|
apiKey,
|
|
231
|
+
userId: keyInfo.userId,
|
|
131
232
|
activeWorkspaceId: keyInfo.workspaceId,
|
|
132
233
|
activeProjectId: null,
|
|
133
|
-
createdAt:
|
|
234
|
+
createdAt: now,
|
|
235
|
+
lastUsedAt: now,
|
|
236
|
+
get unauthorized() {
|
|
237
|
+
return authState.unauthorized;
|
|
238
|
+
},
|
|
239
|
+
set unauthorized(v: boolean) {
|
|
240
|
+
authState.unauthorized = v;
|
|
241
|
+
},
|
|
134
242
|
};
|
|
135
|
-
|
|
136
|
-
// Create per-session deps
|
|
137
|
-
const client = new HarmonyApiClient({ apiKey, apiUrl: HARMONY_API_URL });
|
|
243
|
+
sessionRef = session;
|
|
138
244
|
|
|
139
245
|
const deps: ToolDeps = {
|
|
140
246
|
getClient: () => client,
|
|
@@ -156,10 +262,13 @@ function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
|
|
|
156
262
|
|
|
157
263
|
registerHandlers(server, deps);
|
|
158
264
|
|
|
159
|
-
//
|
|
265
|
+
// Single cleanup path: fires on explicit DELETE, our evictSession,
|
|
266
|
+
// and the stale-session GC. Keeping onsessioninitialized + this onclose
|
|
267
|
+
// (instead of also wiring onsessionclosed) avoids double-logging on DELETE.
|
|
160
268
|
transport.onclose = () => {
|
|
161
269
|
if (transport.sessionId) {
|
|
162
270
|
sessions.delete(transport.sessionId);
|
|
271
|
+
console.log(`[mcp] session=${transport.sessionId} closed`);
|
|
163
272
|
}
|
|
164
273
|
};
|
|
165
274
|
|
|
@@ -208,91 +317,244 @@ app.get("/.well-known/oauth-protected-resource", (c) =>
|
|
|
208
317
|
|
|
209
318
|
// Unauthenticated 401 that advertises the OAuth metadata discovery point.
|
|
210
319
|
// Claude's `mcp add --transport http` uses this to kick off the flow.
|
|
211
|
-
|
|
320
|
+
// `errorCode` follows RFC 6750 §3 — clients can branch on `invalid_token` to
|
|
321
|
+
// trigger a refresh vs. surface a hard re-auth.
|
|
322
|
+
function unauthenticatedResponse(
|
|
323
|
+
errorCode: "missing_token" | "invalid_token" = "missing_token",
|
|
324
|
+
description?: string,
|
|
325
|
+
): Response {
|
|
326
|
+
const desc =
|
|
327
|
+
description ??
|
|
328
|
+
(errorCode === "invalid_token"
|
|
329
|
+
? "Access token is expired or revoked"
|
|
330
|
+
: "Missing or invalid access token");
|
|
331
|
+
return new Response(
|
|
332
|
+
JSON.stringify({ error: errorCode, error_description: desc }),
|
|
333
|
+
{
|
|
334
|
+
status: 401,
|
|
335
|
+
headers: {
|
|
336
|
+
"Content-Type": "application/json",
|
|
337
|
+
"WWW-Authenticate":
|
|
338
|
+
`Bearer realm="mcp", ` +
|
|
339
|
+
`error="${errorCode}", error_description="${desc}", ` +
|
|
340
|
+
`resource_metadata="${PUBLIC_MCP_URL}/.well-known/oauth-protected-resource"`,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Per MCP spec (Streamable HTTP §3 / Session Management §3-4): when a client
|
|
347
|
+
// presents an Mcp-Session-Id we don't recognize, we MUST return 404. Claude
|
|
348
|
+
// then drops the session id and re-`initialize`s. Returning anything else
|
|
349
|
+
// (e.g., transparently minting a new session) wedges the connection because
|
|
350
|
+
// the body is a `tools/call`, not `initialize`, and the SDK transport will
|
|
351
|
+
// reject it with `Server not initialized`.
|
|
352
|
+
function sessionNotFound(sessionId: string): Response {
|
|
212
353
|
return new Response(
|
|
213
354
|
JSON.stringify({
|
|
214
|
-
|
|
215
|
-
|
|
355
|
+
jsonrpc: "2.0",
|
|
356
|
+
error: { code: -32001, message: "Session not found" },
|
|
357
|
+
id: null,
|
|
216
358
|
}),
|
|
217
359
|
{
|
|
218
|
-
status:
|
|
360
|
+
status: 404,
|
|
219
361
|
headers: {
|
|
220
362
|
"Content-Type": "application/json",
|
|
221
|
-
"
|
|
363
|
+
"Mcp-Session-Id": sessionId,
|
|
222
364
|
},
|
|
223
365
|
},
|
|
224
366
|
);
|
|
225
367
|
}
|
|
226
368
|
|
|
369
|
+
// Per spec: requests without an Mcp-Session-Id (other than initialization)
|
|
370
|
+
// SHOULD return 400.
|
|
371
|
+
function sessionRequiredResponse(): Response {
|
|
372
|
+
return new Response(
|
|
373
|
+
JSON.stringify({
|
|
374
|
+
jsonrpc: "2.0",
|
|
375
|
+
error: {
|
|
376
|
+
code: -32000,
|
|
377
|
+
message:
|
|
378
|
+
"Bad Request: Mcp-Session-Id header required for non-initialize requests",
|
|
379
|
+
},
|
|
380
|
+
id: null,
|
|
381
|
+
}),
|
|
382
|
+
{
|
|
383
|
+
status: 400,
|
|
384
|
+
headers: { "Content-Type": "application/json" },
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Evict a session and tear down its transport. Used when an OAuth token
|
|
390
|
+
// rotates or is revoked mid-session — we don't want to keep a zombie session
|
|
391
|
+
// around with a stale cached api key.
|
|
392
|
+
function evictSession(sessionId: string): void {
|
|
393
|
+
const session = sessions.get(sessionId);
|
|
394
|
+
if (!session) return;
|
|
395
|
+
sessions.delete(sessionId);
|
|
396
|
+
session.transport.close().catch(() => {});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Best-effort body peek so we can route POSTs by JSON-RPC method without
|
|
400
|
+
// double-reading the body downstream (transport.handleRequest accepts
|
|
401
|
+
// `parsedBody` to skip its own json() call).
|
|
402
|
+
async function peekBody(req: Request): Promise<unknown | undefined> {
|
|
403
|
+
if (req.method !== "POST") return undefined;
|
|
404
|
+
const ct = req.headers.get("content-type") || "";
|
|
405
|
+
if (!ct.includes("application/json")) return undefined;
|
|
406
|
+
try {
|
|
407
|
+
return await req.clone().json();
|
|
408
|
+
} catch {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
227
413
|
// MCP endpoint - handles POST (JSON-RPC), GET (SSE), DELETE (session close).
|
|
228
414
|
// Mounted on both `/mcp` and `/` so clients that registered the bare host as
|
|
229
415
|
// their server URL still reach the OAuth challenge instead of a 404.
|
|
230
416
|
const handleMcpRequest = async (c: import("hono").Context) => {
|
|
231
417
|
const method = c.req.method;
|
|
418
|
+
const raw = c.req.raw;
|
|
232
419
|
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// can discover our authorization server.
|
|
420
|
+
// 1. Bearer required for everything. No token → 401 + PRM challenge so
|
|
421
|
+
// Claude's `mcp add --transport http` can kick off the OAuth dance.
|
|
236
422
|
const authHeader = c.req.header("Authorization");
|
|
237
423
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
238
|
-
return unauthenticatedResponse();
|
|
424
|
+
return unauthenticatedResponse("missing_token");
|
|
239
425
|
}
|
|
240
426
|
const apiKey = authHeader.slice(7);
|
|
241
427
|
|
|
242
|
-
// Check for existing session
|
|
243
428
|
const sessionId = c.req.header("Mcp-Session-Id");
|
|
244
429
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
430
|
+
// 2. Existing session path — auth is per-request via the bearer; the
|
|
431
|
+
// session ID is just a transport handle. Surviving token rotation here
|
|
432
|
+
// is what keeps long-lived MCP connections alive past the 1h access
|
|
433
|
+
// token TTL.
|
|
434
|
+
if (sessionId) {
|
|
435
|
+
const session = sessions.get(sessionId);
|
|
436
|
+
|
|
437
|
+
// Per MCP spec §3-4: unknown session id MUST return 404 so the client
|
|
438
|
+
// re-initializes. NEVER silently mint a new session — the body is a
|
|
439
|
+
// `tools/call`, not `initialize`, and we'd just bury the failure inside
|
|
440
|
+
// a JSON-RPC `Server not initialized` envelope. This was the bug
|
|
441
|
+
// surfacing as "Harmony MCP not responding" after a server restart or
|
|
442
|
+
// idle eviction.
|
|
443
|
+
if (!session) {
|
|
444
|
+
console.log(`[mcp] session=${sessionId} unknown → 404 re-init`);
|
|
445
|
+
return sessionNotFound(sessionId);
|
|
446
|
+
}
|
|
250
447
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
448
|
+
session.lastUsedAt = Date.now();
|
|
449
|
+
|
|
450
|
+
// Hot-swap the cached token when the OAuth client refreshed mid-session.
|
|
451
|
+
// Re-validate the new bearer and require it to belong to the same user
|
|
452
|
+
// before we accept it — otherwise a leaked session id paired with a
|
|
453
|
+
// different user's token would let an attacker ride the session.
|
|
454
|
+
if (session.apiKey !== apiKey) {
|
|
455
|
+
const fresh = await validateToken(apiKey);
|
|
456
|
+
if (!fresh) {
|
|
457
|
+
console.log(`[mcp] session=${sessionId} swap rejected: invalid token`);
|
|
458
|
+
return unauthenticatedResponse("invalid_token");
|
|
459
|
+
}
|
|
460
|
+
if (fresh.userId !== session.userId) {
|
|
461
|
+
console.warn(
|
|
462
|
+
`[mcp] session=${sessionId} swap REJECTED: ` +
|
|
463
|
+
`user mismatch (session=${session.userId} bearer=${fresh.userId})`,
|
|
464
|
+
);
|
|
465
|
+
return unauthenticatedResponse(
|
|
466
|
+
"invalid_token",
|
|
467
|
+
"Bearer does not belong to this session",
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
console.log(
|
|
471
|
+
`[mcp] session=${sessionId} token rotated user=${session.userId}`,
|
|
472
|
+
);
|
|
473
|
+
session.apiKey = apiKey;
|
|
474
|
+
session.client.setApiKey(apiKey);
|
|
257
475
|
}
|
|
258
|
-
|
|
259
|
-
|
|
476
|
+
|
|
477
|
+
// Reset the per-request 401 latch before handing off to the transport.
|
|
478
|
+
session.unauthorized = false;
|
|
479
|
+
|
|
480
|
+
const response = await session.transport.handleRequest(raw);
|
|
481
|
+
|
|
482
|
+
// If a tool call 401'd against harmony-api, the api-client tripped the
|
|
483
|
+
// unauthorized flag. Return HTTP 401 + WWW-Authenticate so the client
|
|
484
|
+
// refreshes — instead of burying the auth failure inside a JSON-RPC
|
|
485
|
+
// error envelope the client can't act on.
|
|
486
|
+
//
|
|
487
|
+
// Do NOT evict the session — per MCP spec, the session ID is
|
|
488
|
+
// independent of auth state. The next request arrives with a fresh
|
|
489
|
+
// bearer, the hot-swap above installs it, and the session continues.
|
|
490
|
+
if (session.unauthorized) {
|
|
491
|
+
console.log(`[mcp] session=${sessionId} api 401 → refresh challenge`);
|
|
492
|
+
return unauthenticatedResponse(
|
|
493
|
+
"invalid_token",
|
|
494
|
+
"Access token rejected by harmony-api",
|
|
495
|
+
);
|
|
260
496
|
}
|
|
261
497
|
|
|
262
|
-
|
|
263
|
-
|
|
498
|
+
return response;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 3. No session id — only `initialize` is allowed; everything else is 400.
|
|
502
|
+
if (method !== "POST") {
|
|
503
|
+
// GET/DELETE without a session id are nonsense.
|
|
504
|
+
return sessionRequiredResponse();
|
|
505
|
+
}
|
|
264
506
|
|
|
265
|
-
|
|
266
|
-
|
|
507
|
+
const body = await peekBody(raw);
|
|
508
|
+
if (!body || !isInitializeRequest(body)) {
|
|
509
|
+
return sessionRequiredResponse();
|
|
510
|
+
}
|
|
267
511
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
512
|
+
// 4. Initialize path — validate token, create session, hand off.
|
|
513
|
+
const keyInfo = await validateToken(apiKey);
|
|
514
|
+
if (!keyInfo) {
|
|
515
|
+
return unauthenticatedResponse("invalid_token");
|
|
516
|
+
}
|
|
517
|
+
if (keyInfo.source === "api_key" && !keyInfo.workspaceId) {
|
|
518
|
+
keyInfo.workspaceId = await resolveWorkspaceForLegacyKey(apiKey);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const session = createSession(apiKey, keyInfo);
|
|
522
|
+
await session.server.connect(session.transport);
|
|
523
|
+
|
|
524
|
+
const response = await session.transport.handleRequest(raw, {
|
|
525
|
+
parsedBody: body,
|
|
526
|
+
});
|
|
274
527
|
|
|
275
|
-
|
|
276
|
-
|
|
528
|
+
// Edge case: token revoked mid-handshake. Evict the half-built session.
|
|
529
|
+
if (session.unauthorized) {
|
|
530
|
+
if (session.transport.sessionId) evictSession(session.transport.sessionId);
|
|
531
|
+
return unauthenticatedResponse("invalid_token");
|
|
277
532
|
}
|
|
278
533
|
|
|
279
|
-
|
|
280
|
-
return c.json({ error: "Invalid or missing session" }, 404);
|
|
534
|
+
return response;
|
|
281
535
|
};
|
|
282
536
|
|
|
283
537
|
app.all("/mcp", handleMcpRequest);
|
|
284
538
|
app.all("/", handleMcpRequest);
|
|
285
539
|
|
|
540
|
+
// Exported for tests — drives the same Hono `fetch` handler that the runtime
|
|
541
|
+
// `serve()` call uses below. Tests construct synthetic Requests and assert on
|
|
542
|
+
// the Response without binding to a real port.
|
|
543
|
+
export const fetchHandler = app.fetch;
|
|
544
|
+
export { sessions as _sessionsForTests };
|
|
545
|
+
|
|
286
546
|
// ---------------------------------------------------------------------------
|
|
287
|
-
// Start server
|
|
547
|
+
// Start server (skipped when imported as a module — e.g., from tests)
|
|
288
548
|
// ---------------------------------------------------------------------------
|
|
289
|
-
|
|
549
|
+
if (import.meta.main) {
|
|
550
|
+
console.log(`Starting Harmony Remote MCP server on port ${PORT}...`);
|
|
290
551
|
|
|
291
|
-
serve({
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
});
|
|
552
|
+
serve({
|
|
553
|
+
fetch: app.fetch,
|
|
554
|
+
port: PORT,
|
|
555
|
+
});
|
|
295
556
|
|
|
296
|
-
console.log(`Harmony Remote MCP server running at http://localhost:${PORT}`);
|
|
297
|
-
console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
|
|
298
|
-
console.log(`Health check: http://localhost:${PORT}/health`);
|
|
557
|
+
console.log(`Harmony Remote MCP server running at http://localhost:${PORT}`);
|
|
558
|
+
console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
|
|
559
|
+
console.log(`Health check: http://localhost:${PORT}/health`);
|
|
560
|
+
}
|