@zereight/mcp-gitlab 2.1.5 → 2.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +442 -0
- package/README.md +12 -0
- package/README.zh-CN.md +442 -0
- package/build/config.js +65 -2
- package/build/index.js +348 -5
- package/build/oauth-proxy.js +182 -47
- package/build/stateless/client-id.js +68 -0
- package/build/stateless/codec.js +205 -0
- package/build/stateless/errors.js +24 -0
- package/build/stateless/index.js +14 -0
- package/build/stateless/pending-auth.js +65 -0
- package/build/stateless/secret.js +98 -0
- package/build/stateless/session-id.js +68 -0
- package/build/stateless/stored-tokens.js +66 -0
- package/build/stateless/types.js +18 -0
- package/build/test/stateless/callback-proxy.test.js +393 -0
- package/build/test/stateless/client-id.test.js +176 -0
- package/build/test/stateless/codec.test.js +328 -0
- package/build/test/stateless/config-ttl.test.js +149 -0
- package/build/test/stateless/session-id-integration.test.js +675 -0
- package/build/test/stateless/session-id.test.js +131 -0
- package/package.json +3 -2
|
@@ -0,0 +1,675 @@
|
|
|
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(MOCK_PORT_BASE);
|
|
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(MOCK_PORT_BASE + 100);
|
|
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(OAUTH_MOCK_PORT_BASE);
|
|
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
|
+
});
|