@zereight/mcp-gitlab 2.1.46 → 2.1.48

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.
@@ -0,0 +1,38 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "../../server/request-helpers.js";
4
+ describe("streamable request helpers", () => {
5
+ test("redacts stateless session ids and truncates legacy ids", () => {
6
+ assert.strictEqual(redactSessionIdForLog(undefined), "<none>");
7
+ assert.strictEqual(redactSessionIdForLog("v1.sid.secret-payload"), "v1.sid.<redacted>");
8
+ assert.strictEqual(redactSessionIdForLog("123456789abcdef"), "12345678…");
9
+ assert.strictEqual(redactSessionIdForLog("short"), "short");
10
+ });
11
+ test("detects initialize requests in single and batch bodies", () => {
12
+ assert.strictEqual(isInitializationRequestBody({ method: "initialize" }), true);
13
+ assert.strictEqual(isInitializationRequestBody([{ method: "tools/list" }, { method: "initialize" }]), true);
14
+ assert.strictEqual(isInitializationRequestBody({ method: "tools/list" }), false);
15
+ assert.strictEqual(isInitializationRequestBody(null), false);
16
+ });
17
+ test("allows only discovery methods in unauthenticated discovery bodies", () => {
18
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody({ method: "tools/list" }), true);
19
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
20
+ { method: "initialize" },
21
+ { method: "notifications/initialized" },
22
+ { method: "tools/list" },
23
+ ]), true);
24
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([{ method: "tools/list" }, { method: "call_tool" }]), false);
25
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody(undefined), false);
26
+ });
27
+ test("normalizes Mcp-Session-Id headers", () => {
28
+ assert.strictEqual(readMcpSessionIdHeader({ headers: { "mcp-session-id": "sid-1" } }), "sid-1");
29
+ assert.strictEqual(readMcpSessionIdHeader({ headers: { "mcp-session-id": "" } }), undefined);
30
+ assert.strictEqual(readMcpSessionIdHeader({ headers: { "mcp-session-id": ["sid-1", "sid-2"] } }), undefined);
31
+ assert.strictEqual(readMcpSessionIdHeader({ headers: {} }), undefined);
32
+ });
33
+ test("joins duplicate Accept headers so text/event-stream is not dropped", () => {
34
+ assert.strictEqual(readAcceptHeader({ headers: { accept: "application/json, text/event-stream" } }), "application/json, text/event-stream");
35
+ assert.strictEqual(readAcceptHeader({ headers: { accept: ["application/json", "text/event-stream"] } }), "application/json, text/event-stream");
36
+ assert.strictEqual(readAcceptHeader({ headers: {} }), "");
37
+ });
38
+ });
@@ -290,6 +290,83 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
290
290
  const wrongVerifier = randomBytes(32).toString("base64url");
291
291
  await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
292
292
  });
