@zereight/mcp-gitlab 2.1.25 → 2.1.27

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,104 @@
1
+ import { after, before, describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { createServer } from "node:http";
4
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
5
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
6
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
7
+ const MOCK_TOKEN = "glpat-dynamic-url-token";
8
+ async function startAttackerServer(port) {
9
+ let hits = 0;
10
+ const server = createServer((_req, res) => {
11
+ hits++;
12
+ res.writeHead(200, { "content-type": "application/json" });
13
+ res.end("[]");
14
+ });
15
+ await new Promise((resolve, reject) => {
16
+ server.once("error", reject);
17
+ server.listen(port, HOST, () => {
18
+ server.off("error", reject);
19
+ resolve();
20
+ });
21
+ });
22
+ return { server, getHits: () => hits };
23
+ }
24
+ describe("Dynamic API URL allowlist", () => {
25
+ let primaryGitLab;
26
+ let secondaryGitLab;
27
+ let attackerServer;
28
+ let mcpServer;
29
+ let mcpUrl;
30
+ let secondaryHit = false;
31
+ let getAttackerHits = () => 0;
32
+ before(async () => {
33
+ const primaryPort = await findMockServerPort(9100);
34
+ primaryGitLab = new MockGitLabServer({ port: primaryPort, validTokens: [MOCK_TOKEN] });
35
+ await primaryGitLab.start();
36
+ const secondaryPort = await findMockServerPort(9200);
37
+ secondaryGitLab = new MockGitLabServer({ port: secondaryPort, validTokens: [MOCK_TOKEN] });
38
+ secondaryGitLab.addMockHandler("get", "/projects/1/issues", (_req, res) => {
39
+ secondaryHit = true;
40
+ res.json([]);
41
+ });
42
+ await secondaryGitLab.start();
43
+ const attackerPort = await findAvailablePort(9300);
44
+ const attacker = await startAttackerServer(attackerPort);
45
+ attackerServer = attacker.server;
46
+ getAttackerHits = attacker.getHits;
47
+ const mcpPort = await findAvailablePort(3100);
48
+ mcpServer = await launchServer({
49
+ mode: TransportMode.STREAMABLE_HTTP,
50
+ port: mcpPort,
51
+ timeout: 5000,
52
+ env: {
53
+ STREAMABLE_HTTP: "true",
54
+ REMOTE_AUTHORIZATION: "true",
55
+ ENABLE_DYNAMIC_API_URL: "true",
56
+ GITLAB_API_URL: `${primaryGitLab.getUrl()}/api/v4`,
57
+ GITLAB_ALLOWED_HOSTS: `${secondaryGitLab.getUrl()}/api/v4`,
58
+ },
59
+ });
60
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
61
+ });
62
+ after(async () => {
63
+ if (mcpServer)
64
+ cleanupServers([mcpServer]);
65
+ await primaryGitLab?.stop();
66
+ await secondaryGitLab?.stop();
67
+ const server = attackerServer;
68
+ if (server)
69
+ await new Promise(resolve => server.close(() => resolve()));
70
+ });
71
+ test("allows dynamic API URLs on configured hosts", async () => {
72
+ const client = new CustomHeaderClient({
73
+ authorization: `Bearer ${MOCK_TOKEN}`,
74
+ "x-gitlab-api-url": `${secondaryGitLab.getUrl()}/api/v4`,
75
+ });
76
+ await client.connect(mcpUrl);
77
+ await client.callTool("list_issues", { project_id: "1" });
78
+ await client.disconnect();
79
+ assert.strictEqual(secondaryHit, true, "allowed dynamic host should receive GitLab calls");
80
+ });
81
+ test("rejects dynamic API URLs on unconfigured hosts before forwarding tokens", async () => {
82
+ const server = attackerServer;
83
+ assert.ok(server, "attacker server should be running");
84
+ const attackerUrl = `http://${HOST}:${server.address().port}/api/v4`;
85
+ const client = new CustomHeaderClient({
86
+ authorization: `Bearer ${MOCK_TOKEN}`,
87
+ "x-gitlab-api-url": attackerUrl,
88
+ });
89
+ let connected = false;
90
+ try {
91
+ await client.connect(mcpUrl);
92
+ connected = true;
93
+ await client.callTool("list_issues", { project_id: "1" });
94
+ }
95
+ catch {
96
+ // Expected: the session is rejected before any GitLab API request is made.
97
+ }
98
+ finally {
99
+ await client.disconnect();
100
+ }
101
+ assert.strictEqual(connected, false, "untrusted dynamic host should not initialize a session");
102
+ assert.strictEqual(getAttackerHits(), 0, "token-bearing requests must not reach untrusted hosts");
103
+ });
104
+ });
@@ -233,7 +233,7 @@ describe('Dynamic API URL - Connection Pool', () => {
233
233
  });
234
234
  servers.push(server);
235
235
  mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
236
- metricsUrl = `http://${HOST}:${mcpPort}/metrics`;
236
+ metricsUrl = `http://${HOST}:${mcpPort}/metrics.json`;
237
237
  console.log(`MCP Server: ${mcpUrl}`);
238
238
  console.log(`Metrics URL: ${metricsUrl}`);
239
239
  });
