@gethmy/mcp 2.4.6 → 2.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,25 +5,43 @@ var __getProtoOf = Object.getPrototypeOf;
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ function __accessProp(key) {
9
+ return this[key];
10
+ }
11
+ var __toESMCache_node;
12
+ var __toESMCache_esm;
8
13
  var __toESM = (mod, isNodeMode, target) => {
14
+ var canCache = mod != null && typeof mod === "object";
15
+ if (canCache) {
16
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
17
+ var cached = cache.get(mod);
18
+ if (cached)
19
+ return cached;
20
+ }
9
21
  target = mod != null ? __create(__getProtoOf(mod)) : {};
10
22
  const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
23
  for (let key of __getOwnPropNames(mod))
12
24
  if (!__hasOwnProp.call(to, key))
13
25
  __defProp(to, key, {
14
- get: () => mod[key],
26
+ get: __accessProp.bind(mod, key),
15
27
  enumerable: true
16
28
  });
29
+ if (canCache)
30
+ cache.set(mod, to);
17
31
  return to;
18
32
  };
19
33
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
+ var __returnValue = (v) => v;
35
+ function __exportSetter(name, newValue) {
36
+ this[name] = __returnValue.bind(null, newValue);
37
+ }
20
38
  var __export = (target, all) => {
21
39
  for (var name in all)
22
40
  __defProp(target, name, {
23
41
  get: all[name],
24
42
  enumerable: true,
25
43
  configurable: true,
26
- set: (newValue) => all[name] = () => newValue
44
+ set: __exportSetter.bind(all, name)
27
45
  });
28
46
  };
