@gethmy/mcp 2.20.1 → 2.22.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/src/remote.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Remote MCP Server for Harmony
4
+ * Remote MCP Server for Harmony — stateless Streamable HTTP
5
5
  *
6
6
  * Hosted MCP endpoint that any AI agent can connect to via HTTP.
7
7
  * Auth via API key passed as Bearer token, validated against the Harmony API.
@@ -12,6 +12,107 @@
12
12
  * Env vars:
13
13
  * HARMONY_API_URL - Harmony API base URL (default: https://app.gethmy.com/api)
14
14
  * PORT - Listen port (default: 3002)
15
+ *
16
+ * ## Why stateless (card #772)
17
+ *
18
+ * This server used to run the transport in *stateful* mode: `initialize` minted
19
+ * an `Mcp-Session-Id`, and a process-local `Map<sessionId, McpSession>` held the
20
+ * transport, `Server`, and API client for the life of the connection. That made
21
+ * the connection a dependency: a redeploy or restart of the Railway container
22
+ * dropped every session, and each client's next `tools/call` had to be answered
23
+ * with a spec-mandated 404 so it would re-`initialize` before it could work
24
+ * again. Session state was also a scaling ceiling — a second instance behind the
25
+ * same hostname cannot serve a session another instance minted.
26
+ *
27
+ * Now every HTTP request stands on its own:
28
+ *
29
+ * - `sessionIdGenerator: undefined` puts the SDK transport in stateless mode. No
30
+ * session id is ever issued.
31
+ * - Because we issue none, *every* `Mcp-Session-Id` is by definition unknown, so
32
+ * a non-initialize request carrying one is answered 404 (Streamable HTTP
33
+ * §Session Management). That is a constant, not a lookup — no state is
34
+ * consulted. It costs a client that handshook against the old stateful server
35
+ * exactly one re-`initialize`, and buys back the handshake this server needs
36
+ * for agent attribution (see `clientIdentities`). After it, no client ever
37
+ * sends a session id again.
38
+ * - A fresh `Server` + transport is built per request. The SDK *requires* this:
39
+ * reusing a stateless transport throws ("Stateless transport cannot be reused
40
+ * across requests"), because JSON-RPC ids from different clients would collide
41
+ * in its stream map. Construction is cheap — `registerHandlers` only installs
42
+ * handlers over the module-level `TOOLS` table.
43
+ * - `initialize` is no longer a precondition. The SDK's `Server` gates on
44
+ * declared capabilities, not on having seen a handshake, so a cold
45
+ * `tools/list` or `tools/call` is served directly.
46
+ *
47
+ * ## What is still remembered, and why that is not session state
48
+ *
49
+ * `harmony_set_project_context` has to mean something on the *next* request, and
50
+ * ~28 tools default a missing `projectId`/`workspaceId` from it. So a small
51
+ * `UserContext` is kept, keyed by **OAuth grant** (`contextKey`, #774) — not by
52
+ * connection, not by session id:
53
+ *
54
+ * - `activeWorkspaceId` is re-derivable from the bearer alone (`/v1/auth/context`
55
+ * returns the grant's primary workspace), so it is re-seeded from the token on
56
+ * every request until an explicit `harmony_set_workspace_context` pins it.
57
+ * - `activeProjectId` has no server-side home today, so it lives here. Losing it
58
+ * on restart is a strict improvement on the old behaviour, which lost it too —
59
+ * *and* wedged the client with a 404 until it re-handshook.
60
+ * - One `auto-session` scope per **user** — deliberately coarser than the
61
+ * context that registers it, because it governs which tracked sessions a card
62
+ * switch ends, and one human working a card from two clients wants one session
63
+ * on it, not two (see `getScopeId`). It gets a long-lived `sweepClient`
64
+ * because the sweep fires minutes after any request.
65
+ *
66
+ * None of it is required to *serve* a request: an unknown grant is built from the
67
+ * bearer on the spot. It is remembered convenience, not a session.
68
+ *
69
+ * ### What is emphatically NOT shared: the API client
70
+ *
71
+ * `HarmonyApiClient.apiKey` is mutable and read at fetch time, so a client
72
+ * shared between requests can have its bearer swapped mid-flight by a sibling.
73
+ * For one user holding two OAuth grants that is a boundary break, not a
74
+ * nuisance: a request begun under grant A can complete under grant B's bearer,
75
+ * and `harmony-api`'s `isOAuthWorkspaceMismatch` — "the ONLY tenant boundary in
76
+ * the request" (#695) — sees a bearer that legitimately covers the workspace and
77
+ * lets it through. So every request builds its own client bound to the bearer
78
+ * that authenticated it, and reads workspace/project from a snapshot taken at
79
+ * request start (`buildRequestScope`). Nothing a sibling does can move either
80
+ * out from under an in-flight call.
81
+ *
82
+ * ### Why the key is the grant, not the user (#774)
83
+ *
84
+ * A stateless request carries exactly one identifier — the bearer — and a bearer
85
+ * rotates on refresh, so it cannot key anything long-lived. `userId` can, but it
86
+ * is *too coarse*: two MCP clients signed in as the same user would share what a
87
+ * per-connection server kept apart. `activeProjectId` was the visible symptom —
88
+ * Claude Desktop calling `harmony_set_project_context(P2)` moved where Claude
89
+ * Code's next `harmony_create_card` landed — and `clientIdentities` the quieter
90
+ * one, last-initializer-wins, so work auto-started by one client could be
91
+ * attributed to the other's name.
92
+ *
93
+ * Each client consents separately and so holds its own OAuth grant, which makes
94
+ * the grant exactly the right granularity: finer than the user, and — unlike the
95
+ * bearer — stable across rotation, because `oauth_tokens.token_family_id` is
96
+ * minted once at consent and carried through every refresh. `/v1/auth/context`
97
+ * returns it as `grantId`, and `contextKey` keys both maps on it, falling back
98
+ * to `userId` when there is no grant (legacy api_key, or an older harmony-api).
99
+ *
100
+ * Note what this was never about: authorization. The bearer and the workspace
101
+ * were already per-request, so `isOAuthWorkspaceMismatch` — "the ONLY tenant
102
+ * boundary in the request" (#695) — held before this change and holds after.
103
+ * What moved is correctness and attribution.
104
+ *
105
+ * Residual, and deliberate: the auto-session *scope* stays per user (see
106
+ * `getScopeId`), so its sessions can span two grants while it has only one
107
+ * client to end them with. The inactivity sweep uses the user's most recently
108
+ * used context (`liveContextForUser`); the reaper's final drain has no live
109
+ * context left to pick and falls back to whichever registered last. Either way,
110
+ * if that bearer's grant doesn't cover the swept card's workspace, harmony-api
111
+ * rejects the end call and `cleanup_stale_agent_sessions` closes the row once its
112
+ * grace elapses — 2h for these, since an MCP auto-session carries no `agent_id`
113
+ * and so takes `AGENT_SWEEP_INTERACTIVE_MS`, not the daemon's 30min (#771).
114
+ * Ending on the right bearer means tracking one per tracked session —
115
+ * deliberately out of scope here, and mitigated by that cron.
15
116
  */
16
117
 
17
118
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -22,6 +123,7 @@ import { Hono } from "hono";
22
123
  import { cors } from "hono/cors";
23
124
  import { HarmonyApiClient } from "./api-client.js";
24
125
  import {
126
+ type ClientInfo,
25
127
  dropScope,
26
128
  initAutoSession,
27
129
  shutdownAllSessions,
@@ -50,12 +152,21 @@ interface TokenInfo {
50
152
  userId: string;
51
153
  workspaceId: string | null;
52
154
  source: "api_key" | "oauth";
155
+ /**
156
+ * The OAuth grant this bearer belongs to, or null when there isn't one to
157
+ * have: a legacy `hmy_*` api_key, or a harmony-api deployed before #774.
158
+ *
159
+ * Unlike the bearer it survives refresh rotation, which is what makes it a
160
+ * usable key for remembered state. See `contextKey`.
161
+ */
162
+ grantId: string | null;
53
163
  }
54
164
 
55
- // Tiny TTL cache for /v1/auth/context. A refresh storm (Claude rotates a
56
- // token, fires several queued tool calls in parallel) would otherwise hammer
57
- // harmony-api with identical lookups. 30s is short enough that revocation
58
- // propagates quickly, long enough to absorb any normal burst.
165
+ // Tiny TTL cache for /v1/auth/context. Load-bearing now that the transport is
166
+ // stateless: there is no session to inherit trust from, so EVERY request
167
+ // validates its bearer. Without the cache, a client firing a burst of parallel
168
+ // tool calls would hammer harmony-api with identical lookups. 30s is short
169
+ // enough that revocation propagates quickly, long enough to absorb any burst.
59
170
  const TOKEN_CACHE_TTL_MS = 30_000;
60
171
  const TOKEN_CACHE_MAX = 1000;
61
172
  const tokenCache = new Map<string, { info: TokenInfo; expiresAt: number }>();
@@ -103,12 +214,20 @@ async function validateToken(token: string): Promise<TokenInfo | null> {
103
214
  userId: string;
104
215
  source: "api_key" | "oauth" | "jwt";
105
216
  workspaceId: string | null;
217
+ /**
218
+ * Optional on purpose (#774). This server and harmony-api deploy
219
+ * independently, so a running instance can be talking to an API that
220
+ * doesn't send the field yet — `contextKey` then falls back to `userId`
221
+ * and behaviour is exactly what it was before.
222
+ */
223
+ grantId?: string | null;
106
224
  };
107
225
  if (data.source === "jwt") return null; // JWT not allowed on MCP endpoint
108
226
  const info: TokenInfo = {
109
227
  userId: data.userId,
110
228
  workspaceId: data.workspaceId,
111
229
  source: data.source,
230
+ grantId: data.grantId ?? null,
112
231
  };
113
232
  tokenCache.set(fp, { info, expiresAt: now + TOKEN_CACHE_TTL_MS });
114
233
  evictTokenCacheLru();
@@ -146,178 +265,406 @@ async function resolveWorkspaceForLegacyKey(
146
265
  }
147
266
 
148
267
  // ---------------------------------------------------------------------------
149
- // Session management
268
+ // The key remembered state lives under
269
+ // ---------------------------------------------------------------------------
270
+ /**
271
+ * `contextKey` — the OAuth grant, falling back to the user.
272
+ *
273
+ * Each MCP client consents separately and therefore holds its own grant, which
274
+ * makes the grant the natural boundary for "what this client remembers". And
275
+ * unlike the bearer — which rotates on every refresh — a grant id is stable for
276
+ * the grant's whole life, so it can key a long-lived map.
277
+ *
278
+ * The fallback covers the two cases with no grant to speak of: a legacy `hmy_*`
279
+ * api_key, and the deploy window where harmony-api hasn't shipped `grantId` yet.
280
+ * Both then collapse to one context per user, i.e. exactly the pre-#774
281
+ * behaviour.
282
+ *
283
+ * Deliberately NOT the auto-session scope id, which stays per-user — see
284
+ * `getScopeId` in `baseDeps` for why those two want different granularity.
285
+ */
286
+ function contextKey(keyInfo: TokenInfo): string {
287
+ return keyInfo.grantId ?? keyInfo.userId;
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Remembered MCP client identity (outlives a context reap)
150
292
  // ---------------------------------------------------------------------------
151
- interface McpSession {
152
- transport: WebStandardStreamableHTTPServerTransport;
153
- server: Server;
154
- client: HarmonyApiClient;
155
- apiKey: string;
156
- // Bound at session creation. Re-checked on every token hot-swap so a leaked
157
- // session ID can't be paired with a different user's bearer to ride someone
158
- // else's session.
293
+ /**
294
+ * `contextKey` → the `clientInfo` from that grant's last `initialize`.
295
+ *
296
+ * Kept OUTSIDE `UserContext` on purpose. A stateless `tools/call` carries no
297
+ * handshake, so `Server.getClientVersion()` is undefined there and auto-session
298
+ * would refuse to start a session rather than fabricate an "Unknown Agent" one
299
+ * (card #295, `auto-session.ts`). Identity therefore has to be remembered from
300
+ * the handshake — and remembered for longer than the context that reads it,
301
+ * because a grant reaped after 60min idle whose client is still running would
302
+ * otherwise come back anonymous and silently stop getting auto-sessions.
303
+ *
304
+ * Keyed per grant, not per user (#774): two clients signed in as one user each
305
+ * handshake under their own grant, so keying by user made this
306
+ * last-initializer-wins and let work auto-started by one be attributed to the
307
+ * other's name.
308
+ *
309
+ * Two short strings per grant, so the cap is generous; entries never expire.
310
+ */
311
+ const CLIENT_IDENTITY_MAX = 5000;
312
+ const clientIdentities = new Map<string, ClientInfo>();
313
+
314
+ function rememberClientIdentity(key: string, info: ClientInfo): void {
315
+ // Refresh LRU position, then bound the map.
316
+ clientIdentities.delete(key);
317
+ clientIdentities.set(key, info);
318
+ if (clientIdentities.size > CLIENT_IDENTITY_MAX) {
319
+ const oldest = clientIdentities.keys().next().value;
320
+ if (oldest) clientIdentities.delete(oldest);
321
+ }
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Per-grant context (keyed by `contextKey` — NOT per connection, NOT a session)
326
+ // ---------------------------------------------------------------------------
327
+ interface UserContext {
159
328
  userId: string;
329
+ /** The `userContexts` key this context lives under (see `contextKey`). */
330
+ contextKey: string;
331
+ /**
332
+ * The OAuth grant, or null for a legacy api_key / a pre-#774 harmony-api — in
333
+ * which case `contextKey` is the userId and this is one context per user.
334
+ */
335
+ grantId: string | null;
336
+ /**
337
+ * Client for the auto-session sweep ONLY — never for serving a request.
338
+ *
339
+ * The sweep ends idle sessions minutes after the request that registered the
340
+ * scope, so it needs a bearer that outlives any one request; its key is
341
+ * refreshed from the most recent request. Request handlers must NOT share it:
342
+ * `HarmonyApiClient.apiKey` is mutable and read at fetch time, so a sibling
343
+ * request rotating the key mid-flight would send its bearer on this request's
344
+ * call. Between two OAuth grants of one user that silently crosses the grant
345
+ * boundary `harmony-api` enforces, which is why every request builds its own
346
+ * client bound to the bearer that authenticated it.
347
+ */
348
+ sweepClient: HarmonyApiClient;
349
+ /** Stable deps for the sweep's end-of-session pipeline. Reads `ctx` directly. */
350
+ sweepDeps: ToolDeps;
160
351
  activeWorkspaceId: string | null;
161
352
  activeProjectId: string | null;
162
- createdAt: number;
163
- // Bumped on every request that touches the session. Drives stale-session GC
164
- // so a session that's actively rotating tokens stays alive past the access
165
- // token TTL auth happens per-request via the Bearer header, the session
166
- // itself is just a transport handle.
353
+ /**
354
+ * True once `harmony_set_workspace_context` pinned the workspace. Until then
355
+ * each request derives its workspace from its own token, so a second OAuth
356
+ * grant scoped to a different workspace isn't stuck on the first grant's.
357
+ */
358
+ workspacePinned: boolean;
167
359
  lastUsedAt: number;
168
- // Set by HarmonyApiClient.onUnauthorized when the API rejects the cached
169
- // token mid-session. The HTTP layer reads this after transport.handleRequest
170
- // returns and converts the response to an HTTP 401 challenge so the OAuth
171
- // client refreshes instead of caching a JSON-RPC error forever.
172
- unauthorized: boolean;
173
360
  }
174
361
 
175
- const sessions = new Map<string, McpSession>();
176
-
177
- // Stale-session GC. Uses lastUsedAt (sliding window) instead of createdAt so
178
- // long-lived clients that refresh OAuth tokens periodically aren't killed at
179
- // the 1h mark just because their session was created an hour ago. 24h is an
180
- // upper bound clients that go truly idle that long can re-handshake cheaply.
181
- const SESSION_IDLE_MAX_MS = 24 * 60 * 60 * 1000;
182
- setInterval(
183
- () => {
184
- const now = Date.now();
185
- for (const [id, session] of sessions) {
186
- if (now - session.lastUsedAt > SESSION_IDLE_MAX_MS) {
187
- session.transport.close().catch(() => {});
188
- sessions.delete(id);
189
- }
190
- }
191
- },
192
- 30 * 60 * 1000,
193
- );
362
+ /**
363
+ * A single request's view of the context.
364
+ *
365
+ * Reads are snapshotted at request start and writes go to both the snapshot and
366
+ * the shared context, so a concurrent sibling cannot change the workspace or
367
+ * project a request is already running against. Without this, a request begun
368
+ * under grant A could read a workspace another grant re-seeded mid-flight —
369
+ * harmony-api would reject the mismatch, but the request would fail for a reason
370
+ * that has nothing to do with the caller.
371
+ */
372
+ interface RequestContextView {
373
+ workspaceId: string | null;
374
+ projectId: string | null;
375
+ }
194
376
 
195
- function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
196
- // unauthorized flag lives on a mutable holder so the HarmonyApiClient
197
- // callback can flip it without a circular reference between the client and
198
- // the session struct it lives on.
199
- const authState = { unauthorized: false };
377
+ const userContexts = new Map<string, UserContext>();
200
378
 
201
- // Forward declare so onsessioninitialized can register the session.
202
- let sessionRef: McpSession;
379
+ /**
380
+ * The most-recently-used live context for a user, or null if they have none.
381
+ *
382
+ * Needed because `userContexts` is keyed per grant while the auto-session scope
383
+ * is per user, so one scope can be backed by several contexts. Both of the
384
+ * scope's long-lived hooks — the sweep's client and the end-of-session deps —
385
+ * resolve through this rather than closing over the context that happened to
386
+ * register them: that context may be reaped, or its bearer may have gone stale
387
+ * while a sibling grant is the one still making requests.
388
+ */
389
+ function liveContextForUser(userId: string): UserContext | null {
390
+ let best: UserContext | null = null;
391
+ for (const candidate of userContexts.values()) {
392
+ if (candidate.userId !== userId) continue;
393
+ if (!best || candidate.lastUsedAt > best.lastUsedAt) best = candidate;
394
+ }
395
+ return best;
396
+ }
203
397
 
204
- const transport = new WebStandardStreamableHTTPServerTransport({
205
- sessionIdGenerator: () => crypto.randomUUID(),
206
- enableJsonResponse: true,
207
- onsessioninitialized: (sid: string) => {
208
- sessions.set(sid, sessionRef);
398
+ /**
399
+ * Idle reaper for `userContexts`.
400
+ *
401
+ * The stateful server dropped a user's auto-session scope on `transport.onclose`
402
+ * — its signal that the last connection went away. Stateless has no close event,
403
+ * so without this the `userContexts` and `auto-session` `scopes` maps would grow
404
+ * one entry per distinct user forever, and any session still tracked would
405
+ * dangle "working" (the shape of card #301, Gap 1).
406
+ *
407
+ * 60 minutes sits above both auto-session bounds (10min inactivity end, 25min
408
+ * heartbeat window), so we only reap users the sweep has already finished with.
409
+ * The `cleanup_stale_agent_sessions` cron may have closed a dangling row before
410
+ * we get here; `shutdownAllSessions` is best-effort, so that race costs nothing.
411
+ * (Its grace for these is `AGENT_SWEEP_INTERACTIVE_MS` = 2h — an MCP
412
+ * auto-session has no `agent_id`, so the 30min daemon window never applies.
413
+ * Corrected after #771 widened it; the pairing this comment used to cite is the
414
+ * bug that card fixed.)
415
+ */
416
+ const CONTEXT_IDLE_MAX_MS = 60 * 60 * 1000;
417
+ const CONTEXT_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
418
+
419
+ export function reapIdleContexts(now: number = Date.now()): void {
420
+ for (const [key, ctx] of userContexts) {
421
+ if (now - ctx.lastUsedAt <= CONTEXT_IDLE_MAX_MS) continue;
422
+ userContexts.delete(key);
423
+ const { userId } = ctx;
424
+
425
+ // The auto-session scope is per USER while contexts are per grant, so a
426
+ // user holding two grants has two contexts over one scope. Tearing it down
427
+ // for an idle grant would end the sessions the *active* grant is still
428
+ // working — so leave it to whichever context is reaped last.
429
+ if (liveContextForUser(userId)) {
209
430
  console.log(
210
- `[mcp] session=${sid} init user=${keyInfo.userId} src=${keyInfo.source}`,
431
+ `[mcp] context key=${key} reaped (idle); user=${userId} scope kept`,
211
432
  );
212
- },
213
- });
433
+ continue;
434
+ }
214
435
 
215
- const server = new Server(
216
- { name: "harmony-mcp-remote", version: "1.0.0" },
217
- { capabilities: { tools: {}, resources: {} } },
218
- );
436
+ // Pause anything still tracked, THEN drop the scope so the shared sweep
437
+ // stops walking it. Order matters — dropping first would orphan those
438
+ // sessions with no client left to end them.
439
+ shutdownAllSessions(userId)
440
+ .catch(() => {})
441
+ .finally(() => {
442
+ // `shutdownAllSessions` makes network round-trips, so a request can
443
+ // arrive meanwhile and build a fresh context — which registers the
444
+ // scope again. Dropping it then would leave `getOrCreateScope` to
445
+ // lazily recreate an empty one with null getters, and since
446
+ // `initAutoSession` now runs only on context creation, that user would
447
+ // get no auto-sessions until the next reap. Only drop what we reaped —
448
+ // and a rebuild under *any* of the user's grants counts.
449
+ if (!liveContextForUser(userId)) dropScope(userId);
450
+ });
451
+ console.log(`[mcp] context key=${key} user=${userId} reaped (idle)`);
452
+ }
453
+ }
219
454
 
220
- // Create per-session API client. onUnauthorized fires when harmony-api
221
- // returns 401 — we mark the session so the HTTP layer can surface a real
222
- // 401 + WWW-Authenticate challenge to the OAuth client.
223
- const client = new HarmonyApiClient({
455
+ const contextSweepTimer = setInterval(
456
+ () => reapIdleContexts(),
457
+ CONTEXT_SWEEP_INTERVAL_MS,
458
+ );
459
+ // Bookkeeping only — never a reason to hold the process open. Without this,
460
+ // importing this module from a script or test runner hangs on exit.
461
+ contextSweepTimer.unref?.();
462
+
463
+ /** Deps shared by both paths. `getClient` and the context accessors differ. */
464
+ function baseDeps(
465
+ userId: string,
466
+ ): Omit<
467
+ ToolDeps,
468
+ | "getClient"
469
+ | "getActiveProjectId"
470
+ | "getActiveWorkspaceId"
471
+ | "setActiveProject"
472
+ | "setActiveWorkspace"
473
+ > {
474
+ return {
475
+ isConfigured: () => true,
476
+ getApiUrl: () => HARMONY_API_URL,
477
+ getMemoryDir: () => null, // No local filesystem in remote mode
478
+ getUserEmail: () => null,
479
+ saveConfig: () => {}, // No-op in remote mode
480
+ resetClient: () => {}, // No-op in remote mode
481
+ // Partition auto-session bookkeeping by user. The hosted server is
482
+ // multi-tenant: keying by userId stops two users on the same card from
483
+ // colliding on one shared auto-session entry (card #301, Gap 2).
484
+ //
485
+ // Deliberately still the USER, not the grant, even though remembered
486
+ // context moved to the grant in #774. This scope decides which tracked
487
+ // sessions a card switch ends and which ones the sweep can close, and both
488
+ // are properties of the person, not the client: one human working a card
489
+ // from two clients should hold ONE auto-session on it — attributed to
490
+ // whichever client started it — rather than two rows racing each other.
491
+ // Attribution is what needed per-grant granularity, and it gets it from
492
+ // `getClientInfo` below.
493
+ getScopeId: () => userId,
494
+ };
495
+ }
496
+
497
+ function createUserContext(apiKey: string, keyInfo: TokenInfo): UserContext {
498
+ const sweepClient = new HarmonyApiClient({
224
499
  apiKey,
225
500
  apiUrl: HARMONY_API_URL,
226
501
  onUnauthorized: () => {
227
- authState.unauthorized = true;
228
- // Drop any cached "OK" entry for this bearer so future validations
229
- // re-hit harmony-api and see the rejection.
230
- invalidateTokenCache(client.getApiKey());
502
+ // No request to challenge — the sweep runs on a timer. Just drop the
503
+ // cached "OK" for this bearer so the next validation re-hits harmony-api.
504
+ invalidateTokenCache(sweepClient.getApiKey());
231
505
  },
232
506
  });
233
507
 
234
- const now = Date.now();
235
- const session: McpSession = {
236
- transport,
237
- server,
238
- client,
239
- apiKey,
508
+ const ctx: UserContext = {
240
509
  userId: keyInfo.userId,
510
+ contextKey: contextKey(keyInfo),
511
+ grantId: keyInfo.grantId,
512
+ sweepClient,
513
+ // Replaced immediately below — deps must close over the finished object.
514
+ sweepDeps: null as unknown as ToolDeps,
241
515
  activeWorkspaceId: keyInfo.workspaceId,
242
516
  activeProjectId: null,
243
- createdAt: now,
244
- lastUsedAt: now,
245
- get unauthorized() {
246
- return authState.unauthorized;
247
- },
248
- set unauthorized(v: boolean) {
249
- authState.unauthorized = v;
250
- },
517
+ workspacePinned: false,
518
+ lastUsedAt: Date.now(),
251
519
  };
252
- sessionRef = session;
253
520
 
254
- const deps: ToolDeps = {
255
- getClient: () => client,
256
- isConfigured: () => true,
257
- getActiveProjectId: () => session.activeProjectId,
258
- getActiveWorkspaceId: () => session.activeWorkspaceId,
521
+ ctx.sweepDeps = {
522
+ ...baseDeps(ctx.userId),
523
+ getClient: () => ctx.sweepClient,
524
+ getActiveProjectId: () => ctx.activeProjectId,
525
+ getActiveWorkspaceId: () => ctx.activeWorkspaceId,
259
526
  setActiveProject: (id) => {
260
- session.activeProjectId = id;
527
+ ctx.activeProjectId = id;
261
528
  },
262
529
  setActiveWorkspace: (id) => {
263
- session.activeWorkspaceId = id;
530
+ ctx.activeWorkspaceId = id;
531
+ ctx.workspacePinned = true;
264
532
  },
265
- getApiUrl: () => HARMONY_API_URL,
266
- getMemoryDir: () => null, // No local filesystem in remote mode
267
- getUserEmail: () => null,
268
- saveConfig: () => {}, // No-op in remote mode
269
- resetClient: () => {}, // No-op in remote mode
270
- // Partition auto-session bookkeeping by user. The hosted server is
271
- // multi-tenant: keying by userId stops two users on the same card from
272
- // colliding on one shared auto-session entry (card #301, Gap 2).
273
- getScopeId: () => keyInfo.userId,
274
533
  };
275
534
 
276
- registerHandlers(server, deps);
277
-
278
535
  // Wire the inactivity sweep + end-of-session pipeline for this user's scope.
279
- // The remote transport historically skipped initAutoSession entirely, so
280
- // hosted auto-sessions never auto-ended on inactivity and the end pipeline
281
- // never fired — they dangled "working" indefinitely (card #301, Gap 1).
282
- // Now each connection registers its userId scope; the shared process timer
283
- // sweeps it like stdio. Identity is resolved per-request via options.clientInfo
284
- // in the pre-hook (card #297), so no clientInfoGetter is supplied here. Re-init
285
- // by a second connection of the same user just refreshes the scope's client
286
- // both are equivalent, and per-scope state means it can't clobber other users.
536
+ // `clientInfoGetter` is what keeps agent attribution working now that a
537
+ // stateless `tools/call` carries no handshake to read identity from (#297).
538
+ //
539
+ // A second grant of the same user re-runs this for the SAME scope, and
540
+ // `initAutoSession` overwrites the callbacks it was given (last one wins). So
541
+ // the client getter must not close over `ctx`: this context can be reaped, or
542
+ // sit idle with a bearer nobody is refreshing, while the sibling grant is the
543
+ // one still working. Resolving through `liveContextForUser` per call keeps the
544
+ // sweep on the most recently used context instead.
545
+ //
546
+ // Known gap, and why it is left alone: in the reaper's teardown branch the
547
+ // user has no live context by construction, so the drain falls back to `ctx` —
548
+ // whichever context registered last, not necessarily the one whose grant owns
549
+ // the sessions being drained. Every candidate bearer is 60min+ idle there and
550
+ // may cover the wrong workspace, in which case harmony-api rejects the end
551
+ // call and `cleanup_stale_agent_sessions` closes the row after its 2h
552
+ // interactive grace (#771). Picking correctly would mean tracking a bearer per
553
+ // tracked session, which is a larger change than this card; see the residual
554
+ // note in the module doc.
555
+ //
556
+ // The identity getter is the one hook that must NOT resolve through
557
+ // `liveContextForUser` — "who is calling" is per grant, and a scope-level
558
+ // getter can only answer for one of them. It stays pinned to this context's
559
+ // own key as a best-effort fallback; the authoritative per-request answer is
560
+ // the `getClientInfo` dep in `buildRequestScope`, which `trackActivity`
561
+ // prefers over this.
287
562
  initAutoSession(
563
+ // `runEndSessionPipeline` ignores its deps argument today (it works purely
564
+ // off the client), so this passes the registering context's — no resolution
565
+ // to do until that signature starts reading them.
288
566
  async (endClient, cardId, status) => {
289
- await runEndSessionPipeline(endClient, deps, cardId, status);
567
+ await runEndSessionPipeline(endClient, ctx.sweepDeps, cardId, status);
290
568
  },
291
- () => client,
292
- undefined,
293
- keyInfo.userId,
569
+ () => (liveContextForUser(ctx.userId) ?? ctx).sweepClient,
570
+ () => clientIdentities.get(ctx.contextKey) ?? null,
571
+ ctx.userId,
294
572
  );
295
573
 
296
- // Single cleanup path: fires on explicit DELETE, our evictSession,
297
- // and the stale-session GC. Keeping onsessioninitialized + this onclose
298
- // (instead of also wiring onsessionclosed) avoids double-logging on DELETE.
299
- transport.onclose = () => {
300
- if (transport.sessionId) {
301
- sessions.delete(transport.sessionId);
302
- // Reap this user's auto-session scope once their LAST live connection is
303
- // gone — otherwise `scopes` grows unbounded (one stale client/deps closure
304
- // per distinct user) on a long-lived multi-tenant process. A user with
305
- // another open connection keeps the scope (sessions.delete already ran, so
306
- // the closing session isn't counted). Pause any in-flight auto-sessions so
307
- // nothing dangles "working"; a reconnect + next tool call starts fresh.
308
- const stillLive = [...sessions.values()].some(
309
- (s) => s.userId === session.userId,
310
- );
311
- if (!stillLive) {
312
- shutdownAllSessions(session.userId)
313
- .catch(() => {})
314
- .finally(() => dropScope(session.userId));
315
- }
316
- console.log(`[mcp] session=${transport.sessionId} closed`);
317
- }
574
+ userContexts.set(ctx.contextKey, ctx);
575
+ console.log(
576
+ `[mcp] context key=${ctx.contextKey} user=${ctx.userId} created src=${keyInfo.source} grant=${ctx.grantId ?? "none"}`,
577
+ );
578
+ return ctx;
579
+ }
580
+
581
+ /**
582
+ * Build the client, view, and deps for one request.
583
+ *
584
+ * The client is bound to the bearer that authenticated *this* request and is
585
+ * never shared, so no sibling can swap the key out from under an in-flight
586
+ * fetch. `onUnauthorized` therefore latches directly on this request — no
587
+ * cross-request attribution problem to solve.
588
+ */
589
+ function buildRequestScope(
590
+ ctx: UserContext,
591
+ apiKey: string,
592
+ keyInfo: TokenInfo,
593
+ ): { deps: ToolDeps; authState: { unauthorized: boolean } } {
594
+ const authState = { unauthorized: false };
595
+
596
+ const client = new HarmonyApiClient({
597
+ apiKey,
598
+ apiUrl: HARMONY_API_URL,
599
+ onUnauthorized: () => {
600
+ authState.unauthorized = true;
601
+ invalidateTokenCache(apiKey);
602
+ },
603
+ });
604
+
605
+ // Snapshot. An unpinned workspace comes from THIS request's grant, so two
606
+ // grants of one user never borrow each other's scope.
607
+ const view: RequestContextView = {
608
+ workspaceId: ctx.workspacePinned
609
+ ? ctx.activeWorkspaceId
610
+ : (keyInfo.workspaceId ?? ctx.activeWorkspaceId),
611
+ projectId: ctx.activeProjectId,
612
+ };
613
+
614
+ const deps: ToolDeps = {
615
+ ...baseDeps(ctx.userId),
616
+ getClient: () => client,
617
+ // Identity of the client on the other end of THIS request — i.e. of this
618
+ // grant. `trackActivity` prefers it over the scope-level getter, which one
619
+ // user's second grant would otherwise have overwritten (#774).
620
+ getClientInfo: () => clientIdentities.get(ctx.contextKey) ?? null,
621
+ getActiveProjectId: () => view.projectId,
622
+ getActiveWorkspaceId: () => view.workspaceId,
623
+ // Write through: the snapshot keeps this request consistent, the context
624
+ // carries the selection to the next one.
625
+ setActiveProject: (id) => {
626
+ view.projectId = id;
627
+ ctx.activeProjectId = id;
628
+ },
629
+ setActiveWorkspace: (id) => {
630
+ view.workspaceId = id;
631
+ ctx.activeWorkspaceId = id;
632
+ // An explicit choice outranks the token's primary workspace from here on.
633
+ ctx.workspacePinned = true;
634
+ },
318
635
  };
319
636
 
320
- return session;
637
+ return { deps, authState };
638
+ }
639
+
640
+ async function resolveUserContext(
641
+ apiKey: string,
642
+ keyInfo: TokenInfo,
643
+ ): Promise<UserContext> {
644
+ const existing = userContexts.get(contextKey(keyInfo));
645
+ if (existing) {
646
+ existing.lastUsedAt = Date.now();
647
+ // Keep the sweep's bearer as fresh as the newest request. Safe to mutate
648
+ // because nothing serving a request reads this client — request handlers
649
+ // get their own, bound to their own bearer (see `buildRequestScope`).
650
+ existing.sweepClient.setApiKey(apiKey);
651
+ // Remember this token's grant workspace as the default for the *next*
652
+ // request that has none of its own. The request in hand doesn't read this
653
+ // — it derives its workspace from its own token in `buildRequestScope`.
654
+ if (!existing.workspacePinned && keyInfo.workspaceId) {
655
+ existing.activeWorkspaceId = keyInfo.workspaceId;
656
+ }
657
+ return existing;
658
+ }
659
+
660
+ // Legacy api_keys carry no workspace binding; fall back to the first
661
+ // workspace. Only on first sight of a user — an explicit
662
+ // harmony_set_workspace_context must not be re-seeded out from under them.
663
+ if (keyInfo.source === "api_key" && !keyInfo.workspaceId) {
664
+ keyInfo.workspaceId = await resolveWorkspaceForLegacyKey(apiKey);
665
+ }
666
+
667
+ return createUserContext(apiKey, keyInfo);
321
668
  }
322
669
 
323
670
  // ---------------------------------------------------------------------------
@@ -333,10 +680,12 @@ app.use(
333
680
  allowHeaders: [
334
681
  "Content-Type",
335
682
  "Authorization",
683
+ // Still allowed so a browser client that handshook against the old
684
+ // stateful server can deliver its stale id and get the 404 that tells it
685
+ // to re-initialize, rather than being blocked by CORS preflight.
336
686
  "Mcp-Session-Id",
337
687
  "Mcp-Protocol-Version",
338
688
  ],
339
- exposeHeaders: ["Mcp-Session-Id"],
340
689
  }),
341
690
  );
342
691
 
@@ -345,7 +694,8 @@ app.get("/health", (c) =>
345
694
  c.json({
346
695
  status: "ok",
347
696
  service: "harmony-mcp-remote",
348
- sessions: sessions.size,
697
+ mode: "stateless",
698
+ contexts: userContexts.size,
349
699
  }),
350
700
  );
351
701
 
@@ -388,12 +738,21 @@ function unauthenticatedResponse(
388
738
  );
389
739
  }
390
740
 
391
- // Per MCP spec (Streamable HTTP §3 / Session Management §3-4): when a client
392
- // presents an Mcp-Session-Id we don't recognize, we MUST return 404. Claude
393
- // then drops the session id and re-`initialize`s. Returning anything else
394
- // (e.g., transparently minting a new session) wedges the connection because
395
- // the body is a `tools/call`, not `initialize`, and the SDK transport will
396
- // reject it with `Server not initialized`.
741
+ /**
742
+ * 404 for a non-initialize request that presents an `Mcp-Session-Id`.
743
+ *
744
+ * Streamable HTTP §Session Management: a session id the server does not
745
+ * recognise MUST be answered 404 so the client starts a new session. We issue
746
+ * none, so none is ever recognised — this is a constant, not a lookup, and
747
+ * consults no state.
748
+ *
749
+ * Only reachable from a client that handshook against the old stateful server.
750
+ * It costs that client one re-`initialize`, which is exactly what we want: the
751
+ * handshake is the only place `clientInfo` appears, and without it auto-session
752
+ * refuses to attribute work (see `clientIdentities`). Ignoring the header
753
+ * instead would leave every already-connected client anonymous — and therefore
754
+ * without auto-sessions — for as long as its process stayed alive.
755
+ */
397
756
  function sessionNotFound(sessionId: string): Response {
398
757
  return new Response(
399
758
  JSON.stringify({
@@ -411,41 +770,33 @@ function sessionNotFound(sessionId: string): Response {
411
770
  );
412
771
  }
413
772
 
414
- // Per spec: requests without an Mcp-Session-Id (other than initialization)
415
- // SHOULD return 400.
416
- function sessionRequiredResponse(): Response {
773
+ /**
774
+ * 405 for the methods a stateless endpoint has nothing to do with.
775
+ *
776
+ * Streamable HTTP §2.2 explicitly sanctions this: a server that does not offer a
777
+ * server-initiated SSE stream answers GET with 405, and one that does not
778
+ * support client-terminated sessions answers DELETE with 405. Both hold here —
779
+ * responses are complete JSON (`enableJsonResponse`), and there is no session to
780
+ * terminate. Clients treat both as optional and carry on.
781
+ */
782
+ function methodNotAllowed(message: string): Response {
417
783
  return new Response(
418
784
  JSON.stringify({
419
785
  jsonrpc: "2.0",
420
- error: {
421
- code: -32000,
422
- message:
423
- "Bad Request: Mcp-Session-Id header required for non-initialize requests",
424
- },
786
+ error: { code: -32000, message },
425
787
  id: null,
426
788
  }),
427
789
  {
428
- status: 400,
429
- headers: { "Content-Type": "application/json" },
790
+ status: 405,
791
+ headers: { "Content-Type": "application/json", Allow: "POST" },
430
792
  },
431
793
  );
432
794
  }
433
795
 
434
- // Evict a session and tear down its transport. Used when an OAuth token
435
- // rotates or is revoked mid-session — we don't want to keep a zombie session
436
- // around with a stale cached api key.
437
- function evictSession(sessionId: string): void {
438
- const session = sessions.get(sessionId);
439
- if (!session) return;
440
- sessions.delete(sessionId);
441
- session.transport.close().catch(() => {});
442
- }
443
-
444
- // Best-effort body peek so we can route POSTs by JSON-RPC method without
796
+ // Best-effort body peek so we can read `initialize` client identity without
445
797
  // double-reading the body downstream (transport.handleRequest accepts
446
798
  // `parsedBody` to skip its own json() call).
447
799
  async function peekBody(req: Request): Promise<unknown | undefined> {
448
- if (req.method !== "POST") return undefined;
449
800
  const ct = req.headers.get("content-type") || "";
450
801
  if (!ct.includes("application/json")) return undefined;
451
802
  try {
@@ -455,11 +806,22 @@ async function peekBody(req: Request): Promise<unknown | undefined> {
455
806
  }
456
807
  }
457
808
 
458
- // MCP endpoint - handles POST (JSON-RPC), GET (SSE), DELETE (session close).
459
- // Mounted on both `/mcp` and `/` so clients that registered the bare host as
460
- // their server URL still reach the OAuth challenge instead of a 404.
809
+ /** Pull `clientInfo` out of a validated `initialize` request body. */
810
+ function extractClientInfo(body: unknown): ClientInfo | null {
811
+ const params = (body as { params?: { clientInfo?: unknown } }).params;
812
+ const info = params?.clientInfo as
813
+ | { name?: unknown; version?: unknown }
814
+ | undefined;
815
+ if (!info || typeof info.name !== "string" || !info.name) return null;
816
+ return {
817
+ name: info.name,
818
+ version: typeof info.version === "string" ? info.version : undefined,
819
+ };
820
+ }
821
+
822
+ // MCP endpoint. Mounted on both `/mcp` and `/` so clients that registered the
823
+ // bare host as their server URL still reach the OAuth challenge, not a 404.
461
824
  const handleMcpRequest = async (c: import("hono").Context) => {
462
- const method = c.req.method;
463
825
  const raw = c.req.raw;
464
826
 
465
827
  // 1. Bearer required for everything. No token → 401 + PRM challenge so
@@ -470,113 +832,98 @@ const handleMcpRequest = async (c: import("hono").Context) => {
470
832
  }
471
833
  const apiKey = authHeader.slice(7);
472
834
 
473
- const sessionId = c.req.header("Mcp-Session-Id");
474
-
475
- // 2. Existing session path — auth is per-request via the bearer; the
476
- // session ID is just a transport handle. Surviving token rotation here
477
- // is what keeps long-lived MCP connections alive past the 1h access
478
- // token TTL.
479
- if (sessionId) {
480
- const session = sessions.get(sessionId);
481
-
482
- // Per MCP spec §3-4: unknown session id MUST return 404 so the client
483
- // re-initializes. NEVER silently mint a new session — the body is a
484
- // `tools/call`, not `initialize`, and we'd just bury the failure inside
485
- // a JSON-RPC `Server not initialized` envelope. This was the bug
486
- // surfacing as "Harmony MCP not responding" after a server restart or
487
- // idle eviction.
488
- if (!session) {
489
- console.log(`[mcp] session=${sessionId} unknown → 404 re-init`);
490
- return sessionNotFound(sessionId);
491
- }
492
-
493
- session.lastUsedAt = Date.now();
494
-
495
- // Hot-swap the cached token when the OAuth client refreshed mid-session.
496
- // Re-validate the new bearer and require it to belong to the same user
497
- // before we accept it — otherwise a leaked session id paired with a
498
- // different user's token would let an attacker ride the session.
499
- if (session.apiKey !== apiKey) {
500
- const fresh = await validateToken(apiKey);
501
- if (!fresh) {
502
- console.log(`[mcp] session=${sessionId} swap rejected: invalid token`);
503
- return unauthenticatedResponse("invalid_token");
504
- }
505
- if (fresh.userId !== session.userId) {
506
- console.warn(
507
- `[mcp] session=${sessionId} swap REJECTED: ` +
508
- `user mismatch (session=${session.userId} bearer=${fresh.userId})`,
509
- );
510
- return unauthenticatedResponse(
511
- "invalid_token",
512
- "Bearer does not belong to this session",
513
- );
514
- }
515
- console.log(
516
- `[mcp] session=${sessionId} token rotated user=${session.userId}`,
517
- );
518
- session.apiKey = apiKey;
519
- session.client.setApiKey(apiKey);
520
- }
521
-
522
- // Reset the per-request 401 latch before handing off to the transport.
523
- session.unauthorized = false;
524
-
525
- const response = await session.transport.handleRequest(raw);
526
-
527
- // If a tool call 401'd against harmony-api, the api-client tripped the
528
- // unauthorized flag. Return HTTP 401 + WWW-Authenticate so the client
529
- // refreshes — instead of burying the auth failure inside a JSON-RPC
530
- // error envelope the client can't act on.
531
- //
532
- // Do NOT evict the session — per MCP spec, the session ID is
533
- // independent of auth state. The next request arrives with a fresh
534
- // bearer, the hot-swap above installs it, and the session continues.
535
- if (session.unauthorized) {
536
- console.log(`[mcp] session=${sessionId} api 401 → refresh challenge`);
537
- return unauthenticatedResponse(
538
- "invalid_token",
539
- "Access token rejected by harmony-api",
540
- );
541
- }
542
-
543
- return response;
835
+ // 2. POST is the whole protocol here — see methodNotAllowed().
836
+ if (raw.method === "GET") {
837
+ return methodNotAllowed(
838
+ "Method Not Allowed: this endpoint does not offer an SSE stream",
839
+ );
544
840
  }
545
-
546
- // 3. No session id — only `initialize` is allowed; everything else is 400.
547
- if (method !== "POST") {
548
- // GET/DELETE without a session id are nonsense.
549
- return sessionRequiredResponse();
841
+ if (raw.method === "DELETE") {
842
+ return methodNotAllowed(
843
+ "Method Not Allowed: stateless endpoint has no session to terminate",
844
+ );
550
845
  }
551
-
552
- const body = await peekBody(raw);
553
- if (!body || !isInitializeRequest(body)) {
554
- return sessionRequiredResponse();
846
+ if (raw.method !== "POST") {
847
+ return methodNotAllowed(`Method Not Allowed: ${raw.method}`);
555
848
  }
556
849
 
557
- // 4. Initialize path validate token, create session, hand off.
850
+ // 3. Validate the bearer on EVERY request. Stateless means there is no session
851
+ // to inherit trust from — the token is the only credential, and the userId
852
+ // it resolves to is what selects the context below. Cached for 30s, so a
853
+ // burst of parallel tool calls costs one lookup.
558
854
  const keyInfo = await validateToken(apiKey);
559
855
  if (!keyInfo) {
560
856
  return unauthenticatedResponse("invalid_token");
561
857
  }
562
- if (keyInfo.source === "api_key" && !keyInfo.workspaceId) {
563
- keyInfo.workspaceId = await resolveWorkspaceForLegacyKey(apiKey);
858
+
859
+ const body = await peekBody(raw);
860
+ const isInit = body !== undefined && isInitializeRequest(body);
861
+
862
+ // 4. A session id we could not have issued → 404, so the client re-handshakes
863
+ // once and we learn who it is. Skipped for `initialize` itself, which is
864
+ // already the handshake (and shouldn't be carrying one).
865
+ const sessionId = c.req.header("Mcp-Session-Id");
866
+ if (sessionId && !isInit) {
867
+ console.log(`[mcp] session=${sessionId} presented → 404 re-init`);
868
+ return sessionNotFound(sessionId);
869
+ }
870
+
871
+ const ctx = await resolveUserContext(apiKey, keyInfo);
872
+
873
+ // Remember who is calling. Only `initialize` carries clientInfo, so this is
874
+ // the single chance to learn the identity auto-sessions attribute work to.
875
+ if (isInit) {
876
+ const info = extractClientInfo(body);
877
+ if (info) rememberClientIdentity(ctx.contextKey, info);
564
878
  }
565
879
 
566
- const session = createSession(apiKey, keyInfo);
567
- await session.server.connect(session.transport);
880
+ // 5. Everything this request runs against — its own client bound to its own
881
+ // bearer, and a snapshot of the context it must not see change mid-flight.
882
+ const { deps, authState } = buildRequestScope(ctx, apiKey, keyInfo);
883
+
884
+ // 6. Fresh Server + transport per request. Mandatory in stateless mode: the
885
+ // SDK throws if a stateless transport handles a second request, because
886
+ // JSON-RPC ids from unrelated clients would collide in its stream map.
887
+ const server = new Server(
888
+ { name: "harmony-mcp-remote", version: "1.0.0" },
889
+ { capabilities: { tools: {}, resources: {} } },
890
+ );
891
+ registerHandlers(server, deps);
568
892
 
569
- const response = await session.transport.handleRequest(raw, {
570
- parsedBody: body,
893
+ const transport = new WebStandardStreamableHTTPServerTransport({
894
+ sessionIdGenerator: undefined,
895
+ enableJsonResponse: true,
571
896
  });
572
897
 
573
- // Edge case: token revoked mid-handshake. Evict the half-built session.
574
- if (session.unauthorized) {
575
- if (session.transport.sessionId) evictSession(session.transport.sessionId);
576
- return unauthenticatedResponse("invalid_token");
577
- }
898
+ try {
899
+ await server.connect(transport);
900
+ // `enableJsonResponse` resolves with a fully-materialised JSON Response once
901
+ // every request in the body has been answered — nothing is still streaming
902
+ // when this returns, so the teardown below is safe.
903
+ const response = await transport.handleRequest(
904
+ raw,
905
+ body !== undefined ? { parsedBody: body } : undefined,
906
+ );
907
+
908
+ // A tool call that 401'd against harmony-api tripped this request's own
909
+ // latch (the client is not shared, so it can only be ours). Answer with
910
+ // HTTP 401 + WWW-Authenticate so the client refreshes, instead of burying
911
+ // the auth failure in a JSON-RPC envelope it can't act on.
912
+ if (authState.unauthorized) {
913
+ console.log(`[mcp] user=${ctx.userId} api 401 → refresh challenge`);
914
+ return unauthenticatedResponse(
915
+ "invalid_token",
916
+ "Access token rejected by harmony-api",
917
+ );
918
+ }
578
919
 
579
- return response;
920
+ return response;
921
+ } finally {
922
+ // Closes the transport too (Protocol.close forwards). The user's context,
923
+ // sweep client, and auto-session scope survive — only the request-scoped
924
+ // protocol machinery and its client are torn down.
925
+ await server.close().catch(() => {});
926
+ }
580
927
  };
581
928
 
582
929
  app.all("/mcp", handleMcpRequest);
@@ -586,7 +933,10 @@ app.all("/", handleMcpRequest);
586
933
  // `serve()` call uses below. Tests construct synthetic Requests and assert on
587
934
  // the Response without binding to a real port.
588
935
  export const fetchHandler = app.fetch;
589
- export { sessions as _sessionsForTests };
936
+ export {
937
+ userContexts as _userContextsForTests,
938
+ clientIdentities as _clientIdentitiesForTests,
939
+ };
590
940
 
591
941
  // ---------------------------------------------------------------------------
592
942
  // Start server (skipped when imported as a module — e.g., from tests)