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