@gethmy/mcp 2.4.7 → 2.5.1

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.
@@ -1,285 +0,0 @@
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
- });