@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/dist/cli.js +50 -9
- package/dist/index.js +50 -9
- package/dist/lib/api-client.js +8 -0
- package/package.json +4 -3
- package/src/api-client.ts +76 -5
- package/src/auto-session.ts +25 -6
- package/src/remote.ts +616 -266
- package/src/server.ts +96 -6
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.
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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]
|
|
431
|
+
`[mcp] context key=${key} reaped (idle); user=${userId} scope kept`,
|
|
211
432
|
);
|
|
212
|
-
|
|
213
|
-
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
214
435
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
//
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
getActiveProjectId: () =>
|
|
258
|
-
getActiveWorkspaceId: () =>
|
|
521
|
+
ctx.sweepDeps = {
|
|
522
|
+
...baseDeps(ctx.userId),
|
|
523
|
+
getClient: () => ctx.sweepClient,
|
|
524
|
+
getActiveProjectId: () => ctx.activeProjectId,
|
|
525
|
+
getActiveWorkspaceId: () => ctx.activeWorkspaceId,
|
|
259
526
|
setActiveProject: (id) => {
|
|
260
|
-
|
|
527
|
+
ctx.activeProjectId = id;
|
|
261
528
|
},
|
|
262
529
|
setActiveWorkspace: (id) => {
|
|
263
|
-
|
|
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
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
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,
|
|
567
|
+
await runEndSessionPipeline(endClient, ctx.sweepDeps, cardId, status);
|
|
290
568
|
},
|
|
291
|
-
() =>
|
|
292
|
-
|
|
293
|
-
|
|
569
|
+
() => (liveContextForUser(ctx.userId) ?? ctx).sweepClient,
|
|
570
|
+
() => clientIdentities.get(ctx.contextKey) ?? null,
|
|
571
|
+
ctx.userId,
|
|
294
572
|
);
|
|
295
573
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
|
570
|
-
|
|
893
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
894
|
+
sessionIdGenerator: undefined,
|
|
895
|
+
enableJsonResponse: true,
|
|
571
896
|
});
|
|
572
897
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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 {
|
|
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)
|