@zereight/mcp-gitlab 2.1.28 → 2.1.30

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 (72) hide show
  1. package/README.ko.md +82 -28
  2. package/README.md +56 -23
  3. package/README.zh-CN.md +82 -28
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +564 -482
  7. package/build/oauth-proxy.js +18 -4
  8. package/build/schemas.js +47 -9
  9. package/build/scripts/check-skill-sync.js +137 -0
  10. package/build/scripts/generate-tool-docs.js +7 -2
  11. package/build/server/metrics.js +84 -0
  12. package/build/server/transport-mode.js +25 -0
  13. package/build/test/callback-proxy-tests.js +1 -1
  14. package/build/test/client-pool-test.js +1 -1
  15. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  16. package/build/test/dynamic-api-url-test.js +4 -4
  17. package/build/test/dynamic-routing-tests.js +4 -4
  18. package/build/test/mcp-oauth-tests.js +17 -4
  19. package/build/test/multi-server-test.js +3 -3
  20. package/build/test/no-proxy-integration-test.js +1 -1
  21. package/build/test/path-segment-encoding.test.js +27 -0
  22. package/build/test/remote-auth-simple-test.js +226 -199
  23. package/build/test/server/metrics.test.js +47 -0
  24. package/build/test/sse-auth-guard.test.js +2 -2
  25. package/build/test/stateless/callback-proxy.test.js +8 -0
  26. package/build/test/stateless/session-id-integration.test.js +3 -3
  27. package/build/test/streamable-http-concurrent-session.test.js +1 -1
  28. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  29. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  30. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  31. package/build/test/test-ci-catalog.js +1 -1
  32. package/build/test/test-ci-lint.js +1 -1
  33. package/build/test/test-ci-variables.js +8 -5
  34. package/build/test/test-dependency-proxy.js +11 -7
  35. package/build/test/test-deployment-tools.js +16 -2
  36. package/build/test/test-download-attachment.js +1 -1
  37. package/build/test/test-get-file-blame.js +1 -1
  38. package/build/test/test-geteffectiveprojectid.js +7 -7
  39. package/build/test/test-issue-description-patch.js +1 -1
  40. package/build/test/test-job-artifacts.js +1 -1
  41. package/build/test/test-list-issues.js +1 -1
  42. package/build/test/test-list-merge-requests.js +1 -1
  43. package/build/test/test-list-project-members.js +1 -1
  44. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  45. package/build/test/test-merge-request-pipelines.js +1 -1
  46. package/build/test/test-mr-diffs-filter.js +1 -1
  47. package/build/test/test-mr-file-diffs.js +2 -2
  48. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  49. package/build/test/test-permission-mode.js +215 -0
  50. package/build/test/test-protected-branches.js +1 -1
  51. package/build/test/test-remote-downloads.js +2 -2
  52. package/build/test/test-search-code.js +1 -1
  53. package/build/test/test-tags.js +1 -1
  54. package/build/test/test-todos.js +1 -1
  55. package/build/test/test-token-optimizations.js +3 -3
  56. package/build/test/test-toolset-filtering.js +1 -1
  57. package/build/test/test-update-issue-slim.js +141 -0
  58. package/build/test/test-upload-markdown.js +1 -1
  59. package/build/test/utils/download-token.test.js +35 -0
  60. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  61. package/build/test/utils/graphql-query.test.js +64 -1
  62. package/build/test/utils/mock-gitlab-server.js +24 -31
  63. package/build/test/utils/redact-sensitive.test.js +32 -0
  64. package/build/test/utils/server-launcher.js +1 -2
  65. package/build/test/utils/version-check.test.js +52 -0
  66. package/build/tools/registry.js +30 -5
  67. package/build/utils/download-token.js +71 -0
  68. package/build/utils/forwarded-public-base-url.js +22 -0
  69. package/build/utils/graphql-query.js +109 -12
  70. package/build/utils/redact-sensitive.js +21 -0
  71. package/build/utils/version-check.js +40 -0
  72. package/package.json +5 -4
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { test } from "node:test";
4
+ const source = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
5
+ const rawPathSegmentPatterns = [
6
+ "/issues/${issueIid}",
7
+ "/merge_requests/${mergeRequestIid}",
8
+ "/discussions/${discussionId}",
9
+ "/notes/${noteId}",
10
+ "/links/${issueLinkId}",
11
+ "/draft_notes/${draftNoteId}",
12
+ "/versions/${versionId}",
13
+ "/groups/${groupId}",
14
+ ];
15
+ test("GitLab URL path IDs are encoded before interpolation", () => {
16
+ for (const pattern of rawPathSegmentPatterns) {
17
+ assert.equal(source.includes(pattern), false, `${pattern} must use encodeGitLabPathSegment`);
18
+ }
19
+ });
20
+ test("encoding keeps path traversal payloads inside one URL segment", () => {
21
+ const payload = "1/../../../../admin/ci/variables";
22
+ const url = new URL(`https://gitlab.com/api/v4/projects/42/issues/${encodeURIComponent(payload)}/notes`);
23
+ assert.equal(url.pathname, "/api/v4/projects/42/issues/1%2F..%2F..%2F..%2F..%2Fadmin%2Fci%2Fvariables/notes");
24
+ });
25
+ test("malformed percent-encoded segments can still be encoded", () => {
26
+ assert.equal(encodeURIComponent("bad%zz/path"), "bad%25zz%2Fpath");
27
+ });
@@ -43,222 +43,249 @@ async function waitForSessionDecrease(mcpUrl, beforeTimeout, timeoutMs = 3000) {
43
43
  }
