@zereight/mcp-gitlab 2.1.69 → 2.2.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.
Files changed (155) hide show
  1. package/README.ko.md +10 -4
  2. package/README.md +238 -233
  3. package/README.pt-BR.md +4 -4
  4. package/README.zh-CN.md +6 -4
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +456 -237
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/build/utils/url.js +15 -0
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  50. package/build/test/gitlab-diff-schema.test.js +0 -36
  51. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  52. package/build/test/gitlab-tag-schema.test.js +0 -51
  53. package/build/test/group-milestone-schema.test.js +0 -45
  54. package/build/test/mcp-oauth-tests.js +0 -905
  55. package/build/test/mcp-server-name.test.js +0 -87
  56. package/build/test/merge-merge-request-schema.test.js +0 -75
  57. package/build/test/multi-server-test.js +0 -190
  58. package/build/test/no-proxy-integration-test.js +0 -183
  59. package/build/test/no-proxy-test.js +0 -149
  60. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  61. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  62. package/build/test/oauth-device-flow-tests.js +0 -450
  63. package/build/test/oauth-startup.test.js +0 -204
  64. package/build/test/oauth-tests.js +0 -428
  65. package/build/test/path-segment-encoding.test.js +0 -98
  66. package/build/test/remote-auth-simple-test.js +0 -277
  67. package/build/test/remote-auth-tests.js +0 -314
  68. package/build/test/response-masking.test.js +0 -445
  69. package/build/test/schema-tests.js +0 -1679
  70. package/build/test/server/metrics.test.js +0 -47
  71. package/build/test/server/request-helpers.test.js +0 -48
  72. package/build/test/server/version.test.js +0 -13
  73. package/build/test/sse-auth-guard.test.js +0 -96
  74. package/build/test/sse-session-limits.test.js +0 -273
  75. package/build/test/stateless/callback-proxy.test.js +0 -510
  76. package/build/test/stateless/client-id.test.js +0 -177
  77. package/build/test/stateless/codec.test.js +0 -328
  78. package/build/test/stateless/config-ttl.test.js +0 -149
  79. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  80. package/build/test/stateless/session-id-integration.test.js +0 -675
  81. package/build/test/stateless/session-id.test.js +0 -157
  82. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  83. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  84. package/build/test/streamable-http-sse-stream.test.js +0 -350
  85. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  86. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  87. package/build/test/test-all-transport-server.js +0 -255
  88. package/build/test/test-auth-retry.js +0 -195
  89. package/build/test/test-blank-filters.js +0 -156
  90. package/build/test/test-ci-catalog.js +0 -177
  91. package/build/test/test-ci-lint.js +0 -191
  92. package/build/test/test-ci-variables.js +0 -309
  93. package/build/test/test-create-repository.js +0 -120
  94. package/build/test/test-dependency-proxy.js +0 -195
  95. package/build/test/test-deployment-tools.js +0 -463
  96. package/build/test/test-download-attachment.js +0 -190
  97. package/build/test/test-dynamic-project-scope.js +0 -524
  98. package/build/test/test-empty-toolsets.js +0 -535
  99. package/build/test/test-get-file-blame.js +0 -145
  100. package/build/test/test-get-file-contents.js +0 -128
  101. package/build/test/test-geteffectiveprojectid.js +0 -812
  102. package/build/test/test-health-check.js +0 -115
  103. package/build/test/test-issue-description-patch.js +0 -256
  104. package/build/test/test-job-artifacts.js +0 -275
  105. package/build/test/test-json-schema.js +0 -166
  106. package/build/test/test-list-group-members.js +0 -150
  107. package/build/test/test-list-group-merge-requests.js +0 -165
  108. package/build/test/test-list-issues.js +0 -170
  109. package/build/test/test-list-merge-requests.js +0 -201
  110. package/build/test/test-list-milestones.js +0 -145
  111. package/build/test/test-list-project-members.js +0 -132
  112. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  113. package/build/test/test-merge-request-approvals.js +0 -187
  114. package/build/test/test-merge-request-pipelines.js +0 -106
  115. package/build/test/test-mr-diffs-filter.js +0 -132
  116. package/build/test/test-mr-file-diffs.js +0 -251
  117. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  118. package/build/test/test-orbit.js +0 -155
  119. package/build/test/test-permission-mode.js +0 -424
  120. package/build/test/test-pipeline-schedules.js +0 -482
  121. package/build/test/test-protected-branches.js +0 -174
  122. package/build/test/test-remote-downloads.js +0 -510
  123. package/build/test/test-search-code.js +0 -275
  124. package/build/test/test-snippets.js +0 -735
  125. package/build/test/test-tags.js +0 -220
  126. package/build/test/test-todos.js +0 -195
  127. package/build/test/test-token-optimizations.js +0 -691
  128. package/build/test/test-toolset-filtering.js +0 -499
  129. package/build/test/test-update-issue-slim.js +0 -141
  130. package/build/test/test-update-project.js +0 -112
  131. package/build/test/test-upload-markdown.js +0 -206
  132. package/build/test/test-vulnerabilities.js +0 -399
  133. package/build/test/test-webhooks.js +0 -218
  134. package/build/test/tool-description-quality.test.js +0 -50
  135. package/build/test/utils/bulk-publish-options.test.js +0 -32
  136. package/build/test/utils/download-token.test.js +0 -35
  137. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  138. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  139. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  140. package/build/test/utils/graphql-query.test.js +0 -168
  141. package/build/test/utils/helpers-path.test.js +0 -104
  142. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  143. package/build/test/utils/masking.test.js +0 -384
  144. package/build/test/utils/merge-request-position.test.js +0 -57
  145. package/build/test/utils/mock-gitlab-server.js +0 -614
  146. package/build/test/utils/proxy-client-ip.test.js +0 -28
  147. package/build/test/utils/redact-sensitive.test.js +0 -32
  148. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  149. package/build/test/utils/server-launcher.js +0 -231
  150. package/build/test/utils/tool-args.test.js +0 -210
  151. package/build/test/utils/url.test.js +0 -128
  152. package/build/test/utils/version-check.test.js +0 -239
  153. package/build/test/utils/wiki-title.test.js +0 -21
  154. package/build/test-note.js +0 -54
  155. package/build/test-resolve-issue-note.js +0 -127