@@ -246,7 +246,7 @@ describe('Dynamic API URL - Connection Pool', () => {
246
246
  await mockGitLab2.stop();
247
247
  }
248
248
  });
249
- test('should track pool statistics via metrics endpoint', async () => {
249
+ test('should track pool statistics via metrics JSON endpoint', async () => {
250
250
  // Make some connections first
251
251
  const client1 = new CustomHeaderClient({
252
252
  'authorization': `Bearer ${MOCK_TOKEN_1}`,
@@ -262,7 +262,7 @@ describe('Dynamic API URL - Connection Pool', () => {
262
262
  await client2.listTools();
263
263
  // Check metrics
264
264
  const response = await fetch(metricsUrl);
265
- assert.ok(response.ok, 'Metrics endpoint should be accessible');
265
+ assert.ok(response.ok, 'Metrics JSON endpoint should be accessible');
266
266
  const metrics = await response.json();
267
267
  assert.ok(metrics.gitlabClientPool, 'Should have pool metrics');
268
268
  assert.ok(typeof metrics.gitlabClientPool.size === 'number', 'Should have pool size');
@@ -214,6 +214,44 @@ async function testPortAvailability() {
214
214
  // We just check that the function works, not the actual availability
215
215
  assert(typeof available === 'boolean', 'Port availability check should return boolean');
216
216
  }
217
+ // Test: External OAuth token script
218
+ async function testOAuthTokenScript() {
219
+ const scriptPath = path.join(process.cwd(), '.test-oauth-token-script.sh');
220
+ const writeScript = (output) => {
221
+ fs.writeFileSync(scriptPath, `#!/bin/sh\nprintf '%s\\n' '${output}'\n`, { mode: 0o700 });
222
+ };
223
+ const oauth = () => new GitLabOAuth({
224
+ clientId: TEST_CLIENT_ID,
225
+ redirectUri: TEST_REDIRECT_URI,
226
+ gitlabUrl: TEST_GITLAB_URL,
227
+ scopes: ['api'],
228
+ tokenStoragePath: TEST_TOKEN_PATH,
229
+ tokenScript: scriptPath,
230
+ });
231
+ try {
232
+ writeScript('script-token-123');
233
+ const plainToken = await oauth().getAccessToken();
234
+ assert(plainToken === 'script-token-123', 'Should return plain token from external script');
235
+ assert(oauth().hasValidToken(), 'Token script should count as a valid token source');
236
+ writeScript(JSON.stringify({ access_token: 'json-token-123' }));
237
+ const jsonToken = await oauth().getAccessToken();
238
+ assert(jsonToken === 'json-token-123', 'Should extract access_token from JSON output');
239
+ writeScript(JSON.stringify({ expires_in: 3600 }));
240
+ try {
241
+ await oauth().getAccessToken();
242
+ assert(false, 'Should reject JSON output without a token field');
243
+ }
244
+ catch (error) {
245
+ assert(error instanceof Error &&
246
+ error.message.includes('OAuth token script JSON must include a string access_token or token field'), 'Should explain missing token field in JSON output');
247
+ }
248
+ }
249
+ finally {
250
+ if (fs.existsSync(scriptPath)) {
251
+ fs.unlinkSync(scriptPath);
252
+ }
253
+ }
254
+ }
217
255
  // Test 11: OAuth redirect URI parsing
218
256
  async function testRedirectUriParsing() {
219
257
  const redirectUri = 'http://127.0.0.1:8888/callback';
@@ -332,6 +370,7 @@ async function runOAuthTests() {
332
370
  await runTest('hasValidToken returns false with expired token', testHasValidTokenExpired);
333
371
  await runTest('clearToken removes token file', testClearToken);
334
372
  await runTest('Token file has correct permissions', testTokenFilePermissions, process.platform === 'win32');
373
+ await runTest('External OAuth token script', testOAuthTokenScript, process.platform === 'win32');
335
374
  // Network and configuration tests
336
375
  await runTest('Port availability check', testPortAvailability);
337
376
  await runTest('OAuth redirect URI parsing', testRedirectUriParsing);
@@ -22,9 +22,9 @@ const TIMEOUT_TEST_WAIT_MS = SESSION_TIMEOUT_SECONDS * 1000 + TIMEOUT_BUFFER_MS;
22
22
  const KEEPALIVE_INTERVAL_MS = 2000; // Must be less than SESSION_TIMEOUT_SECONDS
23
23
  const KEEPALIVE_REQUEST_COUNT = 3; // Number of keepalive requests to test
24
24
  async function getMetrics(mcpUrl) {
25
- const metricsUrl = mcpUrl.replace(/\/mcp$/, '/metrics');
25
+ const metricsUrl = mcpUrl.replace(/\/mcp$/, '/metrics.json');
26
26
  const response = await fetch(metricsUrl);
27
- assert.strictEqual(response.status, 200, 'metrics endpoint should be available');
27
+ assert.strictEqual(response.status, 200, 'metrics JSON endpoint should be available');
28
28
  return (await response.json());
29
29
  }
30
30
  async function waitForSessionDecrease(mcpUrl, beforeTimeout, timeoutMs = 3000) {
@@ -125,6 +125,17 @@ describe('Remote Authorization - Basic Functionality', () => {
125
125
  console.log(' ✓ Multiple tool list calls successful with persistent auth');
126
126
  await client.disconnect();
127
127
  });
128
+ test('should expose Prometheus metrics at /metrics', async () => {
129
+ const metricsUrl = mcpUrl.replace(/\/mcp$/, '/metrics');
130
+ const response = await fetch(metricsUrl);
131
+ assert.strictEqual(response.status, 200, 'metrics endpoint should be available');
132
+ assert.match(response.headers.get('content-type') || '', /^text\/plain/);
133
+ const body = await response.text();
134
+ assert.match(body, /# HELP gitlab_mcp_requests_processed_total/);
135
+ assert.match(body, /# TYPE gitlab_mcp_active_sessions gauge/);
136
+ assert.match(body, /gitlab_mcp_config_info\{[^}]*remote_auth_enabled="true"/);
137
+ console.log(' ✓ Prometheus metrics available');
138
+ });
128
139
  test('should reject connection without auth header', async () => {
129
140
  const client = new CustomHeaderClient({});
130
141
  try {
@@ -771,6 +771,57 @@ function runGitLabMergeRequestSchemaTests() {
771
771
  },
772
772
  validate: (data) => data.labels === undefined,
773
773
  },
774
+ {
775
+ name: 'schema:gitlab_merge_request:preserves-merge-user',
776
+ input: {
777
+ ...baseMergeRequest,
778
+ state: 'merged',
779
+ merged_at: '2026-05-08T00:00:00.000Z',
780
+ merge_commit_sha: 'abc123',
781
+ merge_user: {
782
+ id: '7',
783
+ username: 'merger',
784
+ name: 'Merge Bot',
785
+ avatar_url: null,
786
+ web_url: 'https://gitlab.example.com/merger',
787
+ },
788
+ },
789
+ validate: (data) => data.merge_user?.id === '7' &&
790
+ data.merge_user?.username === 'merger' &&
791
+ data.merge_user?.name === 'Merge Bot',
792
+ },
793
+ {
794
+ name: 'schema:gitlab_merge_request:preserves-merged-by',
795
+ input: {
796
+ ...baseMergeRequest,
797
+ state: 'merged',
798
+ merged_by: {
799
+ id: '8',
800
+ username: 'legacy-merger',
801
+ name: 'Legacy Merger',
802
+ avatar_url: null,
803
+ web_url: 'https://gitlab.example.com/legacy-merger',
804
+ },
805
+ },
806
+ validate: (data) => data.merged_by?.id === '8' &&
807
+ data.merged_by?.username === 'legacy-merger',
808
+ },
809
+ {
810
+ name: 'schema:gitlab_merge_request:allows-null-merge-user',
811
+ input: {
812
+ ...baseMergeRequest,
813
+ merge_user: null,
814
+ merged_by: null,
815
+ },
816
+ validate: (data) => data.merge_user === null && data.merged_by === null,
817
+ },
818
+ {
819
+ name: 'schema:gitlab_merge_request:allows-omitted-merge-user',
820
+ input: {
821
+ ...baseMergeRequest,
822
+ },
823
+ validate: (data) => data.merge_user === undefined && data.merged_by === undefined,
824
+ },
774
825
  ];
775
826
  let passed = 0;
776
827
  let failed = 0;
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { afterEach, describe, test } from "node:test";
5
+ import * as path from "node:path";
6
+ import { findAvailablePort } from "./utils/server-launcher.js";
7
+ const ERROR_MESSAGE = "SSE=true on a non-loopback HOST requires SSE_AUTH_TOKEN (or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)";
8
+ const LOOPBACK = "127.0.0.1";
9
+ const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
10
+ const running = new Set();
11
+ function startSseServer(env, port) {
12
+ const child = spawn("node", [SERVER_PATH], {
13
+ env: {
14
+ ...process.env,
15
+ GITLAB_API_URL: "https://gitlab.example.com/api/v4",
16
+ HOST: "0.0.0.0",
17
+ PORT: String(port),
18
+ SSE: "true",
19
+ STREAMABLE_HTTP: "false",
20
+ REMOTE_AUTHORIZATION: "false",
21
+ GITLAB_MCP_OAUTH: "false",
22
+ GITLAB_USE_OAUTH: "false",
23
+ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
24
+ GITLAB_JOB_TOKEN: "",
25
+ GITLAB_AUTH_COOKIE_PATH: "",
26
+ ...env,
27
+ },
28
+ stdio: ["ignore", "pipe", "pipe"],
29
+ });
30
+ running.add(child);
31
+ child.once("exit", () => running.delete(child));
32
+ return child;
33
+ }
34
+ async function waitForExit(child, timeoutMs = 5000) {
35
+ let output = "";
36
+ child.stdout?.on("data", chunk => {
37
+ output += chunk.toString();
38
+ });
39
+ child.stderr?.on("data", chunk => {
40
+ output += chunk.toString();
41
+ });
42
+ let timeoutHandle;
43
+ const timeout = new Promise((_, reject) => {
44
+ timeoutHandle = setTimeout(() => reject(new Error(`server did not exit within ${timeoutMs}ms`)), timeoutMs);
45
+ });
46
+ try {
47
+ const [code] = (await Promise.race([once(child, "exit"), timeout]));
48
+ return { code, output };
49
+ }
50
+ finally {
51
+ clearTimeout(timeoutHandle);
52
+ }
53
+ }
54
+ async function waitForHealth(port, timeoutMs = 5000) {
55
+ const deadline = Date.now() + timeoutMs;
56
+ let lastError;
57
+ while (Date.now() < deadline) {
58
+ try {
59
+ const response = await fetch(`http://${LOOPBACK}:${port}/health`);
60
+ if (response.ok)
61
+ return;
62
+ }
63
+ catch (error) {
64
+ lastError = error;
65
+ }
66
+ await new Promise(resolve => setTimeout(resolve, 100));
67
+ }
68
+ throw new Error(`server did not become healthy: ${String(lastError)}`);
69
+ }
70
+ afterEach(() => {
71
+ for (const child of running) {
72
+ if (!child.killed)
73
+ child.kill("SIGTERM");
74
+ }
75
+ running.clear();
76
+ });
77
+ describe("SSE remote auth guard", () => {
78
+ test("refuses unauthenticated SSE on non-loopback hosts", async () => {
79
+ const port = await findAvailablePort(4400);
80
+ const child = startSseServer({}, port);
81
+ const { code, output } = await waitForExit(child);
82
+ assert.notEqual(code, 0);
83
+ assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
84
+ });
85
+ test("requires bearer auth on SSE endpoints when SSE_AUTH_TOKEN is configured", async () => {
86
+ const port = await findAvailablePort(4410);
87
+ startSseServer({ SSE_AUTH_TOKEN: "mcp_sse_secret" }, port);
88
+ await waitForHealth(port);
89
+ const sseResponse = await fetch(`http://${LOOPBACK}:${port}/sse`);
90
+ assert.equal(sseResponse.status, 401);
91
+ const unauthenticatedMessage = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=missing`, { method: "POST" });
92
+ assert.equal(unauthenticatedMessage.status, 401);
93
+ const authenticatedMessage = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=missing`, { method: "POST", headers: { Authorization: "Bearer mcp_sse_secret" } });
94
+ assert.equal(authenticatedMessage.status, 400);
95
+ });
96
+ });
@@ -0,0 +1,92 @@
1
+ import { after, before, describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { Buffer } from "node:buffer";
4
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
5
+ import { findMockServerPort, MockGitLabServer } from "./utils/mock-gitlab-server.js";
6
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
7
+ const MOCK_TOKEN = "mock-concurrent-token-12345";
8
+ const TEST_PROJECT_ID = "123";
9
+ function fileResponse(filePath, content) {
10
+ return {
11
+ file_name: filePath.split("/").at(-1),
12
+ file_path: filePath,
13
+ encoding: "base64",
14
+ content: Buffer.from(content, "utf8").toString("base64"),
15
+ };
16
+ }
17
+ describe("Streamable HTTP concurrent session requests", { timeout: 20_000 }, () => {
18
+ let mockGitLab;
19
+ let server;
20
+ let mcpUrl;
21
+ before(async () => {
22
+ const mockPort = await findMockServerPort(9360);
23
+ mockGitLab = new MockGitLabServer({
24
+ port: mockPort,
25
+ validTokens: [MOCK_TOKEN],
26
+ responseDelay: 100,
27
+ });
28
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/package.json`, (_req, res) => {
29
+ res.json(fileResponse("package.json", "package file"));
30
+ });
31
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/README.md`, (_req, res) => {
32
+ res.json(fileResponse("README.md", "readme file"));
33
+ });
34
+ await mockGitLab.start();
35
+ const port = await findAvailablePort(3460);
36
+ server = await launchServer({
37
+ mode: TransportMode.STREAMABLE_HTTP,
38
+ port,
39
+ timeout: 10_000,
40
+ env: {
41
+ STREAMABLE_HTTP: "true",
42
+ REMOTE_AUTHORIZATION: "true",
43
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
44
+ },
45
+ });
46
+ mcpUrl = `http://${HOST}:${port}/mcp`;
47
+ });
48
+ after(async () => {
49
+ cleanupServers([server]);
50
+ if (mockGitLab)
51
+ await mockGitLab.stop();
52
+ });
53
+ test("handles concurrent SDK tool calls with the same Mcp-Session-Id", async () => {
54
+ const client = new CustomHeaderClient({ Authorization: `Bearer ${MOCK_TOKEN}` });
55
+ await client.connect(mcpUrl);
56
+ try {
57
+ const initialSessionId = client.getSessionId();
58
+ assert.ok(initialSessionId, "initialize should return Mcp-Session-Id");
59
+ let timeoutHandle;
60
+ try {
61
+ const [packageResult, readmeResult] = await Promise.race([
62
+ Promise.all([
63
+ client.callTool("get_file_contents", {
64
+ project_id: TEST_PROJECT_ID,
65
+ file_path: "package.json",
66
+ ref: "main",
67
+ }),
68
+ client.callTool("get_file_contents", {
69
+ project_id: TEST_PROJECT_ID,
70
+ file_path: "README.md",
71
+ ref: "main",
72
+ }),
73
+ ]),
74
+ new Promise((_, reject) => {
75
+ timeoutHandle = setTimeout(() => reject(new Error("concurrent tool calls timed out")), 5_000);
76
+ }),
77
+ ]);
78
+ const packageText = packageResult.content?.[0]?.type === "text" ? packageResult.content[0].text : "";
79
+ const readmeText = readmeResult.content?.[0]?.type === "text" ? readmeResult.content[0].text : "";
80
+ assert.strictEqual(client.getSessionId(), initialSessionId, "concurrent calls should reuse the same Mcp-Session-Id");
81
+ assert.ok(packageText.includes("package file"), packageText);
82
+ assert.ok(readmeText.includes("readme file"), readmeText);
83
+ }
84
+ finally {
85
+ clearTimeout(timeoutHandle);
86
+ }
87
+ }
88
+ finally {
89
+ await client.disconnect();
90
+ }
91
+ });
92
+ });
@@ -0,0 +1,113 @@
1
+ import { after, describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
4
+ async function rawMcpRequest(url, body, headers = {}) {
5
+ const response = await fetch(url, {
6
+ method: "POST",
7
+ headers: {
8
+ "Content-Type": "application/json",
9
+ Accept: "application/json, text/event-stream",
10
+ ...headers,
11
+ },
12
+ body: JSON.stringify(body),
13
+ });
14
+ const sessionId = response.headers.get("mcp-session-id");
15
+ if (response.status === 202 || response.status === 204) {
16
+ return { status: response.status, data: null, sessionId, text: "" };
17
+ }
18
+ const text = await response.text();
19
+ const contentType = response.headers.get("content-type") ?? "";
20
+ if (contentType.includes("text/event-stream")) {
21
+ const dataLines = text
22
+ .split("\n")
23
+ .filter(line => line.startsWith("data: "))
24
+ .map(line => line.slice(6));
25
+ return {
26
+ status: response.status,
27
+ data: dataLines.length > 0 ? JSON.parse(dataLines.at(-1)) : null,
28
+ sessionId,
29
+ text,
30
+ };
31
+ }
32
+ return {
33
+ status: response.status,
34
+ data: text ? JSON.parse(text) : null,
35
+ sessionId,
36
+ text,
37
+ };
38
+ }
39
+ async function initialize(mcpUrl) {
40
+ const response = await rawMcpRequest(mcpUrl, {
41
+ jsonrpc: "2.0",
42
+ id: 1,
43
+ method: "initialize",
44
+ params: {
45
+ protocolVersion: "2025-03-26",
46
+ capabilities: {},
47
+ clientInfo: { name: "unauth-discovery-test", version: "1.0.0" },
48
+ },
49
+ });
50
+ assert.strictEqual(response.status, 200, response.text);
51
+ assert.ok(response.sessionId, "initialize should return Mcp-Session-Id");
52
+ return response.sessionId;
53
+ }
54
+ let portOffset = 0;
55
+ async function launchRemoteAuthServer(extraEnv = {}) {
56
+ const port = await findAvailablePort(3470 + portOffset++ * 10);
57
+ const server = await launchServer({
58
+ mode: TransportMode.STREAMABLE_HTTP,
59
+ port,
60
+ timeout: 10_000,
61
+ env: {
62
+ STREAMABLE_HTTP: "true",
63
+ REMOTE_AUTHORIZATION: "true",
64
+ GITLAB_API_URL: "https://gitlab.example.com/api/v4",
65
+ ...extraEnv,
66
+ },
67
+ });
68
+ return { server, mcpUrl: `http://${HOST}:${port}/mcp` };
69
+ }
70
+ describe("Streamable HTTP unauthenticated tool discovery", { timeout: 20_000 }, () => {
71
+ let servers = [];
72
+ after(() => {
73
+ cleanupServers(servers);
74
+ servers = [];
75
+ });
76
+ test("keeps unauthenticated tools/list blocked by default", async () => {
77
+ const { server, mcpUrl } = await launchRemoteAuthServer();
78
+ servers.push(server);
79
+ const sessionId = await initialize(mcpUrl);
80
+ const listResponse = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sessionId });
81
+ assert.strictEqual(listResponse.status, 401, listResponse.text);
82
+ });
83
+ test("allows unauthenticated tools/list when explicitly enabled", async () => {
84
+ const { server, mcpUrl } = await launchRemoteAuthServer({
85
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
86
+ });
87
+ servers.push(server);
88
+ const sessionId = await initialize(mcpUrl);
89
+ const initialized = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", method: "notifications/initialized" }, { "mcp-session-id": sessionId });
90
+ assert.ok([200, 202, 204].includes(initialized.status), initialized.text);
91
+ const listResponse = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sessionId });
92
+ assert.strictEqual(listResponse.status, 200, listResponse.text);
93
+ assert.ok(Array.isArray(listResponse.data.result?.tools), "tools/list should return tools");
94
+ assert.ok(listResponse.data.result.tools.length > 0, "tools/list should not be empty");
95
+ });
96
+ test("still blocks unauthenticated tools/call when discovery is enabled", async () => {
97
+ const { server, mcpUrl } = await launchRemoteAuthServer({
98
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
99
+ });
100
+ servers.push(server);
101
+ const sessionId = await initialize(mcpUrl);
102
+ const callResponse = await rawMcpRequest(mcpUrl, {
103
+ jsonrpc: "2.0",
104
+ id: 3,
105
+ method: "tools/call",
106
+ params: {
107
+ name: "get_project",
108
+ arguments: { project_id: "123" },
109
+ },
110
+ }, { "mcp-session-id": sessionId });
111
+ assert.strictEqual(callResponse.status, 401, callResponse.text);
112
+ });
113
+ });