44
44
  console.log('🔐 Remote Authorization Test Suite');
45
45
  console.log('');
46
- describe('Remote Authorization - Basic Functionality', () => {
47
- let mcpUrl;
48
- let mockGitLab;
49
- let servers = [];
50
- before(async () => {
51
- // Start mock GitLab server
52
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
53
- mockGitLab = new MockGitLabServer({
54
- port: mockPort,
55
- validTokens: [MOCK_TOKEN, MOCK_JOB_TOKEN],
46
+ describe('Remote Authorization', { concurrency: 1 }, () => {
47
+ describe('Remote Authorization - Basic Functionality', () => {
48
+ let mcpUrl;
49
+ let mockGitLab;
50
+ let servers = [];
51
+ before(async () => {
52
+ // Start mock GitLab server
53
+ const mockPort = await findMockServerPort();
54
+ mockGitLab = new MockGitLabServer({
55
+ port: mockPort,
56
+ validTokens: [MOCK_TOKEN, MOCK_JOB_TOKEN],
57
+ });
58
+ await mockGitLab.start();
59
+ const mockGitLabUrl = mockGitLab.getUrl();
60
+ // Start MCP server with remote auth
61
+ const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
62
+ const server = await launchServer({
63
+ mode: TransportMode.STREAMABLE_HTTP,
64
+ port: mcpPort,
65
+ timeout: 5000,
66
+ env: {
67
+ STREAMABLE_HTTP: 'true',
68
+ REMOTE_AUTHORIZATION: 'true',
69
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
70
+ GITLAB_READ_ONLY_MODE: 'true',
71
+ }
72
+ });
73
+ servers.push(server);
74
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
75
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
76
+ console.log(`MCP Server: ${mcpUrl}`);
56
77
  });
57
- await mockGitLab.start();
58
- const mockGitLabUrl = mockGitLab.getUrl();
59
- // Start MCP server with remote auth
60
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
61
- const server = await launchServer({
62
- mode: TransportMode.STREAMABLE_HTTP,
63
- port: mcpPort,
64
- timeout: 5000,
65
- env: {
66
- STREAMABLE_HTTP: 'true',
67
- REMOTE_AUTHORIZATION: 'true',
68
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
69
- GITLAB_READ_ONLY_MODE: 'true',
78
+ after(async () => {
79
+ cleanupServers(servers);
80
+ if (mockGitLab) {
81
+ await mockGitLab.stop();
70
82
  }
71
83
  });
72
- servers.push(server);
73
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
74
- console.log(`Mock GitLab: ${mockGitLabUrl}`);
75
- console.log(`MCP Server: ${mcpUrl}`);
76
- });
77
- after(async () => {
78
- cleanupServers(servers);
79
- if (mockGitLab) {
80
- await mockGitLab.stop();
81
- }
82
- });
83
- test('should connect with Authorization Bearer header', async () => {
84
- const client = new CustomHeaderClient({
85
- 'authorization': `Bearer ${MOCK_TOKEN}`
84
+ test('should reject invalid tokens before creating a session', async () => {
85
+ const before = await getMetrics(mcpUrl);
86
+ const response = await fetch(mcpUrl, {
87
+ method: 'POST',
88
+ headers: {
89
+ 'Content-Type': 'application/json',
90
+ Accept: 'application/json, text/event-stream',
91
+ 'Private-Token': 'aaaaaaaaaaaaaaaaaaaaaaaa',
92
+ },
93
+ body: JSON.stringify({
94
+ jsonrpc: '2.0',
95
+ id: 1,
96
+ method: 'initialize',
97
+ params: {
98
+ protocolVersion: '2025-03-26',
99
+ capabilities: {},
100
+ clientInfo: { name: 'invalid-token-test', version: '1.0.0' },
101
+ },
102
+ }),
103
+ });
104
+ assert.strictEqual(response.status, 401);
105
+ const after = await getMetrics(mcpUrl);
106
+ assert.strictEqual(after.activeSessions, before.activeSessions);
107
+ assert.strictEqual(after.authenticatedSessions, before.authenticatedSessions);
86
108
  });
87
- await client.connect(mcpUrl);
88
- const tools = await client.listTools();
89
- assert.ok(tools.tools.length > 0, 'Should have tools');
90
- console.log(` ✓ Connected successfully, got ${tools.tools.length} tools`);
91
- await client.disconnect();
92
- });
93
- test('should connect with Private-Token header', async () => {
94
- const client = new CustomHeaderClient({
95
- 'private-token': MOCK_TOKEN
109
+ test('should connect with Authorization Bearer header', async () => {
110
+ const client = new CustomHeaderClient({
111
+ 'authorization': `Bearer ${MOCK_TOKEN}`
112
+ });
113
+ await client.connect(mcpUrl);
114
+ const tools = await client.listTools();
115
+ assert.ok(tools.tools.length > 0, 'Should have tools');
116
+ console.log(` ✓ Connected successfully, got ${tools.tools.length} tools`);
117
+ await client.disconnect();
96
118
  });
97
- await client.connect(mcpUrl);
98
- const tools = await client.listTools();
99
- assert.ok(tools.tools.length > 0, 'Should have tools');
100
- console.log(` ✓ Connected with Private-Token, got ${tools.tools.length} tools`);
101
- await client.disconnect();
102
- });
103
- test('should connect with JOB-TOKEN header', async () => {
104
- const client = new CustomHeaderClient({
105
- 'job-token': MOCK_JOB_TOKEN,
119
+ test('should connect with Private-Token header', async () => {
120
+ const client = new CustomHeaderClient({
121
+ 'private-token': MOCK_TOKEN
122
+ });
123
+ await client.connect(mcpUrl);
124
+ const tools = await client.listTools();
125
+ assert.ok(tools.tools.length > 0, 'Should have tools');
126
+ console.log(` ✓ Connected with Private-Token, got ${tools.tools.length} tools`);
127
+ await client.disconnect();
106
128
  });
107
- await client.connect(mcpUrl);
108
- const tools = await client.listTools();
109
- assert.ok(tools.tools.length > 0, 'Should have tools');
110
- console.log(` ✓ Connected with JOB-TOKEN, got ${tools.tools.length} tools`);
111
- await client.disconnect();
112
- });
113
- test('should successfully call listTools with auth', async () => {
114
- const client = new CustomHeaderClient({
115
- 'authorization': `Bearer ${MOCK_TOKEN}`
129
+ test('should connect with JOB-TOKEN header', async () => {
130
+ const client = new CustomHeaderClient({
131
+ 'job-token': MOCK_JOB_TOKEN,
132
+ });
133
+ await client.connect(mcpUrl);
134
+ const tools = await client.listTools();
135
+ assert.ok(tools.tools.length > 0, 'Should have tools');
136
+ console.log(` ✓ Connected with JOB-TOKEN, got ${tools.tools.length} tools`);
137
+ await client.disconnect();
116
138
  });
117
- await client.connect(mcpUrl);
118
- // List tools multiple times to verify auth persists
119
- const tools1 = await client.listTools();
120
- const tools2 = await client.listTools();
121
- const tools3 = await client.listTools();
122
- assert.ok(tools1.tools.length > 0, 'Should have tools');
123
- assert.strictEqual(tools1.tools.length, tools2.tools.length, 'Tool count should be consistent');
124
- assert.strictEqual(tools2.tools.length, tools3.tools.length, 'Tool count should be consistent');
125
- console.log(' ✓ Multiple tool list calls successful with persistent auth');
126
- await client.disconnect();
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
- });
139
- test('should reject connection without auth header', async () => {
140
- const client = new CustomHeaderClient({});
141
- try {
139
+ test('should successfully call listTools with auth', async () => {
140
+ const client = new CustomHeaderClient({
141
+ 'authorization': `Bearer ${MOCK_TOKEN}`
142
+ });
142
143
  await client.connect(mcpUrl);
143
- await client.listTools();
144
+ // List tools multiple times to verify auth persists
145
+ const tools1 = await client.listTools();
146
+ const tools2 = await client.listTools();
147
+ const tools3 = await client.listTools();
148
+ assert.ok(tools1.tools.length > 0, 'Should have tools');
149
+ assert.strictEqual(tools1.tools.length, tools2.tools.length, 'Tool count should be consistent');
150
+ assert.strictEqual(tools2.tools.length, tools3.tools.length, 'Tool count should be consistent');
151
+ console.log(' ✓ Multiple tool list calls successful with persistent auth');
144
152
  await client.disconnect();
145
- assert.fail('Should have rejected connection without auth');
146
- }
147
- catch (error) {
148
- assert.ok(error instanceof Error);
149
- console.log(' ✓ Correctly rejected connection without auth');
150
- }
151
- });
152
- });
153
- describe('Remote Authorization - Session Timeout', () => {
154
- let mcpUrl;
155
- let mockGitLab;
156
- let servers = [];
157
- before(async () => {
158
- // Use different port ranges to avoid collisions with basic tests
159
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + MOCK_GITLAB_PORT_OFFSET);
160
- mockGitLab = new MockGitLabServer({
161
- port: mockPort,
162
- validTokens: [MOCK_TOKEN]
163
153
  });
164
- await mockGitLab.start();
165
- const mockGitLabUrl = mockGitLab.getUrl();
166
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + MCP_SERVER_PORT_OFFSET);
167
- const server = await launchServer({
168
- mode: TransportMode.STREAMABLE_HTTP,
169
- port: mcpPort,
170
- timeout: 5000,
171
- env: {
172
- STREAMABLE_HTTP: 'true',
173
- REMOTE_AUTHORIZATION: 'true',
174
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
175
- SESSION_TIMEOUT_SECONDS: String(SESSION_TIMEOUT_SECONDS),
154
+ test('should expose Prometheus metrics at /metrics', async () => {
155
+ const metricsUrl = mcpUrl.replace(/\/mcp$/, '/metrics');
156
+ const response = await fetch(metricsUrl);
157
+ assert.strictEqual(response.status, 200, 'metrics endpoint should be available');
158
+ assert.match(response.headers.get('content-type') || '', /^text\/plain/);
159
+ const body = await response.text();
160
+ assert.match(body, /# HELP gitlab_mcp_requests_processed_total/);
161
+ assert.match(body, /# TYPE gitlab_mcp_active_sessions gauge/);
162
+ assert.match(body, /gitlab_mcp_config_info\{[^}]*remote_auth_enabled="true"/);
163
+ console.log(' ✓ Prometheus metrics available');
164
+ });
165
+ test('should reject connection without auth header', async () => {
166
+ const client = new CustomHeaderClient({});
167
+ try {
168
+ await client.connect(mcpUrl);
169
+ await client.listTools();
170
+ await client.disconnect();
171
+ assert.fail('Should have rejected connection without auth');
172
+ }
173
+ catch (error) {
174
+ assert.ok(error instanceof Error);
175
+ console.log(' ✓ Correctly rejected connection without auth');
176
176
  }
177
177
  });
178
- servers.push(server);
179
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
180
- console.log(`Session timeout: ${SESSION_TIMEOUT_SECONDS} seconds`);
181
- });
182
- after(async () => {
183
- cleanupServers(servers);
184
- if (mockGitLab) {
185
- await mockGitLab.stop();
186
- }
187
178
  });
188
- test('session timeout verification - activity keeps session alive', async () => {
189
- const client = new CustomHeaderClient({
190
- 'authorization': `Bearer ${MOCK_TOKEN}`
179
+ describe('Remote Authorization - Session Timeout', () => {
180
+ let mcpUrl;
181
+ let mockGitLab;
182
+ let servers = [];
183
+ before(async () => {
184
+ // Use different port ranges to avoid collisions with basic tests
185
+ const mockPort = await findMockServerPort();
186
+ mockGitLab = new MockGitLabServer({
187
+ port: mockPort,
188
+ validTokens: [MOCK_TOKEN]
189
+ });
190
+ await mockGitLab.start();
191
+ const mockGitLabUrl = mockGitLab.getUrl();
192
+ const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + MCP_SERVER_PORT_OFFSET);
193
+ const server = await launchServer({
194
+ mode: TransportMode.STREAMABLE_HTTP,
195
+ port: mcpPort,
196
+ timeout: 5000,
197
+ env: {
198
+ STREAMABLE_HTTP: 'true',
199
+ REMOTE_AUTHORIZATION: 'true',
200
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
201
+ SESSION_TIMEOUT_SECONDS: String(SESSION_TIMEOUT_SECONDS),
202
+ }
203
+ });
204
+ servers.push(server);
205
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
206
+ console.log(`Session timeout: ${SESSION_TIMEOUT_SECONDS} seconds`);
191
207
  });
192
- // Connect and verify it works
193
- await client.connect(mcpUrl);
194
- const tools1 = await client.listTools();
195
- assert.ok(tools1.tools.length > 0, 'Initial connection should work');
196
- console.log(' ✓ Initial connection successful');
197
- // Keep connection alive with periodic requests
198
- console.log(' Keeping session alive with requests...');
199
- for (let i = 0; i < KEEPALIVE_REQUEST_COUNT; i++) {
200
- await new Promise(resolve => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
201
- const tools = await client.listTools();
202
- assert.ok(tools.tools.length > 0, `Request ${i + 1} should succeed`);
203
- console.log(` ✓ Request ${i + 1} succeeded (session still alive)`);
204
- }
205
- await client.disconnect();
206
- console.log(' ✓ Session remained active with periodic requests');
207
- });
208
- test('session timeout expiration - inactivity expires auth', async () => {
209
- // Add a small delay to ensure server is ready/clean from previous test
210
- await new Promise(resolve => setTimeout(resolve, 1000));
211
- const baseline = await getMetrics(mcpUrl);
212
- // Step 1: Connect WITH auth header to establish session
213
- const clientWithAuth = new CustomHeaderClient({
214
- 'authorization': `Bearer ${MOCK_TOKEN}`
208
+ after(async () => {
209
+ cleanupServers(servers);
210
+ if (mockGitLab) {
211
+ await mockGitLab.stop();
212
+ }
215
213
  });
216
- await clientWithAuth.connect(mcpUrl);
217
- const tools1 = await clientWithAuth.listTools();
218
- assert.ok(tools1.tools.length > 0, 'Initial connection should work');
219
- console.log(' ✓ Initial connection successful with auth');
220
- // Extract session ID using proper API
221
- const sessionId = clientWithAuth.getSessionId();
222
- assert.ok(sessionId, 'Session ID should exist');
223
- console.log(` ℹ️ Session ID: ${sessionId}`);
224
- const beforeTimeout = await getMetrics(mcpUrl);
225
- assert.strictEqual(beforeTimeout.activeSessions, baseline.activeSessions + 1, 'Session should occupy one additional active slot');
226
- assert.strictEqual(beforeTimeout.authenticatedSessions, baseline.authenticatedSessions + 1, 'Session should add one authenticated session');
227
- // Step 2: Wait for timeout WITHOUT making any requests
228
- console.log(` ⏳ Waiting ${TIMEOUT_TEST_WAIT_MS / 1000}s for timeout without activity...`);
229
- await new Promise(resolve => setTimeout(resolve, TIMEOUT_TEST_WAIT_MS));
230
- await waitForSessionDecrease(mcpUrl, beforeTimeout);
231
- console.log(' ✓ Timeout closed transport and released active session slot');
232
- // Step 3: Try to make request WITHOUT auth header - should fail with 401
233
- try {
234
- const response = await fetch(mcpUrl, {
235
- method: 'POST',
236
- headers: {
237
- 'Content-Type': 'application/json',
238
- 'mcp-session-id': sessionId,
239
- },
240
- body: JSON.stringify({
241
- jsonrpc: '2.0',
242
- id: 1,
243
- method: 'tools/list'
244
- })
214
+ test('session timeout verification - activity keeps session alive', async () => {
215
+ const client = new CustomHeaderClient({
216
+ 'authorization': `Bearer ${MOCK_TOKEN}`
245
217
  });
246
- if (!response.ok) {
247
- assert.strictEqual(response.status, 401, 'Should return 401 Unauthorized');
248
- console.log(` ✓ Request correctly failed with status ${response.status}`);
249
- const body = await response.text();
250
- console.log(` ℹ️ Error: ${body.substring(0, 100)}...`);
218
+ // Connect and verify it works
219
+ await client.connect(mcpUrl);
220
+ const tools1 = await client.listTools();
221
+ assert.ok(tools1.tools.length > 0, 'Initial connection should work');
222
+ console.log(' ✓ Initial connection successful');
223
+ // Keep connection alive with periodic requests
224
+ console.log(' Keeping session alive with requests...');
225
+ for (let i = 0; i < KEEPALIVE_REQUEST_COUNT; i++) {
226
+ await new Promise(resolve => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
227
+ const tools = await client.listTools();
228
+ assert.ok(tools.tools.length > 0, `Request ${i + 1} should succeed`);
229
+ console.log(` ✓ Request ${i + 1} succeeded (session still alive)`);
251
230
  }
252
- else {
253
- assert.fail('Should have failed due to expired auth token');
231
+ await client.disconnect();
232
+ console.log(' ✓ Session remained active with periodic requests');
233
+ });
234
+ test('session timeout expiration - inactivity expires auth', async () => {
235
+ // Add a small delay to ensure server is ready/clean from previous test
236
+ await new Promise(resolve => setTimeout(resolve, 1000));
237
+ const baseline = await getMetrics(mcpUrl);
238
+ // Step 1: Connect WITH auth header to establish session
239
+ const clientWithAuth = new CustomHeaderClient({
240
+ 'authorization': `Bearer ${MOCK_TOKEN}`
241
+ });
242
+ await clientWithAuth.connect(mcpUrl);
243
+ const tools1 = await clientWithAuth.listTools();
244
+ assert.ok(tools1.tools.length > 0, 'Initial connection should work');
245
+ console.log(' ✓ Initial connection successful with auth');
246
+ // Extract session ID using proper API
247
+ const sessionId = clientWithAuth.getSessionId();
248
+ assert.ok(sessionId, 'Session ID should exist');
249
+ console.log(` ℹ️ Session ID: ${sessionId}`);
250
+ const beforeTimeout = await getMetrics(mcpUrl);
251
+ assert.strictEqual(beforeTimeout.activeSessions, baseline.activeSessions + 1, 'Session should occupy one additional active slot');
252
+ assert.strictEqual(beforeTimeout.authenticatedSessions, baseline.authenticatedSessions + 1, 'Session should add one authenticated session');
253
+ // Step 2: Wait for timeout WITHOUT making any requests
254
+ console.log(` ⏳ Waiting ${TIMEOUT_TEST_WAIT_MS / 1000}s for timeout without activity...`);
255
+ await new Promise(resolve => setTimeout(resolve, TIMEOUT_TEST_WAIT_MS));
256
+ await waitForSessionDecrease(mcpUrl, beforeTimeout);
257
+ console.log(' ✓ Timeout closed transport and released active session slot');
258
+ // Step 3: Try to make request WITHOUT auth header - should fail with 401
259
+ try {
260
+ const response = await fetch(mcpUrl, {
261
+ method: 'POST',
262
+ headers: {
263
+ 'Content-Type': 'application/json',
264
+ 'mcp-session-id': sessionId,
265
+ },
266
+ body: JSON.stringify({
267
+ jsonrpc: '2.0',
268
+ id: 1,
269
+ method: 'tools/list'
270
+ })
271
+ });
272
+ if (!response.ok) {
273
+ assert.strictEqual(response.status, 401, 'Should return 401 Unauthorized');
274
+ console.log(` ✓ Request correctly failed with status ${response.status}`);
275
+ const body = await response.text();
276
+ console.log(` ℹ️ Error: ${body.substring(0, 100)}...`);
277
+ }
278
+ else {
279
+ assert.fail('Should have failed due to expired auth token');
280
+ }
254
281
  }
255
- }
256
- catch (error) {
257
- // Network errors are also acceptable
258
- assert.ok(error instanceof Error, 'Should throw error');
259
- console.log(' ✓ Request correctly failed after timeout');
260
- console.log(` ℹ️ Error: ${error.message.substring(0, 100)}...`);
261
- }
262
- await clientWithAuth.disconnect();
282
+ catch (error) {
283
+ // Network errors are also acceptable
284
+ assert.ok(error instanceof Error, 'Should throw error');
285
+ console.log(' ✓ Request correctly failed after timeout');
286
+ console.log(` ℹ️ Error: ${error.message.substring(0, 100)}...`);
287
+ }
288
+ await clientWithAuth.disconnect();
289
+ });
263
290
  });
264
291
  });
@@ -0,0 +1,47 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { escapePrometheusLabel, formatPrometheusMetrics } from "../../server/metrics.js";
4
+ describe("Prometheus metrics formatting", () => {
5
+ test("escapes label values", () => {
6
+ assert.strictEqual(escapePrometheusLabel('a"b\\c\nd'), 'a\\"b\\\\c\\nd');
7
+ });
8
+ test("formats current MCP metrics", () => {
9
+ const body = formatPrometheusMetrics({
10
+ requestsProcessed: 2,
11
+ rejectedByRateLimit: 1,
12
+ rejectedByCapacity: 0,
13
+ authFailures: 3,
14
+ totalSessions: 4,
15
+ expiredSessions: 5,
16
+ activeSessions: 6,
17
+ authenticatedSessions: 7,
18
+ gitlabClientPool: { size: 8, maxSize: 100 },
19
+ uptime: 9,
20
+ memoryUsage: {
21
+ rss: 10,
22
+ heapTotal: 11,
23
+ heapUsed: 12,
24
+ external: 13,
25
+ arrayBuffers: 14,
26
+ },
27
+ statelessRequests: 15,
28
+ statelessAuthFromHeader: 16,
29
+ statelessAuthFromSealedSid: 17,
30
+ statelessAuthFailures: 18,
31
+ statelessSidRotated: 19,
32
+ config: {
33
+ maxSessions: 1000,
34
+ maxRequestsPerMinute: 60,
35
+ sessionTimeoutSeconds: 3600,
36
+ remoteAuthEnabled: true,
37
+ mcpOAuthEnabled: false,
38
+ statelessModeEnabled: false,
39
+ statelessRotationKey: false,
40
+ },
41
+ });
42
+ assert.match(body, /# HELP gitlab_mcp_requests_processed_total/);
43
+ assert.match(body, /gitlab_mcp_requests_processed_total 2/);
44
+ assert.match(body, /gitlab_mcp_requests_rejected_total\{reason="rate_limit"\} 1/);
45
+ assert.match(body, /gitlab_mcp_config_info\{[^}]*remote_auth_enabled="true"/);
46
+ });
47
+ });
@@ -4,7 +4,7 @@ import { once } from "node:events";
4
4
  import { afterEach, describe, test } from "node:test";
5
5
  import * as path from "node:path";
6
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)";
7
+ const ERROR_MESSAGE = "SSE=true requires SSE_AUTH_TOKEN (or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)";
8
8
  const LOOPBACK = "127.0.0.1";
9
9
  const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
10
10
  const running = new Set();
@@ -75,7 +75,7 @@ afterEach(() => {
75
75
  running.clear();
76
76
  });
77
77
  describe("SSE remote auth guard", () => {
78
- test("refuses unauthenticated SSE on non-loopback hosts", async () => {
78
+ test("refuses unauthenticated SSE", async () => {
79
79
  const port = await findAvailablePort(4400);
80
80
  const child = startSseServer({}, port);
81
81
  const { code, output } = await waitForExit(child);
@@ -256,6 +256,14 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
256
256
  const tokens = await podC.exchangeAuthorizationCode(podCClient, proxyCode, clientCodeVerifier, CLIENT_REDIRECT);
257
257
  assert.equal(tokens.access_token, "gl-access-123");
258
258
  });
259
+ test("callback rejects repeated query parameters", async () => {
260
+ const pod = makeProvider(loadMaterial(secret()));
261
+ const cbRes = makeFakeRes();
262
+ await pod.handleCallback(fakeReq({ code: "gitlab-code", state: ["one", "two"] }), cbRes);
263
+ assert.equal(cbRes.statusCode, 400);
264
+ assert.equal(cbRes.body, "Invalid query parameter");
265
+ assert.equal(stub.calls.length, 0);
266
+ });
259
267
  test("exchangeAuthorizationCode rejects wrong PKCE verifier", async () => {
260
268
  const s = secret();
261
269
  const pod = makeProvider(loadMaterial(s));
@@ -28,7 +28,7 @@ describe("Stateless Mcp-Session-Id — cross-pod integration", () => {
28
28
  let urlB;
29
29
  const sharedSecret = randomBytes(32).toString("base64url");
30
30
  before(async () => {
31
- const mockPort = await findMockServerPort(MOCK_PORT_BASE);
31
+ const mockPort = await findMockServerPort();
32
32
  mockGitLab = new MockGitLabServer({
33
33
  port: mockPort,
34
34
  validTokens: [MOCK_TOKEN],
@@ -204,7 +204,7 @@ describe("Stateless Mcp-Session-Id — inactivity-TTL semantics", () => {
204
204
  const sharedSecret = randomBytes(32).toString("base64url");
205
205
  const TTL_SECONDS = 3;
206
206
  before(async () => {
207
- const mockPort = await findMockServerPort(MOCK_PORT_BASE + 100);
207
+ const mockPort = await findMockServerPort();
208
208
  mockGitLab = new MockGitLabServer({
209
209
  port: mockPort,
210
210
  validTokens: [MOCK_TOKEN],
@@ -471,7 +471,7 @@ describe("Stateless Mcp-Session-Id — OAuth + sid-only follow-up", () => {
471
471
  let material;
472
472
  const sharedSecret = randomBytes(32).toString("base64url");
473
473
  before(async () => {
474
- const mockPort = await findMockServerPort(OAUTH_MOCK_PORT_BASE);
474
+ const mockPort = await findMockServerPort();
475
475
  mockGitLab = new MockGitLabServer({
476
476
  port: mockPort,
477
477
  validTokens: [MOCK_OAUTH_TOKEN],
@@ -19,7 +19,7 @@ describe("Streamable HTTP concurrent session requests", { timeout: 20_000 }, ()
19
19
  let server;
20
20
  let mcpUrl;
21
21
  before(async () => {
22
- const mockPort = await findMockServerPort(9360);
22
+ const mockPort = await findMockServerPort();
23
23
  mockGitLab = new MockGitLabServer({
24
24
  port: mockPort,
25
25
  validTokens: [MOCK_TOKEN],