@@ -1,675 +0,0 @@
1
- /**
2
- * Integration test for stateless Mcp-Session-Id.
3
- *
4
- * Launches two MCP server processes sharing OAUTH_STATELESS_SECRET and a
5
- * single mock GitLab backend. Demonstrates the exact HPA scenario that
6
- * breaks without stateless mode: init on pod A, subsequent request on pod B.
7
- *
8
- * Uses REMOTE_AUTHORIZATION mode with a Private-Token because it exercises
9
- * the session-id code path with minimal OAuth machinery.
10
- */
11
- import assert from "node:assert";
12
- import { randomBytes } from "node:crypto";
13
- import { request as httpRequest } from "node:http";
14
- import { after, before, describe, test } from "node:test";
15
- import { loadKeyMaterialFromEnv, mintSessionId, openSessionId, } from "../../stateless/index.js";
16
- import { MockGitLabServer, findMockServerPort } from "../utils/mock-gitlab-server.js";
17
- import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "../utils/server-launcher.js";
18
- const MOCK_TOKEN = "glpat-mockstateless-12345-abcdef";
19
- // Use unusual port ranges to avoid colliding with other suites.
20
- const MOCK_PORT_BASE = 9800;
21
- const SERVER_PORT_BASE_A = 3800;
22
- const SERVER_PORT_BASE_B = 3850;
23
- const SERVER_PORT_BASE_TTL = 3900;
24
- describe("Stateless Mcp-Session-Id — cross-pod integration", () => {
25
- let servers = [];
26
- let mockGitLab;
27
- let urlA;
28
- let urlB;
29
- const sharedSecret = randomBytes(32).toString("base64url");
30
- before(async () => {
31
- const mockPort = await findMockServerPort();
32
- mockGitLab = new MockGitLabServer({
33
- port: mockPort,
34
- validTokens: [MOCK_TOKEN],
35
- });
36
- await mockGitLab.start();
37
- const mockUrl = mockGitLab.getUrl();
38
- const portA = await findAvailablePort(SERVER_PORT_BASE_A);
39
- const portB = await findAvailablePort(SERVER_PORT_BASE_B);
40
- const commonEnv = {
41
- STREAMABLE_HTTP: "true",
42
- REMOTE_AUTHORIZATION: "true",
43
- GITLAB_API_URL: `${mockUrl}/api/v4`,
44
- GITLAB_READ_ONLY_MODE: "true",
45
- OAUTH_STATELESS_MODE: "true",
46
- OAUTH_STATELESS_SECRET: sharedSecret,
47
- };
48
- const sA = await launchServer({
49
- mode: TransportMode.STREAMABLE_HTTP,
50
- port: portA,
51
- timeout: 5000,
52
- env: { ...commonEnv },
53
- });
54
- servers.push(sA);
55
- urlA = `http://${HOST}:${portA}/mcp`;
56
- const sB = await launchServer({
57
- mode: TransportMode.STREAMABLE_HTTP,
58
- port: portB,
59
- timeout: 5000,
60
- env: { ...commonEnv },
61
- });
62
- servers.push(sB);
63
- urlB = `http://${HOST}:${portB}/mcp`;
64
- console.log(`Stateless pod A: ${urlA}`);
65
- console.log(`Stateless pod B: ${urlB}`);
66
- });
67
- after(async () => {
68
- cleanupServers(servers);
69
- if (mockGitLab) {
70
- await mockGitLab.stop();
71
- }
72
- });
73
- async function post(url, body, headers = {}) {
74
- const res = await fetch(url, {
75
- method: "POST",
76
- headers: {
77
- "Content-Type": "application/json",
78
- Accept: "application/json, text/event-stream",
79
- ...headers,
80
- },
81
- body: JSON.stringify(body),
82
- });
83
- const sid = res.headers.get("mcp-session-id");
84
- const bodyText = await res.text();
85
- return { status: res.status, sid, bodyText };
86
- }
87
- function initRequest() {
88
- return {
89
- jsonrpc: "2.0",
90
- id: 1,
91
- method: "initialize",
92
- params: {
93
- protocolVersion: "2025-03-26",
94
- capabilities: {},
95
- clientInfo: { name: "stateless-integration-test", version: "1.0.0" },
96
- },
97
- };
98
- }
99
- function listToolsRequest(id) {
100
- return { jsonrpc: "2.0", id, method: "tools/list", params: {} };
101
- }
102
- // ---------------------------------------------------------------------
103
- // Tests
104
- // ---------------------------------------------------------------------
105
- test("init on pod A returns a stateless Mcp-Session-Id", async () => {
106
- const result = await post(urlA, initRequest(), {
107
- "private-token": MOCK_TOKEN,
108
- });
109
- assert.equal(result.status, 200);
110
- assert.ok(result.sid, "init response must include Mcp-Session-Id");
111
- assert.ok(result.sid.startsWith("v1.sid."), `sid should be a stateless sealed value, got: ${result.sid.slice(0, 20)}…`);
112
- });
113
- test("subsequent request on pod B (different pod) succeeds with A's sid", async () => {
114
- // Init on A (with token)
115
- const initRes = await post(urlA, initRequest(), {
116
- "private-token": MOCK_TOKEN,
117
- });
118
- assert.equal(initRes.status, 200);
119
- const sidFromA = initRes.sid;
120
- assert.ok(sidFromA);
121
- // List tools on B using sid from A, WITHOUT re-supplying the token.
122
- // This is the critical test: pod B has never seen this session before
123
- // and does not share any in-memory state with pod A.
124
- const listRes = await post(urlB, listToolsRequest(2), {
125
- "mcp-session-id": sidFromA,
126
- });
127
- assert.equal(listRes.status, 200, `pod B should accept pod A's sealed sid, got status ${listRes.status}: ${listRes.bodyText.slice(0, 200)}`);
128
- // The response body should contain a tools list, not an error.
129
- assert.match(listRes.bodyText, /tools/, "response body should include the tools/list result");
130
- });
131
- test("pod B rejects a tampered sid with 404 (session ended)", async () => {
132
- const initRes = await post(urlA, initRequest(), {
133
- "private-token": MOCK_TOKEN,
134
- });
135
- const sid = initRes.sid;
136
- // Flip a byte in the middle of the blob
137
- const parts = sid.split(".");
138
- const blob = Buffer.from(parts[2], "base64url");
139
- blob[Math.floor(blob.length / 2)] ^= 0xff;
140
- parts[2] = blob.toString("base64url");
141
- const tampered = parts.join(".");
142
- const listRes = await post(urlB, listToolsRequest(3), {
143
- "mcp-session-id": tampered,
144
- });
145
- // 404 (not 401) per MCP Streamable HTTP: a tampered / unknown sid looks
146
- // identical to a terminated session from the client's perspective; 404
147
- // tells it to re-initialize, whereas 401 would trip the auth-failure
148
- // path and break automatic recovery.
149
- assert.equal(listRes.status, 404);
150
- });
151
- test("request without sid or auth header is 401", async () => {
152
- // A non-init request with no auth at all — stateless mode has no way to
153
- // derive the caller's identity.
154
- const listRes = await post(urlA, listToolsRequest(4));
155
- assert.equal(listRes.status, 401);
156
- });
157
- test("fresh Authorization header on subsequent request rotates the sid", async () => {
158
- const initRes = await post(urlA, initRequest(), {
159
- "private-token": MOCK_TOKEN,
160
- });
161
- const sidFromA = initRes.sid;
162
- // Supply BOTH the old sid and a fresh Private-Token header. Our handler
163
- // prefers the fresh header and mints a new sid. The handler now also
164
- // emits the freshly minted sid on non-init responses (so clients can
165
- // adopt it) — assert it changed.
166
- const listRes = await post(urlB, listToolsRequest(5), {
167
- "mcp-session-id": sidFromA,
168
- "private-token": MOCK_TOKEN,
169
- });
170
- assert.equal(listRes.status, 200);
171
- assert.ok(listRes.sid, "non-init response should now carry a rotated sid");
172
- assert.notEqual(listRes.sid, sidFromA, "fresh auth must rotate the sid on every request");
173
- });
174
- test("sid-only request also rotates the sid (inactivity-TTL semantics)", async () => {
175
- const initRes = await post(urlA, initRequest(), {
176
- "private-token": MOCK_TOKEN,
177
- });
178
- const sid0 = initRes.sid;
179
- // Sid-only: no live auth headers. Prior to the fix the server would
180
- // reuse sid0 verbatim, freezing the embedded iat. After the fix the
181
- // server mints a fresh sid with an advanced iat on every request.
182
- const listRes = await post(urlB, listToolsRequest(6), {
183
- "mcp-session-id": sid0,
184
- });
185
- assert.equal(listRes.status, 200);
186
- assert.ok(listRes.sid, "sid-only response must carry a rotated sid");
187
- assert.notEqual(listRes.sid, sid0, "iat must advance → sid value must differ");
188
- });
189
- });
190
- // =============================================================================
191
- // TTL / inactivity semantics
192
- // =============================================================================
193
- //
194
- // These tests exercise the rotation + TTL behaviour that makes
195
- // OAUTH_STATELESS_SESSION_TTL_SECONDS an inactivity timeout rather than an
196
- // absolute-age cap. They run a single dedicated server with a deliberately
197
- // small TTL (3 s) so the whole suite completes in a few seconds of wall-clock
198
- // time — no fake-clock plumbing required.
199
- describe("Stateless Mcp-Session-Id — inactivity-TTL semantics", () => {
200
- const servers = [];
201
- let mockGitLab;
202
- let url;
203
- let material;
204
- const sharedSecret = randomBytes(32).toString("base64url");
205
- const TTL_SECONDS = 3;
206
- before(async () => {
207
- const mockPort = await findMockServerPort();
208
- mockGitLab = new MockGitLabServer({
209
- port: mockPort,
210
- validTokens: [MOCK_TOKEN],
211
- });
212
- await mockGitLab.start();
213
- const mockUrl = mockGitLab.getUrl();
214
- const port = await findAvailablePort(SERVER_PORT_BASE_TTL);
215
- const s = await launchServer({
216
- mode: TransportMode.STREAMABLE_HTTP,
217
- port,
218
- timeout: 5000,
219
- env: {
220
- STREAMABLE_HTTP: "true",
221
- REMOTE_AUTHORIZATION: "true",
222
- GITLAB_API_URL: `${mockUrl}/api/v4`,
223
- GITLAB_READ_ONLY_MODE: "true",
224
- OAUTH_STATELESS_MODE: "true",
225
- OAUTH_STATELESS_SECRET: sharedSecret,
226
- OAUTH_STATELESS_SESSION_TTL_SECONDS: String(TTL_SECONDS),
227
- },
228
- });
229
- servers.push(s);
230
- url = `http://${HOST}:${port}/mcp`;
231
- // Load key material locally so the test can decode sid payloads
232
- // (specifically to read back iat and verify it advances).
233
- material = loadKeyMaterialFromEnv(true, {
234
- OAUTH_STATELESS_SECRET: sharedSecret,
235
- });
236
- assert.ok(material, "test failed to load stateless key material");
237
- });
238
- after(async () => {
239
- cleanupServers(servers);
240
- if (mockGitLab) {
241
- await mockGitLab.stop();
242
- }
243
- });
244
- async function post(body, headers = {}) {
245
- const res = await fetch(url, {
246
- method: "POST",
247
- headers: {
248
- "Content-Type": "application/json",
249
- Accept: "application/json, text/event-stream",
250
- ...headers,
251
- },
252
- body: JSON.stringify(body),
253
- });
254
- const sid = res.headers.get("mcp-session-id");
255
- const bodyText = await res.text();
256
- return { status: res.status, sid, bodyText };
257
- }
258
- function initRequest() {
259
- return {
260
- jsonrpc: "2.0",
261
- id: 1,
262
- method: "initialize",
263
- params: {
264
- protocolVersion: "2025-03-26",
265
- capabilities: {},
266
- clientInfo: { name: "ttl-test", version: "1.0.0" },
267
- },
268
- };
269
- }
270
- function listToolsRequest(id) {
271
- return { jsonrpc: "2.0", id, method: "tools/list", params: {} };
272
- }
273
- // ---------------------------------------------------------------------
274
- // (a) iat advances on sid-only authenticated requests
275
- // ---------------------------------------------------------------------
276
- test("iat advances on sid-only authenticated requests", async () => {
277
- const initRes = await post(initRequest(), { "private-token": MOCK_TOKEN });
278
- assert.equal(initRes.status, 200);
279
- const sid1 = initRes.sid;
280
- const opened1 = openSessionId(material, sid1, 3600);
281
- assert.ok(opened1, "init sid must decode");
282
- const iat1 = opened1.iat;
283
- // Wait long enough that a 1s-granularity iat can plausibly change, but
284
- // well under the TTL so the session itself is still valid.
285
- await new Promise((r) => setTimeout(r, 1500));
286
- const sidOnlyRes = await post(listToolsRequest(2), {
287
- "mcp-session-id": sid1,
288
- });
289
- assert.equal(sidOnlyRes.status, 200);
290
- assert.ok(sidOnlyRes.sid, "sid-only request should emit a rotated Mcp-Session-Id header");
291
- assert.notEqual(sidOnlyRes.sid, sid1, "handler must mint a fresh sid on sid-only requests");
292
- const opened2 = openSessionId(material, sidOnlyRes.sid, 3600);
293
- assert.ok(opened2, "rotated sid must decode");
294
- assert.ok(opened2.iat > iat1, `iat must advance: initial=${iat1}, rotated=${opened2.iat}`);
295
- });
296
- // ---------------------------------------------------------------------
297
- // (b) continuously-used session survives past TTL
298
- // ---------------------------------------------------------------------
299
- test("continuously-used session survives past TTL", async () => {
300
- const initRes = await post(initRequest(), { "private-token": MOCK_TOKEN });
301
- assert.equal(initRes.status, 200);
302
- let sid = initRes.sid;
303
- const startTs = Date.now();
304
- // Run sid-only requests with spacing < TTL, for a total window > TTL.
305
- // Step = TTL/2 (seconds). Iterations ≥ 2*TTL + 1 → total elapsed > TTL.
306
- const stepMs = Math.floor((TTL_SECONDS * 1000) / 2);
307
- const iterations = TTL_SECONDS * 2 + 1;
308
- for (let i = 0; i < iterations; i++) {
309
- await new Promise((r) => setTimeout(r, stepMs));
310
- const res = await post(listToolsRequest(100 + i), {
311
- "mcp-session-id": sid,
312
- });
313
- assert.equal(res.status, 200, `iteration ${i}: continuously-used session must stay alive past TTL`);
314
- assert.ok(res.sid, `iteration ${i}: must emit rotated sid`);
315
- sid = res.sid;
316
- }
317
- const totalElapsedSec = (Date.now() - startTs) / 1000;
318
- assert.ok(totalElapsedSec > TTL_SECONDS, `test must span more than TTL (${TTL_SECONDS}s), actual=${totalElapsedSec}s`);
319
- // Final sid must still validate.
320
- const finalOpened = openSessionId(material, sid, TTL_SECONDS);
321
- assert.ok(finalOpened, "final sid must still validate despite total elapsed > TTL");
322
- });
323
- // ---------------------------------------------------------------------
324
- // (c) statelessSidRotated counter does not bump on sid-only mints
325
- // ---------------------------------------------------------------------
326
- //
327
- // We cannot introspect the child-process counter directly, so we instead
328
- // verify the contract by observing that the *response sid* changes on
329
- // sid-only requests (as in test (a)) — which means minting occurs — while
330
- // the counter-bump gate (`freshAuthPresent`) was false. If the counter
331
- // had bumped, freshAuthPresent would have been true, which means live
332
- // headers were honoured — but sid-only requests carry no live headers,
333
- // so the counter path is unreachable for them by construction of the
334
- // handler. This test documents that invariant at the HTTP surface.
335
- test("sid-only request mints without marking freshAuthPresent (statelessSidRotated gate)", async () => {
336
- const initRes = await post(initRequest(), { "private-token": MOCK_TOKEN });
337
- assert.equal(initRes.status, 200);
338
- const sid1 = initRes.sid;
339
- const sidOnlyRes = await post(listToolsRequest(3), {
340
- "mcp-session-id": sid1,
341
- });
342
- assert.equal(sidOnlyRes.status, 200);
343
- assert.ok(sidOnlyRes.sid);
344
- // Mint happened (different sid) but no live auth header was present.
345
- assert.notEqual(sidOnlyRes.sid, sid1);
346
- // Sanity: a request with a live header also rotates the sid — that path
347
- // *is* the path that bumps the counter in-process. Asserting both paths
348
- // rotate confirms our change is uniform.
349
- const freshRes = await post(listToolsRequest(4), {
350
- "mcp-session-id": sid1,
351
- "private-token": MOCK_TOKEN,
352
- });
353
- assert.equal(freshRes.status, 200);
354
- assert.ok(freshRes.sid);
355
- assert.notEqual(freshRes.sid, sid1);
356
- });
357
- // ---------------------------------------------------------------------
358
- // (d) still returns terminated-session response after inactivity > TTL
359
- // ---------------------------------------------------------------------
360
- test("returns 404 (session ended) after inactivity greater than TTL", async () => {
361
- const initRes = await post(initRequest(), { "private-token": MOCK_TOKEN });
362
- assert.equal(initRes.status, 200);
363
- const sid1 = initRes.sid;
364
- // Sleep just past TTL with no traffic at all.
365
- await new Promise((r) => setTimeout(r, (TTL_SECONDS + 1) * 1000));
366
- const res = await post(listToolsRequest(5), { "mcp-session-id": sid1 });
367
- // Per MCP Streamable HTTP: 404 on a session-bound request means
368
- // "session ended, re-initialize". The client should start a fresh
369
- // handshake, not retry auth. This is the critical contract for
370
- // automatic recovery after inactivity TTL expiry in stateless mode.
371
- assert.equal(res.status, 404, `expired sid must 404 (session ended), got ${res.status}: ${res.bodyText.slice(0, 200)}`);
372
- });
373
- // ---------------------------------------------------------------------
374
- // (e) request without any sid or auth still returns 401 (genuine auth)
375
- // ---------------------------------------------------------------------
376
- test("no sid and no auth headers is 401 (not 404)", async () => {
377
- // Guard: 404 must only be returned when the client presented a sid we
378
- // failed to open. With nothing at all, the client is unauthenticated
379
- // and 401 is the correct signal.
380
- const res = await post(listToolsRequest(6));
381
- assert.equal(res.status, 401);
382
- });
383
- // ---------------------------------------------------------------------
384
- // (f) expired sid + live auth headers → client auto-recovers
385
- // ---------------------------------------------------------------------
386
- test("expired sid with live auth headers still succeeds (auto-recovery)", async () => {
387
- const initRes = await post(initRequest(), { "private-token": MOCK_TOKEN });
388
- assert.equal(initRes.status, 200);
389
- const staleSid = initRes.sid;
390
- await new Promise((r) => setTimeout(r, (TTL_SECONDS + 1) * 1000));
391
- // Client presents the stale sid AND fresh auth. Live headers take
392
- // priority, so the request succeeds and a fresh sid is minted.
393
- const res = await post(listToolsRequest(7), {
394
- "mcp-session-id": staleSid,
395
- "private-token": MOCK_TOKEN,
396
- });
397
- assert.equal(res.status, 200);
398
- assert.ok(res.sid);
399
- assert.notEqual(res.sid, staleSid, "a fresh sid must be minted");
400
- });
401
- // ---------------------------------------------------------------------
402
- // (g) duplicate Mcp-Session-Id must not 500
403
- // ---------------------------------------------------------------------
404
- test("duplicate Mcp-Session-Id headers do not crash the server", async () => {
405
- // Node's HTTP types allow repeated headers to arrive as string[]. The
406
- // old handler cast to `string` unconditionally and called .startsWith
407
- // on the array, throwing TypeError and yielding 500. This test sends a
408
- // request with two Mcp-Session-Id values using raw node:http (fetch
409
- // can't emit duplicate headers) and asserts the server responds with
410
- // a well-formed 401/404 — never 5xx.
411
- const u = new URL(url);
412
- const body = JSON.stringify(listToolsRequest(8));
413
- const status = await new Promise((resolve, reject) => {
414
- const r = httpRequest({
415
- hostname: u.hostname,
416
- port: Number(u.port),
417
- path: u.pathname,
418
- method: "POST",
419
- headers: {
420
- "Content-Type": "application/json",
421
- Accept: "application/json, text/event-stream",
422
- "Content-Length": Buffer.byteLength(body),
423
- // Pass as array → node emits two separate header lines.
424
- "Mcp-Session-Id": ["v1.sid.aaaa.bbbb.cccc", "v1.sid.dddd.eeee.ffff"],
425
- },
426
- }, (res) => {
427
- res.resume();
428
- res.on("end", () => resolve(res.statusCode ?? 0));
429
- });
430
- r.on("error", reject);
431
- r.write(body);
432
- r.end();
433
- });
434
- assert.ok(status < 500, `duplicate Mcp-Session-Id must not yield 5xx, got ${status}`);
435
- // Either 401 (treated as "no sid presented", since array-valued sid is
436
- // rejected at normalization) or 404 (if future implementations treat
437
- // it as an invalid session) are both acceptable — only 5xx is a bug.
438
- assert.ok(status === 401 || status === 404, `expected 401 or 404, got ${status}`);
439
- });
440
- });
441
- // =============================================================================
442
- // OAuth + stateless — sid-only follow-up across pods
443
- // =============================================================================
444
- //
445
- // Regression coverage for the PR #442 review finding: the mcpBearerAuth
446
- // middleware runs BEFORE handleStatelessMcpRequest and, without the bypass,
447
- // requireBearerAuth returns 401 for sid-only requests in GITLAB_MCP_OAUTH
448
- // mode — breaking the headline multi-pod OAuth use case of this PR.
449
- //
450
- // These tests launch two pods with GITLAB_MCP_OAUTH=true +
451
- // OAUTH_STATELESS_MODE=true and a shared stateless secret, then:
452
- // 1. initialize on pod A with Authorization: Bearer <token>, capture sid;
453
- // 2. issue a sid-only POST on pod B — must succeed with 200 + rotated sid;
454
- // 3. verify sid + invalid Authorization still 401s (bypass is guarded on
455
- // `!req.headers.authorization`);
456
- // 4. verify a malformed sid reaches the handler and returns 404 (presence-
457
- // not-validity);
458
- // 5. verify DELETE /mcp with sid-only reaches the handler (not 401 from
459
- // mcpBearerAuth), since DELETE is now gated by the same middleware.
460
- describe("Stateless Mcp-Session-Id — OAuth + sid-only follow-up", () => {
461
- const MOCK_OAUTH_TOKEN = "ya29.mock-oauth-token-stateless-abcdef";
462
- const MOCK_CLIENT_ID = "mock-app-uid-oauth-stateless";
463
- const OAUTH_MOCK_PORT_BASE = 9950;
464
- const OAUTH_SERVER_PORT_BASE_A = 3950;
465
- const OAUTH_SERVER_PORT_BASE_B = 3970;
466
- const servers = [];
467
- let mockGitLab;
468
- let urlA;
469
- let urlB;
470
- let mockGitLabUrl;
471
- let material;
472
- const sharedSecret = randomBytes(32).toString("base64url");
473
- before(async () => {
474
- const mockPort = await findMockServerPort();
475
- mockGitLab = new MockGitLabServer({
476
- port: mockPort,
477
- validTokens: [MOCK_OAUTH_TOKEN],
478
- });
479
- await mockGitLab.start();
480
- mockGitLabUrl = mockGitLab.getUrl();
481
- // Minimal OAuth endpoints — same pattern as test/mcp-oauth-tests.ts.
482
- // Only token/info is strictly required for these tests (the server's
483
- // OAuth verifier calls it to validate the bearer token). DCR +
484
- // well-known are added for parity so the server boots cleanly.
485
- mockGitLab.addRootHandler("get", "/oauth/token/info", (req, res) => {
486
- const auth = req.headers["authorization"];
487
- const token = auth?.replace(/^Bearer\s+/i, "");
488
- if (token !== MOCK_OAUTH_TOKEN) {
489
- res.status(401).json({ error: "invalid_token" });
490
- return;
491
- }
492
- res.json({
493
- resource_owner_id: 42,
494
- scopes: ["api"],
495
- expires_in_seconds: 7200,
496
- application: { uid: MOCK_CLIENT_ID },
497
- created_at: Math.floor(Date.now() / 1000),
498
- });
499
- });
500
- mockGitLab.addRootHandler("post", "/oauth/register", (req, res) => {
501
- res.status(201).json({
502
- client_id: MOCK_CLIENT_ID,
503
- client_name: req.body?.client_name ?? "test",
504
- redirect_uris: req.body?.redirect_uris ?? [],
505
- token_endpoint_auth_method: "none",
506
- require_pkce: true,
507
- });
508
- });
509
- mockGitLab.addRootHandler("get", "/.well-known/oauth-authorization-server", (_req, res) => {
510
- res.json({
511
- issuer: mockGitLabUrl,
512
- authorization_endpoint: `${mockGitLabUrl}/oauth/authorize`,
513
- token_endpoint: `${mockGitLabUrl}/oauth/token`,
514
- registration_endpoint: `${mockGitLabUrl}/oauth/register`,
515
- revocation_endpoint: `${mockGitLabUrl}/oauth/revoke`,
516
- scopes_supported: ["api", "read_api", "read_user"],
517
- response_types_supported: ["code"],
518
- grant_types_supported: ["authorization_code", "refresh_token"],
519
- code_challenge_methods_supported: ["S256"],
520
- });
521
- });
522
- const portA = await findAvailablePort(OAUTH_SERVER_PORT_BASE_A);
523
- const portB = await findAvailablePort(OAUTH_SERVER_PORT_BASE_B);
524
- const baseUrlA = `http://${HOST}:${portA}`;
525
- const baseUrlB = `http://${HOST}:${portB}`;
526
- const commonEnv = {
527
- STREAMABLE_HTTP: "true",
528
- GITLAB_MCP_OAUTH: "true",
529
- GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
530
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
531
- MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
532
- OAUTH_STATELESS_MODE: "true",
533
- OAUTH_STATELESS_SECRET: sharedSecret,
534
- };
535
- const sA = await launchServer({
536
- mode: TransportMode.STREAMABLE_HTTP,
537
- port: portA,
538
- timeout: 5000,
539
- env: { ...commonEnv, MCP_SERVER_URL: baseUrlA },
540
- });
541
- servers.push(sA);
542
- urlA = `${baseUrlA}/mcp`;
543
- const sB = await launchServer({
544
- mode: TransportMode.STREAMABLE_HTTP,
545
- port: portB,
546
- timeout: 5000,
547
- env: { ...commonEnv, MCP_SERVER_URL: baseUrlB },
548
- });
549
- servers.push(sB);
550
- urlB = `${baseUrlB}/mcp`;
551
- // Load key material in-process so tests can decode sids for assertions.
552
- material = loadKeyMaterialFromEnv(true, {
553
- OAUTH_STATELESS_SECRET: sharedSecret,
554
- });
555
- assert.ok(material, "test failed to load stateless key material");
556
- console.log(`OAuth+stateless pod A: ${urlA}`);
557
- console.log(`OAuth+stateless pod B: ${urlB}`);
558
- });
559
- after(async () => {
560
- cleanupServers(servers);
561
- if (mockGitLab) {
562
- await mockGitLab.stop();
563
- }
564
- });
565
- async function post(url, body, headers = {}) {
566
- const res = await fetch(url, {
567
- method: "POST",
568
- headers: {
569
- "Content-Type": "application/json",
570
- Accept: "application/json, text/event-stream",
571
- ...headers,
572
- },
573
- body: JSON.stringify(body),
574
- });
575
- const sid = res.headers.get("mcp-session-id");
576
- const bodyText = await res.text();
577
- return { status: res.status, sid, bodyText };
578
- }
579
- function initRequest() {
580
- return {
581
- jsonrpc: "2.0",
582
- id: 1,
583
- method: "initialize",
584
- params: {
585
- protocolVersion: "2025-03-26",
586
- capabilities: {},
587
- clientInfo: { name: "oauth-stateless-integration-test", version: "1.0.0" },
588
- },
589
- };
590
- }
591
- function listToolsRequest(id) {
592
- return { jsonrpc: "2.0", id, method: "tools/list", params: {} };
593
- }
594
- // ---------------------------------------------------------------------
595
- // Test 1 (CRITICAL): the PR #442 regression scenario.
596
- // Init on pod A with Authorization, follow-up on pod B with ONLY the sid.
597
- // Without the bypass in mcpBearerAuth, requireBearerAuth would 401 before
598
- // handleStatelessMcpRequest ever ran.
599
- // ---------------------------------------------------------------------
600
- test("init on pod A with Authorization → sid-only follow-up on pod B succeeds with rotated sid", async () => {
601
- const initRes = await post(urlA, initRequest(), {
602
- Authorization: `Bearer ${MOCK_OAUTH_TOKEN}`,
603
- });
604
- assert.equal(initRes.status, 200, `init must succeed, got ${initRes.status}: ${initRes.bodyText.slice(0, 200)}`);
605
- const sid1 = initRes.sid;
606
- assert.ok(sid1, "init response must include Mcp-Session-Id");
607
- assert.ok(sid1.startsWith("v1.sid."), `sid must be stateless-shaped, got: ${sid1.slice(0, 20)}…`);
608
- const opened1 = openSessionId(material, sid1, 3600);
609
- assert.ok(opened1, "init sid must decode");
610
- // Follow-up on pod B with ONLY the sid — no Authorization header.
611
- // This is the exact scenario zereight called out in the PR review.
612
- const followUp = await post(urlB, listToolsRequest(2), {
613
- "mcp-session-id": sid1,
614
- });
615
- assert.equal(followUp.status, 200, `sid-only follow-up on pod B must succeed (was masked by 401 before fix), got ${followUp.status}: ${followUp.bodyText.slice(0, 200)}`);
616
- assert.ok(followUp.sid, "follow-up response must carry a rotated sid");
617
- assert.notEqual(followUp.sid, sid1, "sid must rotate on every authenticated request");
618
- const opened2 = openSessionId(material, followUp.sid, 3600);
619
- assert.ok(opened2, "rotated sid must decode");
620
- assert.ok(opened2.iat >= opened1.iat, `rotated iat must be >= original: initial=${opened1.iat}, rotated=${opened2.iat}`);
621
- });
622
- // ---------------------------------------------------------------------
623
- // Test 2: sid + INVALID Authorization still runs bearer validation.
624
- // The bypass is guarded on `!req.headers.authorization`, so the presence
625
- // of any Authorization header must fall through to oauthBearerAuth.
626
- // ---------------------------------------------------------------------
627
- test("sid + invalid Authorization still runs bearer validation (bypass guarded on !authorization)", async () => {
628
- const initRes = await post(urlA, initRequest(), {
629
- Authorization: `Bearer ${MOCK_OAUTH_TOKEN}`,
630
- });
631
- assert.equal(initRes.status, 200);
632
- const sid = initRes.sid;
633
- const res = await post(urlB, listToolsRequest(3), {
634
- "mcp-session-id": sid,
635
- Authorization: "Bearer garbage-invalid-token",
636
- });
637
- assert.equal(res.status, 401, `invalid bearer must 401 even with valid sid present, got ${res.status}`);
638
- });
639
- // ---------------------------------------------------------------------
640
- // Test 3: malformed sid without Authorization reaches the handler and
641
- // gets 404 (not 401). Proves the bypass keys off header PRESENCE, not
642
- // validity — malformed / expired / legacy sids must reach
643
- // handleStatelessMcpRequest to produce the intended "session ended"
644
- // signal per MCP Streamable HTTP.
645
- // ---------------------------------------------------------------------
646
- test("malformed sid without Authorization reaches handler → 404 (not 401)", async () => {
647
- const res = await post(urlA, listToolsRequest(4), {
648
- "mcp-session-id": "v1.sid.garbage.garbage.garbage",
649
- });
650
- assert.equal(res.status, 404, `malformed sid must reach handler and get 404, got ${res.status}: ${res.bodyText.slice(0, 200)}`);
651
- });
652
- // ---------------------------------------------------------------------
653
- // Test 4: DELETE /mcp is now gated by mcpBearerAuth. A sid-only DELETE
654
- // must REACH the handler body (not 401 from the middleware). In
655
- // stateless mode the DELETE handler doesn't track the sid in
656
- // streamableTransports, so the handler responds 404 "Session not found".
657
- // The critical assertion is !=401 from mcpBearerAuth.
658
- // ---------------------------------------------------------------------
659
- test("DELETE /mcp with sid-only reaches handler (not 401 from mcpBearerAuth)", async () => {
660
- // Use a freshly-minted, valid sealed sid so the presence check
661
- // succeeds and the middleware does not 401.
662
- const sealedSid = mintSessionId(material, {
663
- header: "Authorization",
664
- token: MOCK_OAUTH_TOKEN,
665
- apiUrl: `${mockGitLabUrl}/api/v4`,
666
- });
667
- const res = await fetch(urlA, {
668
- method: "DELETE",
669
- headers: { "mcp-session-id": sealedSid },
670
- });
671
- assert.notEqual(res.status, 401, `DELETE with sid must bypass mcpBearerAuth 401; got ${res.status}`);
672
- // Handler body returns 404 because the sid is not in streamableTransports.
673
- assert.equal(res.status, 404, `DELETE handler should respond 404 Session not found in stateless mode, got ${res.status}`);
674
- });
675
- });