@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.
- package/README.ko.md +82 -28
- package/README.md +56 -23
- package/README.zh-CN.md +82 -28
- package/build/config.js +13 -0
- package/build/downloads/proxy.js +199 -0
- package/build/index.js +564 -482
- package/build/oauth-proxy.js +18 -4
- package/build/schemas.js +47 -9
- package/build/scripts/check-skill-sync.js +137 -0
- package/build/scripts/generate-tool-docs.js +7 -2
- package/build/server/metrics.js +84 -0
- package/build/server/transport-mode.js +25 -0
- package/build/test/callback-proxy-tests.js +1 -1
- package/build/test/client-pool-test.js +1 -1
- package/build/test/dynamic-api-url-allowlist.test.js +2 -2
- package/build/test/dynamic-api-url-test.js +4 -4
- package/build/test/dynamic-routing-tests.js +4 -4
- package/build/test/mcp-oauth-tests.js +17 -4
- package/build/test/multi-server-test.js +3 -3
- package/build/test/no-proxy-integration-test.js +1 -1
- package/build/test/path-segment-encoding.test.js +27 -0
- package/build/test/remote-auth-simple-test.js +226 -199
- package/build/test/server/metrics.test.js +47 -0
- package/build/test/sse-auth-guard.test.js +2 -2
- package/build/test/stateless/callback-proxy.test.js +8 -0
- package/build/test/stateless/session-id-integration.test.js +3 -3
- package/build/test/streamable-http-concurrent-session.test.js +1 -1
- package/build/test/streamable-http-dns-rebinding.test.js +185 -0
- package/build/test/streamable-http-static-token-auth.test.js +25 -22
- package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
- package/build/test/test-ci-catalog.js +1 -1
- package/build/test/test-ci-lint.js +1 -1
- package/build/test/test-ci-variables.js +8 -5
- package/build/test/test-dependency-proxy.js +11 -7
- package/build/test/test-deployment-tools.js +16 -2
- package/build/test/test-download-attachment.js +1 -1
- package/build/test/test-get-file-blame.js +1 -1
- package/build/test/test-geteffectiveprojectid.js +7 -7
- package/build/test/test-issue-description-patch.js +1 -1
- package/build/test/test-job-artifacts.js +1 -1
- package/build/test/test-list-issues.js +1 -1
- package/build/test/test-list-merge-requests.js +1 -1
- package/build/test/test-list-project-members.js +1 -1
- package/build/test/test-merge-request-approval-state-tools.js +1 -1
- package/build/test/test-merge-request-pipelines.js +1 -1
- package/build/test/test-mr-diffs-filter.js +1 -1
- package/build/test/test-mr-file-diffs.js +2 -2
- package/build/test/test-oauth-proxy-rate-limit.js +1 -1
- package/build/test/test-permission-mode.js +215 -0
- package/build/test/test-protected-branches.js +1 -1
- package/build/test/test-remote-downloads.js +2 -2
- package/build/test/test-search-code.js +1 -1
- package/build/test/test-tags.js +1 -1
- package/build/test/test-todos.js +1 -1
- package/build/test/test-token-optimizations.js +3 -3
- package/build/test/test-toolset-filtering.js +1 -1
- package/build/test/test-update-issue-slim.js +141 -0
- package/build/test/test-upload-markdown.js +1 -1
- package/build/test/utils/download-token.test.js +35 -0
- package/build/test/utils/forwarded-public-base-url.test.js +9 -1
- package/build/test/utils/graphql-query.test.js +64 -1
- package/build/test/utils/mock-gitlab-server.js +24 -31
- package/build/test/utils/redact-sensitive.test.js +32 -0
- package/build/test/utils/server-launcher.js +1 -2
- package/build/test/utils/version-check.test.js +52 -0
- package/build/tools/registry.js +30 -5
- package/build/utils/download-token.js +71 -0
- package/build/utils/forwarded-public-base-url.js +22 -0
- package/build/utils/graphql-query.js +109 -12
- package/build/utils/redact-sensitive.js +21 -0
- package/build/utils/version-check.js +40 -0
- 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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
22
|
+
const mockPort = await findMockServerPort();
|
|
23
23
|
mockGitLab = new MockGitLabServer({
|
|
24
24
|
port: mockPort,
|
|
25
25
|
validTokens: [MOCK_TOKEN],
|