@zereight/mcp-gitlab 2.0.7 → 2.0.8
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.md +119 -1
- package/build/index.js +670 -71
- package/build/schemas.js +156 -0
- package/build/test/clients/custom-header-client.js +122 -0
- package/build/test/remote-auth-simple-test.js +215 -0
- package/build/test/remote-auth-tests.js +315 -0
- package/build/test/utils/mock-gitlab-server.js +275 -0
- package/build/test/utils/server-launcher.js +10 -4
- package/package.json +3 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Authorization Tests
|
|
3
|
+
* Tests for per-session HTTP header-based authorization
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, after, before } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import fetch from 'node-fetch';
|
|
8
|
+
import { launchServer, findAvailablePort, cleanupServers, TransportMode, checkHealthEndpoint, HOST } from './utils/server-launcher.js';
|
|
9
|
+
console.log('🔐 Remote Authorization Tests');
|
|
10
|
+
console.log('');
|
|
11
|
+
// Configuration check
|
|
12
|
+
const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
|
|
13
|
+
const GITLAB_TOKEN = process.env.GITLAB_TOKEN_TEST || process.env.GITLAB_TOKEN;
|
|
14
|
+
const TEST_PROJECT_ID = process.env.TEST_PROJECT_ID;
|
|
15
|
+
console.log('🔧 Test Configuration:');
|
|
16
|
+
console.log(` GitLab URL: ${GITLAB_API_URL}`);
|
|
17
|
+
console.log(` Token: ${GITLAB_TOKEN ? '✅ Provided' : '❌ Missing'}`);
|
|
18
|
+
console.log(` Project ID: ${TEST_PROJECT_ID || '❌ Missing'}`);
|
|
19
|
+
// Validate required configuration
|
|
20
|
+
if (!GITLAB_TOKEN) {
|
|
21
|
+
console.error('❌ Error: GITLAB_TOKEN_TEST or GITLAB_TOKEN environment variable is required for testing');
|
|
22
|
+
console.error(' Set one of these variables to your GitLab API token');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
if (!TEST_PROJECT_ID) {
|
|
26
|
+
console.error('❌ Error: TEST_PROJECT_ID environment variable is required for testing');
|
|
27
|
+
console.error(' Set this variable to a valid GitLab project ID (e.g., "123" or "group/project")');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
console.log('✅ Configuration validated');
|
|
31
|
+
console.log('');
|
|
32
|
+
let servers = [];
|
|
33
|
+
// Cleanup function for all tests
|
|
34
|
+
const cleanup = () => {
|
|
35
|
+
cleanupServers(servers);
|
|
36
|
+
servers = [];
|
|
37
|
+
};
|
|
38
|
+
// Handle process termination
|
|
39
|
+
process.on('SIGINT', cleanup);
|
|
40
|
+
process.on('SIGTERM', cleanup);
|
|
41
|
+
process.on('exit', cleanup);
|
|
42
|
+
/**
|
|
43
|
+
* Helper to send MCP request with custom headers
|
|
44
|
+
*/
|
|
45
|
+
async function sendMCPRequest(url, method, headers = {}) {
|
|
46
|
+
const response = await fetch(url, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
...headers,
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify(method),
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const text = await response.text();
|
|
56
|
+
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
57
|
+
}
|
|
58
|
+
return response.json();
|
|
59
|
+
}
|
|
60
|
+
describe('Remote Authorization - Streamable HTTP with Authorization header', () => {
|
|
61
|
+
let server;
|
|
62
|
+
let port;
|
|
63
|
+
let mcpUrl;
|
|
64
|
+
before(async () => {
|
|
65
|
+
port = await findAvailablePort();
|
|
66
|
+
server = await launchServer({
|
|
67
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
68
|
+
port,
|
|
69
|
+
timeout: 3000,
|
|
70
|
+
env: {
|
|
71
|
+
STREAMABLE_HTTP: 'true',
|
|
72
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
73
|
+
GITLAB_API_URL: `${GITLAB_API_URL}/api/v4`,
|
|
74
|
+
GITLAB_PROJECT_ID: TEST_PROJECT_ID,
|
|
75
|
+
GITLAB_READ_ONLY_MODE: 'true',
|
|
76
|
+
// Explicitly no GITLAB_PERSONAL_ACCESS_TOKEN
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
servers.push(server);
|
|
80
|
+
mcpUrl = `http://${HOST}:${port}/mcp`;
|
|
81
|
+
// Verify server started successfully
|
|
82
|
+
assert.ok(server.process.pid !== undefined, 'Server process should have PID');
|
|
83
|
+
// Verify health check
|
|
84
|
+
if (server.port) {
|
|
85
|
+
const health = await checkHealthEndpoint(server.port);
|
|
86
|
+
assert.strictEqual(health.status, 'healthy', 'Health status should be healthy');
|
|
87
|
+
}
|
|
88
|
+
console.log('Server started with remote authorization enabled');
|
|
89
|
+
});
|
|
90
|
+
after(async () => {
|
|
91
|
+
cleanup();
|
|
92
|
+
console.log('Server stopped');
|
|
93
|
+
});
|
|
94
|
+
test('should reject request without Authorization header', async () => {
|
|
95
|
+
const initRequest = {
|
|
96
|
+
jsonrpc: '2.0',
|
|
97
|
+
id: 1,
|
|
98
|
+
method: 'initialize',
|
|
99
|
+
params: {
|
|
100
|
+
protocolVersion: '2024-11-05',
|
|
101
|
+
capabilities: {},
|
|
102
|
+
clientInfo: { name: 'test-client', version: '1.0.0' }
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
await sendMCPRequest(mcpUrl, initRequest, {
|
|
107
|
+
'mcp-session-id': 'test-session-no-auth'
|
|
108
|
+
});
|
|
109
|
+
assert.fail('Should have rejected request without auth header');
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
assert.ok(error instanceof Error);
|
|
113
|
+
assert.ok(error.message.includes('401'), 'Should get 401 Unauthorized');
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
test('should accept request with Authorization Bearer header', async () => {
|
|
117
|
+
const sessionId = `test-session-bearer-${Date.now()}`;
|
|
118
|
+
const initRequest = {
|
|
119
|
+
jsonrpc: '2.0',
|
|
120
|
+
id: 1,
|
|
121
|
+
method: 'initialize',
|
|
122
|
+
params: {
|
|
123
|
+
protocolVersion: '2024-11-05',
|
|
124
|
+
capabilities: {},
|
|
125
|
+
clientInfo: { name: 'test-client', version: '1.0.0' }
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const response = await sendMCPRequest(mcpUrl, initRequest, {
|
|
129
|
+
'mcp-session-id': sessionId,
|
|
130
|
+
'authorization': `Bearer ${GITLAB_TOKEN}`
|
|
131
|
+
});
|
|
132
|
+
assert.ok(response, 'Should get response');
|
|
133
|
+
assert.ok(response.result, 'Should have result');
|
|
134
|
+
assert.strictEqual(response.result.protocolVersion, '2024-11-05', 'Protocol version should match');
|
|
135
|
+
});
|
|
136
|
+
test('should reuse auth from first request in subsequent requests', async () => {
|
|
137
|
+
const sessionId = `test-session-reuse-${Date.now()}`;
|
|
138
|
+
// First request with auth
|
|
139
|
+
const initRequest = {
|
|
140
|
+
jsonrpc: '2.0',
|
|
141
|
+
id: 1,
|
|
142
|
+
method: 'initialize',
|
|
143
|
+
params: {
|
|
144
|
+
protocolVersion: '2024-11-05',
|
|
145
|
+
capabilities: {},
|
|
146
|
+
clientInfo: { name: 'test-client', version: '1.0.0' }
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const initResponse = await sendMCPRequest(mcpUrl, initRequest, {
|
|
150
|
+
'mcp-session-id': sessionId,
|
|
151
|
+
'authorization': `Bearer ${GITLAB_TOKEN}`
|
|
152
|
+
});
|
|
153
|
+
assert.ok(initResponse.result, 'Init should succeed');
|
|
154
|
+
// Second request without auth header (should reuse)
|
|
155
|
+
const listToolsRequest = {
|
|
156
|
+
jsonrpc: '2.0',
|
|
157
|
+
id: 2,
|
|
158
|
+
method: 'tools/list',
|
|
159
|
+
params: {}
|
|
160
|
+
};
|
|
161
|
+
const listResponse = await sendMCPRequest(mcpUrl, listToolsRequest, {
|
|
162
|
+
'mcp-session-id': sessionId
|
|
163
|
+
// No authorization header
|
|
164
|
+
});
|
|
165
|
+
assert.ok(listResponse.result, 'List tools should succeed with reused auth');
|
|
166
|
+
assert.ok(Array.isArray(listResponse.result.tools), 'Should return tools array');
|
|
167
|
+
assert.ok(listResponse.result.tools.length > 0, 'Should have at least one tool');
|
|
168
|
+
});
|
|
169
|
+
test('should call tool with Bearer token', async () => {
|
|
170
|
+
const sessionId = `test-session-tool-${Date.now()}`;
|
|
171
|
+
// Initialize
|
|
172
|
+
const initRequest = {
|
|
173
|
+
jsonrpc: '2.0',
|
|
174
|
+
id: 1,
|
|
175
|
+
method: 'initialize',
|
|
176
|
+
params: {
|
|
177
|
+
protocolVersion: '2024-11-05',
|
|
178
|
+
capabilities: {},
|
|
179
|
+
clientInfo: { name: 'test-client', version: '1.0.0' }
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
await sendMCPRequest(mcpUrl, initRequest, {
|
|
183
|
+
'mcp-session-id': sessionId,
|
|
184
|
+
'authorization': `Bearer ${GITLAB_TOKEN}`
|
|
185
|
+
});
|
|
186
|
+
// Call tool
|
|
187
|
+
const callToolRequest = {
|
|
188
|
+
jsonrpc: '2.0',
|
|
189
|
+
id: 2,
|
|
190
|
+
method: 'tools/call',
|
|
191
|
+
params: {
|
|
192
|
+
name: 'get_project',
|
|
193
|
+
arguments: {
|
|
194
|
+
project_id: TEST_PROJECT_ID
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const toolResponse = await sendMCPRequest(mcpUrl, callToolRequest, {
|
|
199
|
+
'mcp-session-id': sessionId
|
|
200
|
+
});
|
|
201
|
+
assert.ok(toolResponse.result, 'Tool call should succeed');
|
|
202
|
+
assert.ok(toolResponse.result.content, 'Should have content');
|
|
203
|
+
assert.ok(Array.isArray(toolResponse.result.content), 'Content should be array');
|
|
204
|
+
assert.ok(toolResponse.result.content.length > 0, 'Should have content items');
|
|
205
|
+
const projectData = JSON.parse(toolResponse.result.content[0].text);
|
|
206
|
+
assert.ok(projectData.id, 'Should have project id');
|
|
207
|
+
assert.ok(projectData.name, 'Should have project name');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('Remote Authorization - Streamable HTTP with Private-Token header', () => {
|
|
211
|
+
let server;
|
|
212
|
+
let port;
|
|
213
|
+
let mcpUrl;
|
|
214
|
+
before(async () => {
|
|
215
|
+
port = await findAvailablePort();
|
|
216
|
+
server = await launchServer({
|
|
217
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
218
|
+
port,
|
|
219
|
+
timeout: 3000,
|
|
220
|
+
env: {
|
|
221
|
+
STREAMABLE_HTTP: 'true',
|
|
222
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
223
|
+
GITLAB_API_URL: `${GITLAB_API_URL}/api/v4`,
|
|
224
|
+
GITLAB_PROJECT_ID: TEST_PROJECT_ID,
|
|
225
|
+
GITLAB_READ_ONLY_MODE: 'true',
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
servers.push(server);
|
|
229
|
+
mcpUrl = `http://${HOST}:${port}/mcp`;
|
|
230
|
+
console.log('Server started for Private-Token tests');
|
|
231
|
+
});
|
|
232
|
+
after(async () => {
|
|
233
|
+
cleanup();
|
|
234
|
+
console.log('Server stopped');
|
|
235
|
+
});
|
|
236
|
+
test('should accept request with Private-Token header', async () => {
|
|
237
|
+
const sessionId = `test-session-private-${Date.now()}`;
|
|
238
|
+
const initRequest = {
|
|
239
|
+
jsonrpc: '2.0',
|
|
240
|
+
id: 1,
|
|
241
|
+
method: 'initialize',
|
|
242
|
+
params: {
|
|
243
|
+
protocolVersion: '2024-11-05',
|
|
244
|
+
capabilities: {},
|
|
245
|
+
clientInfo: { name: 'test-client', version: '1.0.0' }
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const response = await sendMCPRequest(mcpUrl, initRequest, {
|
|
249
|
+
'mcp-session-id': sessionId,
|
|
250
|
+
'private-token': GITLAB_TOKEN
|
|
251
|
+
});
|
|
252
|
+
assert.ok(response.result, 'Should succeed with Private-Token');
|
|
253
|
+
assert.strictEqual(response.result.protocolVersion, '2024-11-05', 'Protocol version should match');
|
|
254
|
+
});
|
|
255
|
+
test('should call tool with Private-Token', async () => {
|
|
256
|
+
const sessionId = `test-session-private-tool-${Date.now()}`;
|
|
257
|
+
// Initialize
|
|
258
|
+
const initRequest = {
|
|
259
|
+
jsonrpc: '2.0',
|
|
260
|
+
id: 1,
|
|
261
|
+
method: 'initialize',
|
|
262
|
+
params: {
|
|
263
|
+
protocolVersion: '2024-11-05',
|
|
264
|
+
capabilities: {},
|
|
265
|
+
clientInfo: { name: 'test-client', version: '1.0.0' }
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
await sendMCPRequest(mcpUrl, initRequest, {
|
|
269
|
+
'mcp-session-id': sessionId,
|
|
270
|
+
'private-token': GITLAB_TOKEN
|
|
271
|
+
});
|
|
272
|
+
// Call tool
|
|
273
|
+
const callToolRequest = {
|
|
274
|
+
jsonrpc: '2.0',
|
|
275
|
+
id: 2,
|
|
276
|
+
method: 'tools/call',
|
|
277
|
+
params: {
|
|
278
|
+
name: 'list_merge_requests',
|
|
279
|
+
arguments: {
|
|
280
|
+
project_id: TEST_PROJECT_ID
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const toolResponse = await sendMCPRequest(mcpUrl, callToolRequest, {
|
|
285
|
+
'mcp-session-id': sessionId
|
|
286
|
+
});
|
|
287
|
+
assert.ok(toolResponse.result, 'Tool call should succeed with Private-Token');
|
|
288
|
+
assert.ok(toolResponse.result.content, 'Should have content');
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
describe('Remote Authorization - SSE mode should be disabled', () => {
|
|
292
|
+
test('should fail to start with SSE and REMOTE_AUTHORIZATION', async () => {
|
|
293
|
+
const port = await findAvailablePort();
|
|
294
|
+
try {
|
|
295
|
+
const server = await launchServer({
|
|
296
|
+
mode: TransportMode.SSE,
|
|
297
|
+
port,
|
|
298
|
+
timeout: 3000,
|
|
299
|
+
env: {
|
|
300
|
+
SSE: 'true',
|
|
301
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
302
|
+
GITLAB_API_URL: `${GITLAB_API_URL}/api/v4`,
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
// If we get here, the server started when it shouldn't have
|
|
306
|
+
servers.push(server);
|
|
307
|
+
assert.fail('Server should not start with SSE and REMOTE_AUTHORIZATION=true');
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
// Expected: server should fail to start
|
|
311
|
+
assert.ok(error instanceof Error, 'Should throw an error');
|
|
312
|
+
console.log('✅ Server correctly rejected SSE mode with remote authorization');
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock GitLab API Server for Testing
|
|
3
|
+
* Implements minimal GitLab API endpoints for testing remote authorization
|
|
4
|
+
*/
|
|
5
|
+
import express from 'express';
|
|
6
|
+
export class MockGitLabServer {
|
|
7
|
+
app;
|
|
8
|
+
server = null;
|
|
9
|
+
config;
|
|
10
|
+
requestCount = 0;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.app = express();
|
|
14
|
+
this.setupMiddleware();
|
|
15
|
+
this.setupRoutes();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Setup middleware including auth validation
|
|
19
|
+
*/
|
|
20
|
+
setupMiddleware() {
|
|
21
|
+
this.app.use(express.json());
|
|
22
|
+
// Request counter for rate limiting tests
|
|
23
|
+
this.app.use((req, res, next) => {
|
|
24
|
+
this.requestCount++;
|
|
25
|
+
next();
|
|
26
|
+
});
|
|
27
|
+
// Artificial delay middleware (for timeout testing)
|
|
28
|
+
if (this.config.responseDelay) {
|
|
29
|
+
this.app.use((req, res, next) => {
|
|
30
|
+
setTimeout(next, this.config.responseDelay);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// Rate limiting middleware
|
|
34
|
+
if (this.config.rateLimitAfter) {
|
|
35
|
+
this.app.use((req, res, next) => {
|
|
36
|
+
if (this.requestCount > this.config.rateLimitAfter) {
|
|
37
|
+
res.status(429).json({
|
|
38
|
+
message: 'Rate limit exceeded',
|
|
39
|
+
retry_after: 60
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
next();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Authentication middleware - applies to all /api/v4/* routes
|
|
47
|
+
this.app.use('/api/v4', (req, res, next) => {
|
|
48
|
+
const authHeader = req.headers['authorization'];
|
|
49
|
+
const privateToken = req.headers['private-token'];
|
|
50
|
+
let token = null;
|
|
51
|
+
if (authHeader) {
|
|
52
|
+
// Extract token from "Bearer <token>"
|
|
53
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
54
|
+
token = match ? match[1].trim() : null;
|
|
55
|
+
}
|
|
56
|
+
else if (privateToken) {
|
|
57
|
+
token = privateToken.trim();
|
|
58
|
+
}
|
|
59
|
+
if (!token) {
|
|
60
|
+
res.status(401).json({
|
|
61
|
+
message: 'Unauthorized',
|
|
62
|
+
error: 'Missing authentication token'
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!this.config.validTokens.includes(token)) {
|
|
67
|
+
res.status(401).json({
|
|
68
|
+
message: 'Unauthorized',
|
|
69
|
+
error: 'Invalid authentication token'
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Store validated token in request
|
|
74
|
+
req.gitlabToken = token;
|
|
75
|
+
next();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
setupRoutes() {
|
|
79
|
+
// GET /api/v4/user - Get current user
|
|
80
|
+
this.app.get('/api/v4/user', (req, res) => {
|
|
81
|
+
const token = req.gitlabToken || 'unknown';
|
|
82
|
+
res.json({
|
|
83
|
+
id: 1,
|
|
84
|
+
username: `user_${token.substring(0, 8)}`,
|
|
85
|
+
name: 'Test User',
|
|
86
|
+
email: 'test@example.com',
|
|
87
|
+
state: 'active'
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
// GET /api/v4/projects/:projectId - Get project
|
|
91
|
+
this.app.get('/api/v4/projects/:projectId', (req, res) => {
|
|
92
|
+
const projectId = req.params.projectId;
|
|
93
|
+
res.json({
|
|
94
|
+
id: parseInt(projectId) || 123,
|
|
95
|
+
name: 'Test Project',
|
|
96
|
+
path: 'test-project',
|
|
97
|
+
path_with_namespace: 'test-group/test-project',
|
|
98
|
+
description: 'A mock test project',
|
|
99
|
+
visibility: 'private',
|
|
100
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
101
|
+
web_url: `https://gitlab.mock/project/${projectId}`,
|
|
102
|
+
namespace: {
|
|
103
|
+
id: 1,
|
|
104
|
+
name: 'Test Group',
|
|
105
|
+
path: 'test-group',
|
|
106
|
+
kind: 'group',
|
|
107
|
+
full_path: 'test-group'
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
// GET /api/v4/projects/:projectId/merge_requests - List merge requests
|
|
112
|
+
this.app.get('/api/v4/projects/:projectId/merge_requests', (req, res) => {
|
|
113
|
+
res.json([
|
|
114
|
+
{
|
|
115
|
+
id: 1,
|
|
116
|
+
iid: 1,
|
|
117
|
+
title: 'Test MR 1',
|
|
118
|
+
state: 'opened',
|
|
119
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
120
|
+
author: {
|
|
121
|
+
id: 1,
|
|
122
|
+
username: 'test-user',
|
|
123
|
+
name: 'Test User'
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 2,
|
|
128
|
+
iid: 2,
|
|
129
|
+
title: 'Test MR 2',
|
|
130
|
+
state: 'merged',
|
|
131
|
+
created_at: '2024-01-02T00:00:00Z',
|
|
132
|
+
author: {
|
|
133
|
+
id: 1,
|
|
134
|
+
username: 'test-user',
|
|
135
|
+
name: 'Test User'
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
// GET /api/v4/projects/:projectId/merge_requests/:mr_iid - Get single MR
|
|
141
|
+
this.app.get('/api/v4/projects/:projectId/merge_requests/:mr_iid', (req, res) => {
|
|
142
|
+
const mrIid = parseInt(req.params.mr_iid);
|
|
143
|
+
res.json({
|
|
144
|
+
id: mrIid,
|
|
145
|
+
iid: mrIid,
|
|
146
|
+
title: `Test MR ${mrIid}`,
|
|
147
|
+
state: 'opened',
|
|
148
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
149
|
+
author: {
|
|
150
|
+
id: 1,
|
|
151
|
+
username: 'test-user',
|
|
152
|
+
name: 'Test User'
|
|
153
|
+
},
|
|
154
|
+
source_branch: 'feature-branch',
|
|
155
|
+
target_branch: 'main',
|
|
156
|
+
merge_status: 'can_be_merged'
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
// GET /api/v4/projects/:projectId/issues - List issues
|
|
160
|
+
this.app.get('/api/v4/projects/:projectId/issues', (req, res) => {
|
|
161
|
+
res.json([
|
|
162
|
+
{
|
|
163
|
+
id: 1,
|
|
164
|
+
iid: 1,
|
|
165
|
+
title: 'Test Issue 1',
|
|
166
|
+
state: 'opened',
|
|
167
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
168
|
+
author: {
|
|
169
|
+
id: 1,
|
|
170
|
+
username: 'test-user',
|
|
171
|
+
name: 'Test User'
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
// GET /api/v4/projects - List projects
|
|
177
|
+
this.app.get('/api/v4/projects', (req, res) => {
|
|
178
|
+
res.json([
|
|
179
|
+
{
|
|
180
|
+
id: 123,
|
|
181
|
+
name: 'Test Project',
|
|
182
|
+
path: 'test-project',
|
|
183
|
+
path_with_namespace: 'test-group/test-project',
|
|
184
|
+
description: 'A mock test project',
|
|
185
|
+
visibility: 'private',
|
|
186
|
+
namespace: {
|
|
187
|
+
id: 1,
|
|
188
|
+
name: 'Test Group',
|
|
189
|
+
path: 'test-group',
|
|
190
|
+
kind: 'group',
|
|
191
|
+
full_path: 'test-group'
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
// Health check endpoint
|
|
197
|
+
this.app.get('/health', (req, res) => {
|
|
198
|
+
res.json({ status: 'ok', message: 'Mock GitLab API is running' });
|
|
199
|
+
});
|
|
200
|
+
// Catch-all for unimplemented endpoints
|
|
201
|
+
this.app.use((req, res) => {
|
|
202
|
+
console.log(`Mock GitLab: Unimplemented endpoint: ${req.method} ${req.path}`);
|
|
203
|
+
res.status(404).json({
|
|
204
|
+
message: '404 Not Found',
|
|
205
|
+
error: 'Endpoint not implemented in mock server'
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async start() {
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
this.server = this.app.listen(this.config.port, '127.0.0.1', () => {
|
|
212
|
+
console.log(`Mock GitLab API listening on http://127.0.0.1:${this.config.port}`);
|
|
213
|
+
resolve();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async stop() {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
if (this.server) {
|
|
220
|
+
this.server.close((err) => {
|
|
221
|
+
if (err)
|
|
222
|
+
reject(err);
|
|
223
|
+
else {
|
|
224
|
+
console.log('Mock GitLab API stopped');
|
|
225
|
+
resolve();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
resolve();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
getUrl() {
|
|
235
|
+
return `http://127.0.0.1:${this.config.port}`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Helper to find available port for mock server
|
|
240
|
+
*/
|
|
241
|
+
export async function findMockServerPort(basePort = 9000, maxAttempts = 10) {
|
|
242
|
+
const net = await import('net');
|
|
243
|
+
const tryPort = async (port, attemptsLeft) => {
|
|
244
|
+
if (attemptsLeft === 0) {
|
|
245
|
+
throw new Error(`Could not find available port after ${maxAttempts} attempts starting from ${basePort}`);
|
|
246
|
+
}
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
const server = net.createServer();
|
|
249
|
+
server.unref();
|
|
250
|
+
server.on('error', async () => {
|
|
251
|
+
try {
|
|
252
|
+
const nextPort = await tryPort(port + 1, attemptsLeft - 1);
|
|
253
|
+
resolve(nextPort);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
reject(err);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
server.listen(port, '127.0.0.1', () => {
|
|
260
|
+
const addr = server.address();
|
|
261
|
+
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
262
|
+
server.close(() => {
|
|
263
|
+
resolve(actualPort);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
return tryPort(basePort, maxAttempts);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Reset request counter (useful for rate limit testing)
|
|
272
|
+
*/
|
|
273
|
+
export function resetMockServerState(server) {
|
|
274
|
+
server.requestCount = 0;
|
|
275
|
+
}
|
|
@@ -21,21 +21,27 @@ export async function launchServer(config) {
|
|
|
21
21
|
const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
|
|
22
22
|
const GITLAB_TOKEN = process.env.GITLAB_TOKEN_TEST || process.env.GITLAB_TOKEN;
|
|
23
23
|
const TEST_PROJECT_ID = process.env.TEST_PROJECT_ID;
|
|
24
|
-
//
|
|
25
|
-
|
|
24
|
+
// Check if remote authorization is enabled
|
|
25
|
+
const isRemoteAuth = env.REMOTE_AUTHORIZATION === 'true';
|
|
26
|
+
// Validate that we have required configuration (unless using remote auth)
|
|
27
|
+
if (!GITLAB_TOKEN && !isRemoteAuth) {
|
|
26
28
|
throw new Error('GITLAB_TOKEN_TEST or GITLAB_TOKEN environment variable is required for server testing');
|
|
27
29
|
}
|
|
28
|
-
if (!TEST_PROJECT_ID) {
|
|
30
|
+
if (!TEST_PROJECT_ID && !isRemoteAuth) {
|
|
29
31
|
throw new Error('TEST_PROJECT_ID environment variable is required for server testing');
|
|
30
32
|
}
|
|
31
33
|
const serverEnv = {
|
|
32
34
|
// Add all environment variables from the current process
|
|
33
35
|
...process.env,
|
|
34
36
|
GITLAB_API_URL: `${GITLAB_API_URL}/api/v4`,
|
|
35
|
-
GITLAB_PROJECT_ID: TEST_PROJECT_ID,
|
|
37
|
+
...(TEST_PROJECT_ID ? { GITLAB_PROJECT_ID: TEST_PROJECT_ID } : {}),
|
|
36
38
|
GITLAB_READ_ONLY_MODE: 'true', // Use read-only mode for testing
|
|
37
39
|
...env,
|
|
38
40
|
};
|
|
41
|
+
// Only set GITLAB_PERSONAL_ACCESS_TOKEN if not using remote auth
|
|
42
|
+
if (!isRemoteAuth && GITLAB_TOKEN) {
|
|
43
|
+
serverEnv.GITLAB_PERSONAL_ACCESS_TOKEN = GITLAB_TOKEN;
|
|
44
|
+
}
|
|
39
45
|
// Set transport-specific environment variables
|
|
40
46
|
switch (mode) {
|
|
41
47
|
case TransportMode.SSE:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
4
4
|
"description": "MCP server for using the GitLab API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "zereight",
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
"watch": "tsc --watch",
|
|
23
23
|
"deploy": "npm publish --access public",
|
|
24
24
|
"changelog": "auto-changelog -p",
|
|
25
|
-
"test": "node test/validate-api.js",
|
|
25
|
+
"test": "node test/validate-api.js && npm run test:remote-auth",
|
|
26
26
|
"test:integration": "node test/validate-api.js",
|
|
27
|
+
"test:remote-auth": "npm run build && npx tsx --test test/remote-auth-simple-test.ts",
|
|
27
28
|
"test:server": "npm run build && node build/test/test-all-transport-server.js",
|
|
28
29
|
"test:mcp:readonly": "tsx test/readonly-mcp-tests.ts",
|
|
29
30
|
"test:all": "npm run test && npm run test:mcp:readonly",
|