@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/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) return null;
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
- return {
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
- // Clean up stale sessions every 30 minutes
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.createdAt > maxAge) {
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: Date.now(),
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
- // Clean up session when transport closes
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
- function unauthenticatedResponse(): Response {
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
- error: "unauthorized",
215
- error_description: "Missing or invalid access token",
355
+ jsonrpc: "2.0",
356
+ error: { code: -32001, message: "Session not found" },
357
+ id: null,
216
358
  }),
217
359
  {
218
- status: 401,
360
+ status: 404,
219
361
  headers: {
220
362
  "Content-Type": "application/json",
221
- "WWW-Authenticate": `Bearer realm="mcp", resource_metadata="${PUBLIC_MCP_URL}/.well-known/oauth-protected-resource"`,
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
- // Extract bearer token. Accept OAuth access tokens (hmy_at_) and legacy
234
- // integration keys (hmy_). No token 401 with WWW-Authenticate so Claude
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
- if (sessionId && sessions.has(sessionId)) {
246
- // Existing session - forward request
247
- const session = sessions.get(sessionId)!;
248
- return session.transport.handleRequest(c.req.raw);
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
- if (method === "POST") {
252
- // Validate the token. OAuth tokens carry workspaceId; legacy keys don't,
253
- // so we look up workspaces in a follow-up call for those.
254
- const keyInfo = await validateToken(apiKey);
255
- if (!keyInfo) {
256
- return unauthenticatedResponse();
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
- if (keyInfo.source === "api_key" && !keyInfo.workspaceId) {
259
- keyInfo.workspaceId = await resolveWorkspaceForLegacyKey(apiKey);
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
- // Create new session
263
- const session = createSession(apiKey, keyInfo);
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
- // Connect server to transport
266
- await session.server.connect(session.transport);
507
+ const body = await peekBody(raw);
508
+ if (!body || !isInitializeRequest(body)) {
509
+ return sessionRequiredResponse();
510
+ }
267
511
 
268
- // Store session once transport has a session ID
269
- const origOnSessionInitialized = session.transport._onsessioninitialized;
270
- session.transport._onsessioninitialized = (sid: string) => {
271
- sessions.set(sid, session);
272
- origOnSessionInitialized?.(sid);
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
- // Handle the initialize request
276
- return session.transport.handleRequest(c.req.raw);
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
- // GET or DELETE without a valid session
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
- console.log(`Starting Harmony Remote MCP server on port ${PORT}...`);
549
+ if (import.meta.main) {
550
+ console.log(`Starting Harmony Remote MCP server on port ${PORT}...`);
290
551
 
291
- serve({
292
- fetch: app.fetch,
293
- port: PORT,
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
+ }