293
+ test("failed PKCE does not burn the code; legitimate exchange still succeeds", async () => {
294
+ const s = secret();
295
+ const pod = makeProvider(loadMaterial(s));
296
+ const registered = await pod.clientsStore.registerClient({
297
+ redirect_uris: [CLIENT_REDIRECT],
298
+ token_endpoint_auth_method: "none",
299
+ });
300
+ const correctVerifier = randomBytes(32).toString("base64url");
301
+ const correctChallenge = createHash("sha256")
302
+ .update(correctVerifier)
303
+ .digest("base64url");
304
+ const authorizeRes = makeAuthorizeRes();
305
+ await pod.authorize(registered, {
306
+ state: "s",
307
+ scopes: ["api"],
308
+ redirectUri: CLIENT_REDIRECT,
309
+ codeChallenge: correctChallenge,
310
+ }, authorizeRes);
311
+ const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
312
+ const cbRes = makeFakeRes();
313
+ await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
314
+ const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
315
+ const wrongVerifier = randomBytes(32).toString("base64url");
316
+ await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
317
+ const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, correctVerifier, CLIENT_REDIRECT);
318
+ assert.equal(tokens.access_token, "gl-access-123");
319
+ });
320
+ test("successful exchange then replay is rejected on the same pod", async () => {
321
+ const s = secret();
322
+ const pod = makeProvider(loadMaterial(s));
323
+ const registered = await pod.clientsStore.registerClient({
324
+ redirect_uris: [CLIENT_REDIRECT],
325
+ token_endpoint_auth_method: "none",
326
+ });
327
+ const verifier = randomBytes(32).toString("base64url");
328
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
329
+ const authorizeRes = makeAuthorizeRes();
330
+ await pod.authorize(registered, {
331
+ state: "s",
332
+ scopes: ["api"],
333
+ redirectUri: CLIENT_REDIRECT,
334
+ codeChallenge: challenge,
335
+ }, authorizeRes);
336
+ const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
337
+ const cbRes = makeFakeRes();
338
+ await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
339
+ const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
340
+ const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
341
+ assert.equal(tokens.access_token, "gl-access-123");
342
+ await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT), /Authorization code already used/);
343
+ });
344
+ test("exchangeAuthorizationCode reports busy when replay cache is full", async () => {
345
+ const s = secret();
346
+ const pod = makeProvider(loadMaterial(s));
347
+ const registered = await pod.clientsStore.registerClient({
348
+ redirect_uris: [CLIENT_REDIRECT],
349
+ token_endpoint_auth_method: "none",
350
+ });
351
+ const verifier = randomBytes(32).toString("base64url");
352
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
353
+ const authorizeRes = makeAuthorizeRes();
354
+ await pod.authorize(registered, {
355
+ state: "s",
356
+ scopes: ["api"],
357
+ redirectUri: CLIENT_REDIRECT,
358
+ codeChallenge: challenge,
359
+ }, authorizeRes);
360
+ const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
361
+ const cbRes = makeFakeRes();
362
+ await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
363
+ const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
364
+ const cache = pod._usedProxyCodes;
365
+ for (let i = 0; i < cache.maxSize; i++) {
366
+ cache.tryReserve(`fill-${i}`, 600);
367
+ }
368
+ await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT), /Authorization server busy/);
369
+ });
293
370
  test("exchangeAuthorizationCode rejects wrong client_id", async () => {
294
371
  const s = secret();
295
372
  const pod = makeProvider(loadMaterial(s));
@@ -316,6 +393,9 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
316
393
  const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
317
394
  // Try to redeem as r2 (different client_id)
318
395
  await assert.rejects(() => pod.exchangeAuthorizationCode(r2, proxyCode, verifier, CLIENT_REDIRECT), /Invalid client for authorization code/);
396
+ // Failed binding must not burn the code for the legitimate client.
397
+ const tokens = await pod.exchangeAuthorizationCode(r1, proxyCode, verifier, CLIENT_REDIRECT);
398
+ assert.equal(tokens.access_token, "gl-access-123");
319
399
  });
320
400
  test("exchangeAuthorizationCode rejects wrong redirect_uri", async () => {
321
401
  const s = secret();
@@ -338,6 +418,9 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
338
418
  await pod.handleCallback(fakeReq({ code: "g", state: proxyState }), cbRes);
339
419
  const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
340
420
  await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, "https://attacker.example/cb"), /Invalid redirect_uri for authorization code/);
421
+ // Failed binding must not burn the code for the legitimate client.
422
+ const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
423
+ assert.equal(tokens.access_token, "gl-access-123");
341
424
  });
342
425
  test("stale state rejected at /callback", async () => {
343
426
  const s = secret();
@@ -373,6 +456,32 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
373
456
  const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
374
457
  assert.equal(tokens.access_token, "gl-access-123");
375
458
  });