29
47
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -8942,7 +8960,7 @@ var require_formats = __commonJS((exports) => {
8942
8960
  }
8943
8961
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
8944
8962
  function getTime(strictTimeZone) {
8945
- return function time(str) {
8963
+ return function time3(str) {
8946
8964
  const matches = TIME.exec(str);
8947
8965
  if (!matches)
8948
8966
  return false;
@@ -27074,6 +27092,9 @@ class HarmonyApiClient {
27074
27092
  setApiKey(apiKey) {
27075
27093
  this.apiKey = apiKey;
27076
27094
  }
27095
+ getApiKey() {
27096
+ return this.apiKey;
27097
+ }
27077
27098
  async request(method, path, body, options) {
27078
27099
  await requestSemaphore.acquire();
27079
27100
  try {
package/dist/index.js CHANGED
@@ -5,25 +5,43 @@ var __getProtoOf = Object.getPrototypeOf;
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ function __accessProp(key) {
9
+ return this[key];
10
+ }
11
+ var __toESMCache_node;
12
+ var __toESMCache_esm;
8
13
  var __toESM = (mod, isNodeMode, target) => {
14
+ var canCache = mod != null && typeof mod === "object";
15
+ if (canCache) {
16
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
17
+ var cached = cache.get(mod);
18
+ if (cached)
19
+ return cached;
20
+ }
9
21
  target = mod != null ? __create(__getProtoOf(mod)) : {};
10
22
  const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
23
  for (let key of __getOwnPropNames(mod))
12
24
  if (!__hasOwnProp.call(to, key))
13
25
  __defProp(to, key, {
14
- get: () => mod[key],
26
+ get: __accessProp.bind(mod, key),
15
27
  enumerable: true
16
28
  });
29
+ if (canCache)
30
+ cache.set(mod, to);
17
31
  return to;
18
32
  };
19
33
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
+ var __returnValue = (v) => v;
35
+ function __exportSetter(name, newValue) {
36
+ this[name] = __returnValue.bind(null, newValue);
37
+ }
20
38
  var __export = (target, all) => {
21
39
  for (var name in all)
22
40
  __defProp(target, name, {
23
41
  get: all[name],
24
42
  enumerable: true,
25
43
  configurable: true,
26
- set: (newValue) => all[name] = () => newValue
44
+ set: __exportSetter.bind(all, name)
27
45
  });
28
46
  };
29
47
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -6849,7 +6867,7 @@ var require_formats = __commonJS((exports) => {
6849
6867
  }
6850
6868
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
6851
6869
  function getTime(strictTimeZone) {
6852
- return function time(str) {
6870
+ return function time3(str) {
6853
6871
  const matches = TIME.exec(str);
6854
6872
  if (!matches)
6855
6873
  return false;
@@ -24834,6 +24852,9 @@ class HarmonyApiClient {
24834
24852
  setApiKey(apiKey) {
24835
24853
  this.apiKey = apiKey;
24836
24854
  }
24855
+ getApiKey() {
24856
+ return this.apiKey;
24857
+ }
24837
24858
  async request(method, path, body, options) {
24838
24859
  await requestSemaphore.acquire();
24839
24860
  try {
@@ -1,11 +1,15 @@
1
1
  var __defProp = Object.defineProperty;
2
+ var __returnValue = (v) => v;
3
+ function __exportSetter(name, newValue) {
4
+ this[name] = __returnValue.bind(null, newValue);
5
+ }
2
6
  var __export = (target, all) => {
3
7
  for (var name in all)
4
8
  __defProp(target, name, {
5
9
  get: all[name],
6
10
  enumerable: true,
7
11
  configurable: true,
8
- set: (newValue) => all[name] = () => newValue
12
+ set: __exportSetter.bind(all, name)
9
13
  });
10
14
  };
11
15
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -1592,6 +1596,9 @@ class HarmonyApiClient {
1592
1596
  setApiKey(apiKey) {
1593
1597
  this.apiKey = apiKey;
1594
1598
  }
1599
+ getApiKey() {
1600
+ return this.apiKey;
1601
+ }
1595
1602
  async request(method, path, body, options) {
1596
1603
  await requestSemaphore.acquire();
1597
1604
  try {
@@ -1,11 +1,15 @@
1
1
  var __defProp = Object.defineProperty;
2
+ var __returnValue = (v) => v;
3
+ function __exportSetter(name, newValue) {
4
+ this[name] = __returnValue.bind(null, newValue);
5
+ }
2
6
  var __export = (target, all) => {
3
7
  for (var name in all)
4
8
  __defProp(target, name, {
5
9
  get: all[name],
6
10
  enumerable: true,
7
11
  configurable: true,
8
- set: (newValue) => all[name] = () => newValue
12
+ set: __exportSetter.bind(all, name)
9
13
  });
10
14
  };
11
15
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.4.6",
3
+ "version": "2.4.7",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Routing/auth tests for the remote MCP server (`src/remote.ts`).
3
+ *
4
+ * Covers the four session-routing branches mandated by the MCP Streamable
5
+ * HTTP spec — the regression that surfaced as "Harmony MCP not responding"
6
+ * after a server restart, OAuth refresh, or stale-session GC was a missing
7
+ * 404 branch for unknown `Mcp-Session-Id` values.
8
+ *
9
+ * These tests stub `globalThis.fetch` so we don't depend on harmony-api
10
+ * being reachable.
11
+ *
12
+ * Run with: bun test packages/mcp-server/src/__tests__/remote-routing.test.ts
13
+ */
14
+
15
+ import {
16
+ afterAll,
17
+ afterEach,
18
+ beforeAll,
19
+ beforeEach,
20
+ describe,
21
+ expect,
22
+ test,
23
+ } from "bun:test";
24
+
25
+ const ORIGINAL_FETCH = globalThis.fetch;
26
+
27
+ // Fixed values used across tests
28
+ const TEST_USER = "user-alpha";
29
+ const OTHER_USER = "user-beta";
30
+ const TEST_WORKSPACE = "ws-1";
31
+ const VALID_TOKEN = "hmy_at_alpha_valid";
32
+ const REFRESHED_TOKEN = "hmy_at_alpha_refreshed";
33
+ const OTHER_USER_TOKEN = "hmy_at_beta_valid";
34
+ const REVOKED_TOKEN = "hmy_at_revoked";
35
+
36
+ function makeFetchStub() {
37
+ return async (
38
+ input: string | URL | Request,
39
+ init?: RequestInit,
40
+ ): Promise<Response> => {
41
+ const url = typeof input === "string" ? input : input.toString();
42
+ const apiKey =
43
+ (init?.headers as Record<string, string> | undefined)?.["X-API-Key"] ??
44
+ "";
45
+
46
+ if (url.endsWith("/v1/auth/context")) {
47
+ const map: Record<
48
+ string,
49
+ { userId: string; workspaceId: string | null; source: string } | null
50
+ > = {
51
+ [VALID_TOKEN]: {
52
+ userId: TEST_USER,
53
+ workspaceId: TEST_WORKSPACE,
54
+ source: "oauth",
55
+ },
56
+ [REFRESHED_TOKEN]: {
57
+ userId: TEST_USER,
58
+ workspaceId: TEST_WORKSPACE,
59
+ source: "oauth",
60
+ },
61
+ [OTHER_USER_TOKEN]: {
62
+ userId: OTHER_USER,
63
+ workspaceId: "ws-2",
64
+ source: "oauth",
65
+ },
66
+ };
67
+ const ctx = map[apiKey];
68
+ if (!ctx) {
69
+ return new Response(JSON.stringify({ error: "unauthorized" }), {
70
+ status: 401,
71
+ headers: { "Content-Type": "application/json" },
72
+ });
73
+ }
74
+ return new Response(JSON.stringify(ctx), {
75
+ status: 200,
76
+ headers: { "Content-Type": "application/json" },
77
+ });
78
+ }
79
+
80
+ // Anything else — return a benign 404 so unrelated lookups don't blow up.
81
+ return new Response("{}", {
82
+ status: 404,
83
+ headers: { "Content-Type": "application/json" },
84
+ });
85
+ };
86
+ }
87
+
88
+ let fetchHandler: (req: Request) => Promise<Response>;
89
+ let _sessionsForTests: Map<string, unknown>;
90
+
91
+ beforeAll(async () => {
92
+ globalThis.fetch = makeFetchStub() as unknown as typeof fetch;
93
+ // Import after the stub is in place (module init is synchronous-only.)
94
+ const mod = await import("../remote.js");
95
+ fetchHandler = mod.fetchHandler as (req: Request) => Promise<Response>;
96
+ _sessionsForTests = mod._sessionsForTests as Map<string, unknown>;
97
+ });
98
+
99
+ afterAll(() => {
100
+ globalThis.fetch = ORIGINAL_FETCH;
101
+ });
102
+
103
+ beforeEach(() => {
104
+ // Refresh the fetch stub each test (preserves across the cache TTL).
105
+ globalThis.fetch = makeFetchStub() as unknown as typeof fetch;
106
+ });
107
+
108
+ afterEach(() => {
109
+ // Wipe sessions between tests so state doesn't leak.
110
+ _sessionsForTests.clear();
111
+ });
112
+
113
+ const INIT_BODY = {
114
+ jsonrpc: "2.0",
115
+ id: 1,
116
+ method: "initialize",
117
+ params: {
118
+ protocolVersion: "2025-06-18",
119
+ capabilities: {},
120
+ clientInfo: { name: "test", version: "0.0.1" },
121
+ },
122
+ };
123
+
124
+ const TOOLS_LIST_BODY = {
125
+ jsonrpc: "2.0",
126
+ id: 2,
127
+ method: "tools/list",
128
+ params: {},
129
+ };
130
+
131
+ function makePost(
132
+ body: unknown,
133
+ opts: { token?: string; sessionId?: string } = {},
134
+ ): Request {
135
+ const headers: Record<string, string> = {
136
+ "Content-Type": "application/json",
137
+ Accept: "application/json, text/event-stream",
138
+ };
139
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
140
+ if (opts.sessionId) headers["Mcp-Session-Id"] = opts.sessionId;
141
+ return new Request("http://localhost/mcp", {
142
+ method: "POST",
143
+ headers,
144
+ body: JSON.stringify(body),
145
+ });
146
+ }
147
+
148
+ describe("remote MCP routing", () => {
149
+ test("no Authorization header → 401 + WWW-Authenticate", async () => {
150
+ const res = await fetchHandler(
151
+ new Request("http://localhost/mcp", { method: "POST" }),
152
+ );
153
+ expect(res.status).toBe(401);
154
+ const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
155
+ expect(wwwAuth).toContain("Bearer");
156
+ expect(wwwAuth).toContain("resource_metadata=");
157
+ });
158
+
159
+ test("invalid bearer on initialize → 401 invalid_token", async () => {
160
+ const res = await fetchHandler(
161
+ makePost(INIT_BODY, { token: REVOKED_TOKEN }),
162
+ );
163
+ expect(res.status).toBe(401);
164
+ expect(res.headers.get("WWW-Authenticate") ?? "").toContain(
165
+ "invalid_token",
166
+ );
167
+ });
168
+
169
+ test("POST without session id and not initialize → 400", async () => {
170
+ const res = await fetchHandler(
171
+ makePost(TOOLS_LIST_BODY, { token: VALID_TOKEN }),
172
+ );
173
+ expect(res.status).toBe(400);
174
+ const body = (await res.json()) as { error: { message: string } };
175
+ expect(body.error.message).toMatch(/Mcp-Session-Id header required/i);
176
+ });
177
+
178
+ test("POST with unknown session id → 404 (forces client re-init)", async () => {
179
+ const res = await fetchHandler(
180
+ makePost(TOOLS_LIST_BODY, {
181
+ token: VALID_TOKEN,
182
+ sessionId: "ghost-session-id",
183
+ }),
184
+ );
185
+ // This is the spec-mandated behavior that fixes "Harmony MCP not responding".
186
+ expect(res.status).toBe(404);
187
+ const body = (await res.json()) as { error: { code: number } };
188
+ expect(body.error.code).toBe(-32001);
189
+ expect(res.headers.get("Mcp-Session-Id")).toBe("ghost-session-id");
190
+ });
191
+
192
+ test("initialize succeeds and registers a session", async () => {
193
+ const res = await fetchHandler(makePost(INIT_BODY, { token: VALID_TOKEN }));
194
+ expect(res.status).toBe(200);
195
+ const sid = res.headers.get("mcp-session-id");
196
+ expect(sid).toBeTruthy();
197
+ expect(_sessionsForTests.has(sid!)).toBe(true);
198
+ });
199
+
200
+ test("rotated token (same user) is accepted on hot-swap", async () => {
201
+ // Bootstrap a session.
202
+ const initRes = await fetchHandler(
203
+ makePost(INIT_BODY, { token: VALID_TOKEN }),
204
+ );
205
+ const sid = initRes.headers.get("mcp-session-id")!;
206
+
207
+ // Send a follow-up with the *new* token + same session id.
208
+ // We're not asserting on the body — just that the rotation didn't
209
+ // produce a 401/404, which it would if hot-swap were broken.
210
+ const follow = await fetchHandler(
211
+ makePost(TOOLS_LIST_BODY, {
212
+ token: REFRESHED_TOKEN,
213
+ sessionId: sid,
214
+ }),
215
+ );
216
+ expect(follow.status).not.toBe(401);
217
+ expect(follow.status).not.toBe(404);
218
+ });
219
+
220
+ test("token from a different user is REJECTED on hot-swap", async () => {
221
+ const initRes = await fetchHandler(
222
+ makePost(INIT_BODY, { token: VALID_TOKEN }),
223
+ );
224
+ const sid = initRes.headers.get("mcp-session-id")!;
225
+
226
+ // Attempt to ride the session with another user's bearer.
227
+ const hijack = await fetchHandler(
228
+ makePost(TOOLS_LIST_BODY, {
229
+ token: OTHER_USER_TOKEN,
230
+ sessionId: sid,
231
+ }),
232
+ );
233
+ expect(hijack.status).toBe(401);
234
+ const wwwAuth = hijack.headers.get("WWW-Authenticate") ?? "";
235
+ expect(wwwAuth).toContain("invalid_token");
236
+ });
237
+
238
+ test("GET without valid session id → 400", async () => {
239
+ const res = await fetchHandler(
240
+ new Request("http://localhost/mcp", {
241
+ method: "GET",
242
+ headers: {
243
+ Authorization: `Bearer ${VALID_TOKEN}`,
244
+ Accept: "text/event-stream",
245
+ },
246
+ }),
247
+ );
248
+ // GET without a session id falls into "session required" — 400.
249
+ expect(res.status).toBe(400);
250
+ });
251
+
252
+ test("GET with unknown session id → 404", async () => {
253
+ const res = await fetchHandler(
254
+ new Request("http://localhost/mcp", {
255
+ method: "GET",
256
+ headers: {
257
+ Authorization: `Bearer ${VALID_TOKEN}`,
258
+ Accept: "text/event-stream",
259
+ "Mcp-Session-Id": "ghost",
260
+ },
261
+ }),
262
+ );
263
+ expect(res.status).toBe(404);
264
+ });
265
+
266
+ test("/.well-known/oauth-protected-resource is unauthenticated", async () => {
267
+ const res = await fetchHandler(
268
+ new Request("http://localhost/.well-known/oauth-protected-resource"),
269
+ );
270
+ expect(res.status).toBe(200);
271
+ const body = (await res.json()) as {
272
+ authorization_servers: string[];
273
+ bearer_methods_supported: string[];
274
+ };
275
+ expect(body.authorization_servers.length).toBeGreaterThan(0);
276
+ expect(body.bearer_methods_supported).toContain("header");
277
+ });
278
+
279
+ test("/health is unauthenticated", async () => {
280
+ const res = await fetchHandler(new Request("http://localhost/health"));
281
+ expect(res.status).toBe(200);
282
+ const body = (await res.json()) as { status: string };
283
+ expect(body.status).toBe("ok");
284
+ });
285
+ });
package/src/api-client.ts CHANGED
@@ -149,6 +149,10 @@ export class HarmonyApiClient {
149
149
  this.apiKey = apiKey;
150
150
  }
151
151
 
152
+ getApiKey(): string {
153
+ return this.apiKey;
154
+ }
155
+
152
156
  private async request<T>(
153
157
  method: string,
154
158
  path: string,
package/src/remote.ts CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
18
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
19
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
19
20
  import { serve } from "bun";
20
21
  import { Hono } from "hono";
21
22
  import { cors } from "hono/cors";
@@ -42,12 +43,52 @@ interface TokenInfo {
42
43
  source: "api_key" | "oauth";
43
44
  }
44
45
 
46
+ // Tiny TTL cache for /v1/auth/context. A refresh storm (Claude rotates a
47
+ // token, fires several queued tool calls in parallel) would otherwise hammer
48
+ // harmony-api with identical lookups. 30s is short enough that revocation
49
+ // propagates quickly, long enough to absorb any normal burst.
50
+ const TOKEN_CACHE_TTL_MS = 30_000;
51
+ const TOKEN_CACHE_MAX = 1000;
52
+ const tokenCache = new Map<string, { info: TokenInfo; expiresAt: number }>();
53
+
54
+ async function tokenFingerprint(token: string): Promise<string> {
55
+ const data = new TextEncoder().encode(token);
56
+ const buf = await crypto.subtle.digest("SHA-256", data);
57
+ return Array.from(new Uint8Array(buf))
58
+ .map((b) => b.toString(16).padStart(2, "0"))
59
+ .join("");
60
+ }
61
+
62
+ function evictTokenCacheLru(): void {
63
+ if (tokenCache.size <= TOKEN_CACHE_MAX) return;
64
+ const oldest = tokenCache.keys().next().value;
65
+ if (oldest) tokenCache.delete(oldest);
66
+ }
67
+
45
68
  async function validateToken(token: string): Promise<TokenInfo | null> {
69
+ const fp = await tokenFingerprint(token);
70
+ const cached = tokenCache.get(fp);
71
+ const now = Date.now();
72
+ if (cached && cached.expiresAt > now) {
73
+ // Refresh LRU position
74
+ tokenCache.delete(fp);
75
+ tokenCache.set(fp, cached);
76
+ return cached.info;
77
+ }
78
+
46
79
  try {
47
80
  const response = await fetch(`${HARMONY_API_URL}/v1/auth/context`, {
48
81
  headers: { "X-API-Key": token },
49
82
  });
50
- if (!response.ok) return null;
83
+ if (!response.ok) {
84
+ // Negative-cache 401s briefly so a flood of bad tokens doesn't
85
+ // pummel harmony-api. Other status codes (5xx, network) bypass the
86
+ // cache so transient failures self-heal on next call.
87
+ if (response.status === 401) {
88
+ tokenCache.delete(fp);
89
+ }
90
+ return null;
91
+ }
51
92
 
52
93
  const data = (await response.json()) as {
53
94
  userId: string;
@@ -55,16 +96,27 @@ async function validateToken(token: string): Promise<TokenInfo | null> {
55
96
  workspaceId: string | null;
56
97
  };
57
98
  if (data.source === "jwt") return null; // JWT not allowed on MCP endpoint
58
- return {
99
+ const info: TokenInfo = {
59
100
  userId: data.userId,
60
101
  workspaceId: data.workspaceId,
61
102
  source: data.source,
62
103
  };
104
+ tokenCache.set(fp, { info, expiresAt: now + TOKEN_CACHE_TTL_MS });
105
+ evictTokenCacheLru();
106
+ return info;
63
107
  } catch {
64
108
  return null;
65
109
  }
66
110
  }
67
111
 
112
+ function invalidateTokenCache(token: string): void {
113
+ // Fire-and-forget — just clear if we already had it. Used after a bearer
114
+ // is rejected by harmony-api so the next attempt re-validates fresh.
115
+ tokenFingerprint(token)
116
+ .then((fp) => tokenCache.delete(fp))
117
+ .catch(() => {});
118
+ }
119
+
68
120
  // For legacy api_keys: fall back to workspaces list to pick the first one.
69
121
  // OAuth tokens already have workspaceId bound at grant time.
70
122
  async function resolveWorkspaceForLegacyKey(
@@ -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
- // Clean up stale sessions every 30 minutes
168
+ // Stale-session GC. Uses lastUsedAt (sliding window) instead of createdAt so
169
+ // long-lived clients that refresh OAuth tokens periodically aren't killed at
170
+ // the 1h mark just because their session was created an hour ago. 24h is an
171
+ // upper bound — clients that go truly idle that long can re-handshake cheaply.
172
+ const SESSION_IDLE_MAX_MS = 24 * 60 * 60 * 1000;
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.createdAt > maxAge) {
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: Date.now(),
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
- // Clean up session when transport closes
265
+ // Single cleanup path: fires on explicit DELETE, our evictSession,
266
+ // and the stale-session GC. Keeping onsessioninitialized + this onclose
267
+ // (instead of also wiring onsessionclosed) avoids double-logging on DELETE.
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
- function unauthenticatedResponse(): Response {
320
+ // `errorCode` follows RFC 6750 §3 — clients can branch on `invalid_token` to
321
+ // trigger a refresh vs. surface a hard re-auth.
322
+ function unauthenticatedResponse(
323
+ errorCode: "missing_token" | "invalid_token" = "missing_token",
324
+ description?: string,
325
+ ): Response {
326
+ const desc =
327
+ description ??
328
+ (errorCode === "invalid_token"
329
+ ? "Access token is expired or revoked"
330
+ : "Missing or invalid access token");
331
+ return new Response(
332
+ JSON.stringify({ error: errorCode, error_description: desc }),
333
+ {
334
+ status: 401,
335
+ headers: {
336
+ "Content-Type": "application/json",
337
+ "WWW-Authenticate":
338
+ `Bearer realm="mcp", ` +
339
+ `error="${errorCode}", error_description="${desc}", ` +
340
+ `resource_metadata="${PUBLIC_MCP_URL}/.well-known/oauth-protected-resource"`,
341
+ },
342
+ },
343
+ );
344
+ }
345
+
346
+ // Per MCP spec (Streamable HTTP §3 / Session Management §3-4): when a client
347
+ // presents an Mcp-Session-Id we don't recognize, we MUST return 404. Claude
348
+ // then drops the session id and re-`initialize`s. Returning anything else
349
+ // (e.g., transparently minting a new session) wedges the connection because
350
+ // the body is a `tools/call`, not `initialize`, and the SDK transport will
351
+ // reject it with `Server not initialized`.
352
+ function sessionNotFound(sessionId: string): Response {
238
353
  return new Response(
239
354
  JSON.stringify({
240
- error: "unauthorized",
241
- error_description: "Missing or invalid access token",
355
+ jsonrpc: "2.0",
356
+ error: { code: -32001, message: "Session not found" },
357
+ id: null,
242
358
  }),
243
359
  {
244
- status: 401,
360
+ status: 404,
245
361
  headers: {
246
362
  "Content-Type": "application/json",
247
- "WWW-Authenticate": `Bearer realm="mcp", resource_metadata="${PUBLIC_MCP_URL}/.well-known/oauth-protected-resource"`,
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
- // Extract bearer token. Accept OAuth access tokens (hmy_at_) and legacy
270
- // integration keys (hmy_). No token 401 with WWW-Authenticate so Claude
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
- if (sessionId && sessions.has(sessionId)) {
282
- const session = sessions.get(sessionId)!;
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 if the OAuth client just refreshed. Without
285
- // this the session would keep using the stale access token forever and
286
- // every tool call after refresh would 401 the bug that motivated this
287
- // patch.
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(c.req.raw);
480
+ const response = await session.transport.handleRequest(raw);
297
481
 
298
- // If a tool call hit 401 against harmony-api, the api-client tripped the
299
- // unauthorized flag. Evict the session and return an HTTP 401 +
300
- // WWW-Authenticate so the client triggers a refresh instead of burying
301
- // the auth failure inside a JSON-RPC error envelope the client can't act
302
- // on.
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
- evictSession(sessionId);
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
- if (method === "POST") {
312
- // Validate the token. OAuth tokens carry workspaceId; legacy keys don't,
313
- // so we look up workspaces in a follow-up call for those.
314
- const keyInfo = await validateToken(apiKey);
315
- if (!keyInfo) {
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
- // Connect server to transport
326
- await session.server.connect(session.transport);
507
+ const body = await peekBody(raw);
508
+ if (!body || !isInitializeRequest(body)) {
509
+ return sessionRequiredResponse();
510
+ }
327
511
 
328
- // Store session once transport has a session ID
329
- const origOnSessionInitialized = session.transport._onsessioninitialized;
330
- session.transport._onsessioninitialized = (sid: string) => {
331
- sessions.set(sid, session);
332
- origOnSessionInitialized?.(sid);
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
- const response = await session.transport.handleRequest(c.req.raw);
521
+ const session = createSession(apiKey, keyInfo);
522
+ await session.server.connect(session.transport);
336
523
 
337
- // Same 401-latch check as the existing-session branch — covers the case
338
- // where the *initialize* call itself triggers an API request that 401s
339
- // (e.g., revoked-during-handshake).
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
- return response;
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
- // GET or DELETE without a valid session
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
- console.log(`Starting Harmony Remote MCP server on port ${PORT}...`);
549
+ if (import.meta.main) {
550
+ console.log(`Starting Harmony Remote MCP server on port ${PORT}...`);
359
551
 
360
- serve({
361
- fetch: app.fetch,
362
- port: PORT,
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
+ }