459
+ test("legacy: failed PKCE does not burn the code; legitimate exchange still succeeds", async () => {
460
+ const pod = makeProvider(null);
461
+ const registered = await pod.clientsStore.registerClient({
462
+ redirect_uris: [CLIENT_REDIRECT],
463
+ token_endpoint_auth_method: "none",
464
+ });
465
+ const correctVerifier = randomBytes(32).toString("base64url");
466
+ const correctChallenge = createHash("sha256")
467
+ .update(correctVerifier)
468
+ .digest("base64url");
469
+ const aRes = makeAuthorizeRes();
470
+ await pod.authorize(registered, {
471
+ state: "s",
472
+ scopes: ["api"],
473
+ redirectUri: CLIENT_REDIRECT,
474
+ codeChallenge: correctChallenge,
475
+ }, aRes);
476
+ const legacyState = new URL(aRes.redirectedTo).searchParams.get("state");
477
+ const cbRes = makeFakeRes();
478
+ await pod.handleCallback(fakeReq({ code: "g", state: legacyState }), cbRes);
479
+ const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
480
+ const wrongVerifier = randomBytes(32).toString("base64url");
481
+ await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
482
+ const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, correctVerifier, CLIENT_REDIRECT);
483
+ assert.equal(tokens.access_token, "gl-access-123");
484
+ });
376
485
  test("different secret on pod B rejects the sealed state", async () => {
377
486
  const podA = makeProvider(loadMaterial(secret()));
378
487
  const podB = makeProvider(loadMaterial(secret())); // different secret
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Unit tests for ConsumedProxyCodeCache — TTL-aware replay prevention.
3
+ *
4
+ * Expectation (CWE-294): consumed hashes remain rejected until the configured
5
+ * code TTL expires; the cache never LRU-evicts early; inserts fail closed when
6
+ * still-valid entries fill capacity.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, test } from "node:test";
10
+ import { ConsumedProxyCodeCache, PROXY_CODE_CACHE_FULL, } from "../../stateless/consumed-proxy-code-cache.js";
11
+ function reserveId(cache, key, ttlSeconds) {
12
+ const result = cache.tryReserve(key, ttlSeconds);
13
+ assert.equal(result.ok, true);
14
+ if (!result.ok) {
15
+ throw new Error("expected reservation to succeed");
16
+ }
17
+ return result.reservationId;
18
+ }
19
+ describe("ConsumedProxyCodeCache", () => {
20
+ test("rejects replay of a committed hash for the full configured TTL", () => {
21
+ let now = 1_000_000;
22
+ const cache = new ConsumedProxyCodeCache(10, () => now);
23
+ const ttlSeconds = 120;
24
+ const id = reserveId(cache, "hash-a", ttlSeconds);
25
+ cache.commit("hash-a", id);
26
+ // Still within TTL — must reject as consumed.
27
+ now += (ttlSeconds - 1) * 1000;
28
+ assert.deepEqual(cache.tryReserve("hash-a", ttlSeconds), {
29
+ ok: false,
30
+ reason: "consumed",
31
+ });
32
+ // Exactly at expiry boundary — still rejected while expiresAt > now.
33
+ now += 999;
34
+ assert.deepEqual(cache.tryReserve("hash-a", ttlSeconds), {
35
+ ok: false,
36
+ reason: "consumed",
37
+ });
38
+ // After TTL — purged; a new reservation is allowed (sealed code itself
39
+ // would also be expired by openStoredTokensCode at this point).
40
+ now += 1;
41
+ assert.equal(cache.tryReserve("hash-a", ttlSeconds).ok, true);
42
+ });
43
+ test("never LRU-evicts a live entry; fails closed when full", () => {
44
+ const now = 5_000_000;
45
+ const maxSize = 3;
46
+ const cache = new ConsumedProxyCodeCache(maxSize, () => now);
47
+ const ttlSeconds = 600;
48
+ cache.commit("h1", reserveId(cache, "h1", ttlSeconds));
49
+ cache.commit("h2", reserveId(cache, "h2", ttlSeconds));
50
+ cache.commit("h3", reserveId(cache, "h3", ttlSeconds));
51
+ assert.equal(cache.size, 3);
52
+ // A fourth insert must fail closed — not silently drop h1.
53
+ assert.throws(() => cache.tryReserve("h4", ttlSeconds), (err) => {
54
+ return err.message === PROXY_CODE_CACHE_FULL;
55
+ });
56
+ // Original consumed hashes are still covered through the TTL window.
57
+ assert.deepEqual(cache.tryReserve("h1", ttlSeconds), {
58
+ ok: false,
59
+ reason: "consumed",
60
+ });
61
+ assert.deepEqual(cache.tryReserve("h2", ttlSeconds), {
62
+ ok: false,
63
+ reason: "consumed",
64
+ });
65
+ assert.deepEqual(cache.tryReserve("h3", ttlSeconds), {
66
+ ok: false,
67
+ reason: "consumed",
68
+ });
69
+ assert.equal(cache.size, 3);
70
+ });
71
+ test("purge of expired entries frees capacity without reopening live replays", () => {
72
+ let now = 9_000_000;
73
+ const cache = new ConsumedProxyCodeCache(2, () => now);
74
+ cache.commit("old", reserveId(cache, "old", 10));
75
+ cache.commit("live", reserveId(cache, "live", 600));
76
+ assert.equal(cache.size, 2);
77
+ // Expire only "old" so a new insert can reclaim that slot.
78
+ now += 10_000;
79
+ cache.commit("fresh", reserveId(cache, "fresh", 600));
80
+ assert.equal(cache.size, 2);
81
+ // Still-valid "live" hash must remain rejected through its TTL.
82
+ assert.deepEqual(cache.tryReserve("live", 600), {
83
+ ok: false,
84
+ reason: "consumed",
85
+ });
86
+ // Cache is full of non-expired entries — fail closed, do not LRU-evict.
87
+ assert.throws(() => cache.tryReserve("another", 600), (err) => {
88
+ return err.message === PROXY_CODE_CACHE_FULL;
89
+ });
90
+ });
91
+ test("release drops pending reservation but not committed entries", () => {
92
+ const now = 2_000_000;
93
+ const cache = new ConsumedProxyCodeCache(10, () => now);
94
+ const pendingId = reserveId(cache, "pending", 60);
95
+ cache.release("pending", pendingId);
96
+ assert.equal(cache.size, 0);
97
+ const retryId = reserveId(cache, "pending", 60);
98
+ cache.commit("pending", retryId);
99
+ cache.release("pending", retryId);
100
+ assert.equal(cache.size, 1);
101
+ assert.deepEqual(cache.tryReserve("pending", 60), {
102
+ ok: false,
103
+ reason: "consumed",
104
+ });
105
+ });
106
+ test("pending reservation also blocks concurrent replay attempts", () => {
107
+ const cache = new ConsumedProxyCodeCache(10, () => 3_000_000);
108
+ assert.equal(cache.tryReserve("in-flight", 120).ok, true);
109
+ assert.deepEqual(cache.tryReserve("in-flight", 120), {
110
+ ok: false,
111
+ reason: "pending",
112
+ });
113
+ });
114
+ test("non-finite TTL still blocks a second reservation for the same key", () => {
115
+ const cache = new ConsumedProxyCodeCache(10, () => 4_000_000);
116
+ assert.equal(cache.tryReserve("nan-ttl", Number.NaN).ok, true);
117
+ assert.deepEqual(cache.tryReserve("nan-ttl", Number.NaN), {
118
+ ok: false,
119
+ reason: "pending",
120
+ });
121
+ });
122
+ test("extreme finite TTL keeps expiresAt finite and evictable", () => {
123
+ let now = 1_000_000;
124
+ const cache = new ConsumedProxyCodeCache(10, () => now);
125
+ cache.commit("max-ttl", reserveId(cache, "max-ttl", Number.MAX_VALUE));
126
+ assert.deepEqual(cache.tryReserve("max-ttl", Number.MAX_VALUE), {
127
+ ok: false,
128
+ reason: "consumed",
129
+ });
130
+ // Advance past the capped expiry so purgeExpired can reclaim the slot.
131
+ now += Math.floor(Number.MAX_SAFE_INTEGER / 1000) * 1000 + 1;
132
+ assert.equal(cache.tryReserve("max-ttl", 60).ok, true);
133
+ });
134
+ test("stale reservation id cannot release or commit a newer reservation", () => {
135
+ let now = 1_000_000;
136
+ const cache = new ConsumedProxyCodeCache(10, () => now);
137
+ const ttlSeconds = 10;
138
+ const staleId = reserveId(cache, "key", ttlSeconds);
139
+ now += 11_000;
140
+ const liveId = reserveId(cache, "key", ttlSeconds);
141
+ cache.release("key", staleId);
142
+ assert.deepEqual(cache.tryReserve("key", ttlSeconds), {
143
+ ok: false,
144
+ reason: "pending",
145
+ });
146
+ cache.commit("key", staleId);
147
+ cache.release("key", liveId);
148
+ assert.equal(cache.tryReserve("key", ttlSeconds).ok, true);
149
+ });
150
+ });
@@ -0,0 +1,350 @@
1
+ import { after, before, describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
4
+ import { findMockServerPort, MockGitLabServer } from "./utils/mock-gitlab-server.js";
5
+ const VALID_TOKEN = "test-token-sse-stream-12345";
6
+ async function postJsonRpc(url, body, headers = {}) {
7
+ const response = await fetch(url, {
8
+ method: "POST",
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ Accept: "application/json, text/event-stream",
12
+ ...headers,
13
+ },
14
+ body: JSON.stringify(body),
15
+ });
16
+ const sessionId = response.headers.get("mcp-session-id");
17
+ const text = await response.text();
18
+ const contentType = response.headers.get("content-type") ?? "";
19
+ if (contentType.includes("text/event-stream")) {
20
+ const dataLines = text
21
+ .split("\n")
22
+ .filter(line => line.startsWith("data: "))
23
+ .map(line => line.slice(6));
24
+ const lastLine = dataLines.at(-1);
25
+ const parsedData = lastLine && lastLine.length > 0 ? JSON.parse(lastLine) : null;
26
+ return {
27
+ status: response.status,
28
+ data: parsedData,
29
+ sessionId,
30
+ text,
31
+ };
32
+ }
33
+ const parsedData = text.length > 0 ? JSON.parse(text) : null;
34
+ return {
35
+ status: response.status,
36
+ data: parsedData,
37
+ sessionId,
38
+ text,
39
+ };
40
+ }
41
+ describe("When Streamable HTTP server is running", () => {
42
+ let mockGitLab = null;
43
+ let server = null;
44
+ let mcpUrl = "";
45
+ before(async () => {
46
+ const mockPort = await findMockServerPort();
47
+ mockGitLab = new MockGitLabServer({
48
+ port: mockPort,
49
+ validTokens: [VALID_TOKEN],
50
+ });
51
+ await mockGitLab.start();
52
+ const port = await findAvailablePort(3720);
53
+ server = await launchServer({
54
+ mode: TransportMode.STREAMABLE_HTTP,
55
+ port,
56
+ timeout: 10_000,
57
+ env: {
58
+ STREAMABLE_HTTP: "true",
59
+ REMOTE_AUTHORIZATION: "true",
60
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
61
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
62
+ GITLAB_TOOLSETS: "merge_requests",
63
+ },
64
+ });
65
+ mcpUrl = `http://${HOST}:${port}/mcp`;
66
+ });
67
+ after(async () => {
68
+ if (server) {
69
+ await cleanupServers([server]);
70
+ server = null;
71
+ }
72
+ if (mockGitLab) {
73
+ await mockGitLab.stop();
74
+ mockGitLab = null;
75
+ }
76
+ });
77
+ describe("with initialize handshake", () => {
78
+ it("should advertise tools.listChanged capability in server capabilities", async () => {
79
+ // Given: initialize request body
80
+ const initBody = {
81
+ jsonrpc: "2.0",
82
+ id: 1,
83
+ method: "initialize",
84
+ params: {
85
+ protocolVersion: "2025-03-26",
86
+ capabilities: {},
87
+ clientInfo: { name: "test-client", version: "1.0.0" },
88
+ },
89
+ };
90
+ // When: client sends initialize request to /mcp
91
+ const response = await postJsonRpc(mcpUrl, initBody);
92
+ // Then: response should be 200 and include tools.listChanged: true
93
+ assert.strictEqual(response.status, 200, response.text);
94
+ assert.ok(response.sessionId, "initialize should return Mcp-Session-Id");
95
+ const result = response.data?.result;
96
+ assert.ok(typeof result === "object" && result !== null, "Result should be an object");
97
+ assert.ok(result.capabilities?.tools, "capabilities.tools should be defined");
98
+ assert.strictEqual(result.capabilities.tools.listChanged, true, "tools.listChanged capability must be true");
99
+ });
100
+ });
101
+ describe("with GET requests on /mcp", () => {
102
+ it("should return 406 when Accept header lacks text/event-stream", async () => {
103
+ // Given: GET request without Accept: text/event-stream
104
+ const response = await fetch(mcpUrl, {
105
+ method: "GET",
106
+ headers: {
107
+ Accept: "application/json",
108
+ },
109
+ });
110
+ // When: server evaluates the request
111
+ const body = await response.json();
112
+ // Then: server should respond with 406 Not Acceptable
113
+ assert.strictEqual(response.status, 406);
114
+ assert.strictEqual(body?.error, "Not Acceptable");
115
+ const allowHeader = response.headers.get("allow");
116
+ assert.ok(allowHeader?.includes("GET"), "Allow header should contain GET");
117
+ assert.ok(allowHeader?.includes("POST"), "Allow header should contain POST");
118
+ assert.ok(allowHeader?.includes("DELETE"), "Allow header should contain DELETE");
119
+ });
120
+ it("should return 400 when Mcp-Session-Id header is missing", async () => {
121
+ // Given: GET request with Accept: text/event-stream but without Mcp-Session-Id
122
+ const response = await fetch(mcpUrl, {
123
+ method: "GET",
124
+ headers: {
125
+ Accept: "text/event-stream",
126
+ },
127
+ });
128
+ // When: server evaluates the request
129
+ const body = await response.json();
130
+ // Then: server should respond with 400 Bad Request
131
+ assert.strictEqual(response.status, 400);
132
+ assert.strictEqual(body?.error, "Bad Request");
133
+ });
134
+ it("should return 404 when Mcp-Session-Id does not match any active session", async () => {
135
+ // Given: GET request with unknown session ID
136
+ const response = await fetch(mcpUrl, {
137
+ method: "GET",
138
+ headers: {
139
+ Accept: "text/event-stream",
140
+ "Mcp-Session-Id": "non-existent-session-id-12345",
141
+ },
142
+ });
143
+ // When: server evaluates the request
144
+ const body = await response.json();
145
+ // Then: server should respond with 404 Session not found
146
+ assert.strictEqual(response.status, 404);
147
+ assert.strictEqual(body?.error, "Session not found");
148
+ });
149
+ it("should establish SSE stream when valid Mcp-Session-Id and Accept header are provided", async () => {
150
+ // Given: an initialized session
151
+ const initBody = {
152
+ jsonrpc: "2.0",
153
+ id: 10,
154
+ method: "initialize",
155
+ params: {
156
+ protocolVersion: "2025-03-26",
157
+ capabilities: {},
158
+ clientInfo: { name: "sse-stream-client", version: "1.0.0" },
159
+ },
160
+ };
161
+ const initResponse = await postJsonRpc(mcpUrl, initBody);
162
+ const sessionId = initResponse.sessionId;
163
+ assert.ok(sessionId, "Session ID should be present");
164
+ // When: client connects with GET /mcp and Accept: text/event-stream
165
+ const abortController = new AbortController();
166
+ const sseResponse = await fetch(mcpUrl, {
167
+ method: "GET",
168
+ headers: {
169
+ Accept: "text/event-stream",
170
+ "Mcp-Session-Id": sessionId,
171
+ },
172
+ signal: abortController.signal,
173
+ });
174
+ // Then: response should be 200 with Content-Type text/event-stream
175
+ assert.strictEqual(sseResponse.status, 200);
176
+ const contentType = sseResponse.headers.get("content-type") ?? "";
177
+ assert.ok(contentType.includes("text/event-stream"), "Content-Type must be text/event-stream");
178
+ abortController.abort();
179
+ });
180
+ it("should receive list_changed notification over SSE stream when discover_tools runs", async () => {
181
+ // Given: an authenticated session connected to GET /mcp SSE stream
182
+ const initBody = {
183
+ jsonrpc: "2.0",
184
+ id: 20,
185
+ method: "initialize",
186
+ params: {
187
+ protocolVersion: "2025-03-26",
188
+ capabilities: {},
189
+ clientInfo: { name: "sse-notify-client", version: "1.0.0" },
190
+ },
191
+ };
192
+ const initResponse = await postJsonRpc(mcpUrl, initBody, {
193
+ "Private-Token": VALID_TOKEN,
194
+ });
195
+ const sessionId = initResponse.sessionId;
196
+ assert.ok(sessionId, "Session ID should be present");
197
+ // Initialized notification
198
+ await postJsonRpc(mcpUrl, {
199
+ jsonrpc: "2.0",
200
+ method: "notifications/initialized",
201
+ }, {
202
+ "mcp-session-id": sessionId,
203
+ "Private-Token": VALID_TOKEN,
204
+ });
205
+ const abortController = new AbortController();
206
+ const sseResponse = await fetch(mcpUrl, {
207
+ method: "GET",
208
+ headers: {
209
+ Accept: "text/event-stream",
210
+ "Mcp-Session-Id": sessionId,
211
+ "Private-Token": VALID_TOKEN,
212
+ },
213
+ signal: abortController.signal,
214
+ });
215
+ assert.strictEqual(sseResponse.status, 200);
216
+ const streamReader = sseResponse.body?.getReader();
217
+ assert.ok(streamReader, "SSE response body reader should exist");
218
+ const decoder = new TextDecoder();
219
+ let receivedEvents = "";
220
+ // Background listener to collect SSE chunks
221
+ const readPromise = (async () => {
222
+ try {
223
+ while (true) {
224
+ const { done, value } = await streamReader.read();
225
+ if (done)
226
+ break;
227
+ receivedEvents += decoder.decode(value, { stream: true });
228
+ if (receivedEvents.includes("notifications/tools/list_changed")) {
229
+ break;
230
+ }
231
+ }
232
+ }
233
+ catch {
234
+ // Stream closed or aborted
235
+ }
236
+ })();
237
+ // When: client calls discover_tools to activate pipelines category
238
+ const discoverResponse = await postJsonRpc(mcpUrl, {
239
+ jsonrpc: "2.0",
240
+ id: 21,
241
+ method: "tools/call",
242
+ params: {
243
+ name: "discover_tools",
244
+ arguments: {
245
+ category: "pipelines",
246
+ },
247
+ },
248
+ }, {
249
+ "mcp-session-id": sessionId,
250
+ "Private-Token": VALID_TOKEN,
251
+ });
252
+ // Then: discover_tools succeeds and list_changed notification arrives on SSE stream
253
+ assert.strictEqual(discoverResponse.status, 200, discoverResponse.text);
254
+ let timeoutHandle;
255
+ try {
256
+ await Promise.race([
257
+ readPromise,
258
+ new Promise(resolve => {
259
+ timeoutHandle = setTimeout(resolve, 3000);
260
+ }),
261
+ ]);
262
+ }
263
+ finally {
264
+ if (timeoutHandle)
265
+ clearTimeout(timeoutHandle);
266
+ }
267
+ abortController.abort();
268
+ assert.ok(receivedEvents.includes("notifications/tools/list_changed"), `SSE stream should receive notifications/tools/list_changed. Received: ${receivedEvents}`);
269
+ });
270
+ });
271
+ describe("with unsupported HTTP methods", () => {
272
+ it("should return 405 Method Not Allowed for PUT requests", async () => {
273
+ // Given: PUT request to /mcp
274
+ const response = await fetch(mcpUrl, { method: "PUT" });
275
+ // When: server evaluates the method
276
+ const body = await response.json();
277
+ // Then: server should respond with 405 Method Not Allowed
278
+ assert.strictEqual(response.status, 405);
279
+ assert.strictEqual(body?.error, "Method Not Allowed");
280
+ const allowHeader = response.headers.get("allow");
281
+ assert.ok(allowHeader?.includes("GET"), "Allow header must contain GET");
282
+ assert.ok(allowHeader?.includes("POST"), "Allow header must contain POST");
283
+ assert.ok(allowHeader?.includes("DELETE"), "Allow header must contain DELETE");
284
+ });
285
+ });
286
+ });
287
+ describe("When Streamable HTTP GET SSE is open", { timeout: 20_000 }, () => {
288
+ describe("with a short session timeout and no POST traffic", () => {
289
+ it("should keep the session alive for the life of the SSE stream", async () => {
290
+ const mockPort = await findMockServerPort();
291
+ const mockGitLab = new MockGitLabServer({
292
+ port: mockPort,
293
+ validTokens: [VALID_TOKEN],
294
+ });
295
+ await mockGitLab.start();
296
+ const port = await findAvailablePort(3740);
297
+ const timeoutServer = await launchServer({
298
+ mode: TransportMode.STREAMABLE_HTTP,
299
+ port,
300
+ timeout: 10_000,
301
+ env: {
302
+ STREAMABLE_HTTP: "true",
303
+ REMOTE_AUTHORIZATION: "true",
304
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
305
+ SESSION_TIMEOUT_SECONDS: "1",
306
+ },
307
+ });
308
+ const timeoutMcpUrl = `http://${HOST}:${port}/mcp`;
309
+ try {
310
+ // Given: an authenticated session with an open GET SSE stream
311
+ const initResponse = await postJsonRpc(timeoutMcpUrl, {
312
+ jsonrpc: "2.0",
313
+ id: 1,
314
+ method: "initialize",
315
+ params: {
316
+ protocolVersion: "2025-03-26",
317
+ capabilities: {},
318
+ clientInfo: { name: "sse-keepalive-client", version: "1.0.0" },
319
+ },
320
+ }, { "Private-Token": VALID_TOKEN });
321
+ const sessionId = initResponse.sessionId;
322
+ assert.ok(sessionId, "Session ID should be present");
323
+ const abortController = new AbortController();
324
+ const sseResponse = await fetch(timeoutMcpUrl, {
325
+ method: "GET",
326
+ headers: {
327
+ Accept: "text/event-stream",
328
+ "Mcp-Session-Id": sessionId,
329
+ "Private-Token": VALID_TOKEN,
330
+ },
331
+ signal: abortController.signal,
332
+ });
333
+ assert.strictEqual(sseResponse.status, 200);
334
+ // When: more than SESSION_TIMEOUT_SECONDS elapses with no POST
335
+ await new Promise(resolve => setTimeout(resolve, 2_000));
336
+ const listResponse = await postJsonRpc(timeoutMcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, {
337
+ "mcp-session-id": sessionId,
338
+ "Private-Token": VALID_TOKEN,
339
+ });
340
+ // Then: the session is still alive
341
+ assert.strictEqual(listResponse.status, 200, listResponse.text);
342
+ abortController.abort();
343
+ }
344
+ finally {
345
+ await cleanupServers([timeoutServer]);
346
+ await mockGitLab.stop();
347
+ }
348
+ });
349
+ });
350
+ });