@zereight/mcp-gitlab 2.0.32 → 2.0.34

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,236 @@
1
+ /**
2
+ * Test suite for getEffectiveProjectId function
3
+ * Tests the behavior of project ID resolution with different environment configurations
4
+ */
5
+ import { describe, test, before, after } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST } from './utils/server-launcher.js';
8
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
9
+ import { StreamableHTTPTestClient } from './clients/streamable-http-client.js';
10
+ // Use the same token that will be passed via GITLAB_TOKEN_TEST environment variable
11
+ const MOCK_TOKEN = process.env.GITLAB_TOKEN_TEST || 'glpat-mock-token-12345';
12
+ const DEFAULT_PROJECT_ID = '123';
13
+ const OTHER_PROJECT_ID = '456';
14
+ console.log('🔍 Testing getEffectiveProjectId functionality');
15
+ console.log('');
16
+ describe('getEffectiveProjectId - No GITLAB_ALLOWED_PROJECT_IDS', () => {
17
+ let mcpUrl;
18
+ let mockGitLab;
19
+ let servers = [];
20
+ let client;
21
+ before(async () => {
22
+ // Start mock GitLab server
23
+ const mockPort = await findMockServerPort(9100);
24
+ mockGitLab = new MockGitLabServer({
25
+ port: mockPort,
26
+ validTokens: [MOCK_TOKEN]
27
+ });
28
+ await mockGitLab.start();
29
+ const mockGitLabUrl = mockGitLab.getUrl();
30
+ // Start MCP server WITHOUT GITLAB_ALLOWED_PROJECT_IDS
31
+ const mcpPort = await findAvailablePort(3100);
32
+ const server = await launchServer({
33
+ mode: TransportMode.STREAMABLE_HTTP,
34
+ port: mcpPort,
35
+ timeout: 5000,
36
+ env: {
37
+ STREAMABLE_HTTP: 'true',
38
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
39
+ GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
40
+ GITLAB_READ_ONLY_MODE: 'true',
41
+ }
42
+ });
43
+ servers.push(server);
44
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
45
+ client = new StreamableHTTPTestClient();
46
+ await client.connect(mcpUrl);
47
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
48
+ console.log(`MCP Server: ${mcpUrl}`);
49
+ console.log(`Default Project: ${DEFAULT_PROJECT_ID}`);
50
+ });
51
+ after(async () => {
52
+ if (client) {
53
+ await client.disconnect();
54
+ }
55
+ cleanupServers(servers);
56
+ if (mockGitLab) {
57
+ await mockGitLab.stop();
58
+ }
59
+ });
60
+ test('should use GITLAB_PROJECT_ID when no project_id is provided', async () => {
61
+ // Call get_project without specifying project_id
62
+ const result = await client.callTool('get_project', {
63
+ project_id: ''
64
+ });
65
+ assert.ok(result.content, 'Should have content');
66
+ const content = result.content[0];
67
+ assert.ok('text' in content, 'Content should have text');
68
+ const project = JSON.parse(content.text);
69
+ // The mock server should receive a request for the default project
70
+ assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use GITLAB_PROJECT_ID as default');
71
+ console.log(` ✓ Used default project ${DEFAULT_PROJECT_ID} when no project_id provided`);
72
+ });
73
+ test('should prioritize passed project_id over GITLAB_PROJECT_ID', async () => {
74
+ // Call get_project with a different project_id
75
+ const result = await client.callTool('get_project', {
76
+ project_id: OTHER_PROJECT_ID
77
+ });
78
+ assert.ok(result.content, 'Should have content');
79
+ const content = result.content[0];
80
+ assert.ok('text' in content, 'Content should have text');
81
+ const project = JSON.parse(content.text);
82
+ // Should use the passed project_id, not GITLAB_PROJECT_ID
83
+ assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should use passed project_id');
84
+ console.log(` ✓ Used passed project_id ${OTHER_PROJECT_ID} instead of default ${DEFAULT_PROJECT_ID}`);
85
+ });
86
+ });
87
+ describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () => {
88
+ let mcpUrl;
89
+ let mockGitLab;
90
+ let servers = [];
91
+ let client;
92
+ before(async () => {
93
+ // Start mock GitLab server
94
+ const mockPort = await findMockServerPort(9200);
95
+ mockGitLab = new MockGitLabServer({
96
+ port: mockPort,
97
+ validTokens: [MOCK_TOKEN]
98
+ });
99
+ await mockGitLab.start();
100
+ const mockGitLabUrl = mockGitLab.getUrl();
101
+ // Start MCP server WITH single GITLAB_ALLOWED_PROJECT_IDS
102
+ const mcpPort = await findAvailablePort(3200);
103
+ const server = await launchServer({
104
+ mode: TransportMode.STREAMABLE_HTTP,
105
+ port: mcpPort,
106
+ timeout: 5000,
107
+ env: {
108
+ STREAMABLE_HTTP: 'true',
109
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
110
+ GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
111
+ GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
112
+ GITLAB_READ_ONLY_MODE: 'true',
113
+ }
114
+ });
115
+ servers.push(server);
116
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
117
+ client = new StreamableHTTPTestClient();
118
+ await client.connect(mcpUrl);
119
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
120
+ console.log(`MCP Server: ${mcpUrl}`);
121
+ console.log(`Allowed Project: ${DEFAULT_PROJECT_ID}`);
122
+ });
123
+ after(async () => {
124
+ if (client) {
125
+ await client.disconnect();
126
+ }
127
+ cleanupServers(servers);
128
+ if (mockGitLab) {
129
+ await mockGitLab.stop();
130
+ }
131
+ });
132
+ test('should use single allowed project as default', async () => {
133
+ const result = await client.callTool('get_project', {
134
+ project_id: ''
135
+ });
136
+ assert.ok(result.content, 'Should have content');
137
+ const content = result.content[0];
138
+ assert.ok('text' in content, 'Content should have text');
139
+ const project = JSON.parse(content.text);
140
+ assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use allowed project as default');
141
+ console.log(` ✓ Used allowed project ${DEFAULT_PROJECT_ID} as default`);
142
+ });
143
+ test('should reject access to non-allowed project', async () => {
144
+ try {
145
+ await client.callTool('get_project', {
146
+ project_id: OTHER_PROJECT_ID
147
+ });
148
+ assert.fail('Should have rejected access to non-allowed project');
149
+ }
150
+ catch (error) {
151
+ assert.ok(error instanceof Error);
152
+ assert.ok(error.message.includes('Access denied'), 'Should indicate access denied');
153
+ console.log(' ✓ Correctly rejected access to non-allowed project');
154
+ }
155
+ });
156
+ });
157
+ describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', () => {
158
+ let mcpUrl;
159
+ let mockGitLab;
160
+ let servers = [];
161
+ let client;
162
+ before(async () => {
163
+ // Start mock GitLab server
164
+ const mockPort = await findMockServerPort(9300);
165
+ mockGitLab = new MockGitLabServer({
166
+ port: mockPort,
167
+ validTokens: [MOCK_TOKEN]
168
+ });
169
+ await mockGitLab.start();
170
+ const mockGitLabUrl = mockGitLab.getUrl();
171
+ // Start MCP server WITH multiple GITLAB_ALLOWED_PROJECT_IDS
172
+ const mcpPort = await findAvailablePort(3300);
173
+ const server = await launchServer({
174
+ mode: TransportMode.STREAMABLE_HTTP,
175
+ port: mcpPort,
176
+ timeout: 5000,
177
+ env: {
178
+ STREAMABLE_HTTP: 'true',
179
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
180
+ GITLAB_ALLOWED_PROJECT_IDS: `${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`,
181
+ GITLAB_READ_ONLY_MODE: 'true',
182
+ }
183
+ });
184
+ servers.push(server);
185
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
186
+ client = new StreamableHTTPTestClient();
187
+ await client.connect(mcpUrl);
188
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
189
+ console.log(`MCP Server: ${mcpUrl}`);
190
+ console.log(`Allowed Projects: ${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`);
191
+ });
192
+ after(async () => {
193
+ if (client) {
194
+ await client.disconnect();
195
+ }
196
+ cleanupServers(servers);
197
+ if (mockGitLab) {
198
+ await mockGitLab.stop();
199
+ }
200
+ });
201
+ test('should require explicit project_id when multiple projects allowed', async () => {
202
+ try {
203
+ await client.callTool('get_project', {
204
+ project_id: ''
205
+ });
206
+ assert.fail('Should have required explicit project_id');
207
+ }
208
+ catch (error) {
209
+ assert.ok(error instanceof Error);
210
+ assert.ok(error.message.includes('Please specify a project ID'), 'Should require project ID');
211
+ console.log(' ✓ Correctly required explicit project_id');
212
+ }
213
+ });
214
+ test('should allow access to first allowed project', async () => {
215
+ const result = await client.callTool('get_project', {
216
+ project_id: DEFAULT_PROJECT_ID
217
+ });
218
+ assert.ok(result.content, 'Should have content');
219
+ const content = result.content[0];
220
+ assert.ok('text' in content, 'Content should have text');
221
+ const project = JSON.parse(content.text);
222
+ assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should allow first project');
223
+ console.log(` ✓ Allowed access to first project ${DEFAULT_PROJECT_ID}`);
224
+ });
225
+ test('should allow access to second allowed project', async () => {
226
+ const result = await client.callTool('get_project', {
227
+ project_id: OTHER_PROJECT_ID
228
+ });
229
+ assert.ok(result.content, 'Should have content');
230
+ const content = result.content[0];
231
+ assert.ok('text' in content, 'Content should have text');
232
+ const project = JSON.parse(content.text);
233
+ assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should allow second project');
234
+ console.log(` ✓ Allowed access to second project ${OTHER_PROJECT_ID}`);
235
+ });
236
+ });
@@ -0,0 +1,148 @@
1
+ import { describe, test, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
8
+ const MOCK_TOKEN = 'glpat-mock-token-12345';
9
+ const TEST_PROJECT_ID = '123';
10
+ function callUploadMarkdown(args, env, timeoutMs = 15_000) {
11
+ return new Promise((resolve, reject) => {
12
+ const proc = spawn('node', ['build/index.js'], {
13
+ stdio: ['pipe', 'pipe', 'pipe'],
14
+ env: { ...process.env, ...env },
15
+ });
16
+ const timer = setTimeout(() => {
17
+ proc.kill();
18
+ reject(new Error(`Process timed out after ${timeoutMs}ms`));
19
+ }, timeoutMs);
20
+ let stdout = '';
21
+ let stderr = '';
22
+ proc.stdout?.on('data', (d) => (stdout += d.toString()));
23
+ proc.stderr?.on('data', (d) => (stderr += d.toString()));
24
+ proc.on('error', (err) => {
25
+ clearTimeout(timer);
26
+ reject(new Error(`Failed to spawn process: ${err.message}`));
27
+ });
28
+ proc.on('close', () => {
29
+ clearTimeout(timer);
30
+ const lines = stdout.split('\n').filter(l => l.trim().startsWith('{'));
31
+ for (const line of lines) {
32
+ try {
33
+ const parsed = JSON.parse(line);
34
+ if (parsed.id === 1) {
35
+ resolve(parsed);
36
+ return;
37
+ }
38
+ }
39
+ catch { /* try next line */ }
40
+ }
41
+ reject(new Error(`No matching JSON-RPC response found.\nstdout: ${stdout}\nstderr: ${stderr}`));
42
+ });
43
+ proc.stdin?.end(JSON.stringify({
44
+ jsonrpc: '2.0',
45
+ id: 1,
46
+ method: 'tools/call',
47
+ params: { name: 'upload_markdown', arguments: args },
48
+ }) + '\n');
49
+ });
50
+ }
51
+ const MOCK_UPLOAD_RESPONSE = {
52
+ id: 42,
53
+ alt: 'test-file.txt',
54
+ url: '/uploads/abc123secret/test-file.txt',
55
+ full_path: '/test-group/test-project/uploads/abc123secret/test-file.txt',
56
+ markdown: '[test-file.txt](/uploads/abc123secret/test-file.txt)',
57
+ };
58
+ describe('upload_markdown', () => {
59
+ let mockGitLab;
60
+ let env;
61
+ // Captured per-request state, reset before each invocation via the handler
62
+ let lastContentType;
63
+ let lastRawBody;
64
+ before(async () => {
65
+ const port = await findMockServerPort(9200);
66
+ mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
67
+ await mockGitLab.start();
68
+ env = {
69
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
70
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
71
+ };
72
+ mockGitLab.addMockHandler('post', `/projects/${TEST_PROJECT_ID}/uploads`, (req, res) => {
73
+ lastContentType = req.headers['content-type'];
74
+ const chunks = [];
75
+ req.on('data', (chunk) => chunks.push(chunk));
76
+ req.on('end', () => {
77
+ lastRawBody = Buffer.concat(chunks).toString('binary');
78
+ res.status(201).json(MOCK_UPLOAD_RESPONSE);
79
+ });
80
+ });
81
+ });
82
+ after(async () => {
83
+ await mockGitLab.stop();
84
+ });
85
+ test('Content-Type is multipart/form-data with a boundary', async () => {
86
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-ct-test.txt');
87
+ fs.writeFileSync(tmpFile, 'content-type test');
88
+ try {
89
+ await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
90
+ assert.ok(lastContentType, 'Content-Type header must be present');
91
+ assert.ok(lastContentType.startsWith('multipart/form-data'), `Expected multipart/form-data, got: ${lastContentType}`);
92
+ assert.ok(lastContentType.includes('boundary='), `Content-Type must include boundary, got: ${lastContentType}`);
93
+ }
94
+ finally {
95
+ fs.unlinkSync(tmpFile);
96
+ }
97
+ });
98
+ test('multipart body contains a "file" field with the file content', async () => {
99
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-body-test.txt');
100
+ const fileContent = 'hello from multipart upload test';
101
+ fs.writeFileSync(tmpFile, fileContent);
102
+ try {
103
+ await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
104
+ assert.ok(lastRawBody, 'Request body must be captured');
105
+ assert.ok(lastRawBody.includes('name="file"'), 'Multipart body should include a field named "file"');
106
+ assert.ok(lastRawBody.includes(fileContent), 'Multipart body should contain the uploaded file content');
107
+ }
108
+ finally {
109
+ fs.unlinkSync(tmpFile);
110
+ }
111
+ });
112
+ test('multipart body includes the original filename', async () => {
113
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-filename-check.txt');
114
+ fs.writeFileSync(tmpFile, 'filename check');
115
+ try {
116
+ await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
117
+ assert.ok(lastRawBody, 'Request body must be captured');
118
+ assert.ok(lastRawBody.includes('mcp-upload-filename-check.txt'), 'Multipart body should include the original filename');
119
+ }
120
+ finally {
121
+ fs.unlinkSync(tmpFile);
122
+ }
123
+ });
124
+ test('returns markdown, url, alt, and full_path from upload response', async () => {
125
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-response-test.txt');
126
+ fs.writeFileSync(tmpFile, 'response field test');
127
+ try {
128
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
129
+ assert.ok(!raw.error, `Unexpected RPC error: ${raw.error?.message}`);
130
+ const text = raw.result?.content?.[0]?.text;
131
+ assert.ok(text, 'Result should contain a text content block');
132
+ const parsed = JSON.parse(text);
133
+ assert.strictEqual(parsed.markdown, MOCK_UPLOAD_RESPONSE.markdown);
134
+ assert.strictEqual(parsed.url, MOCK_UPLOAD_RESPONSE.url);
135
+ assert.strictEqual(parsed.alt, MOCK_UPLOAD_RESPONSE.alt);
136
+ assert.strictEqual(parsed.full_path, MOCK_UPLOAD_RESPONSE.full_path);
137
+ }
138
+ finally {
139
+ fs.unlinkSync(tmpFile);
140
+ }
141
+ });
142
+ test('returns an error when the file does not exist', async () => {
143
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '/nonexistent/no-such-file.txt' }, env);
144
+ const hasError = typeof raw.error?.message === 'string' ||
145
+ raw.result?.content?.some(c => c.text && (c.text.toLowerCase().includes('not found') || c.text.toLowerCase().includes('error')));
146
+ assert.ok(hasError, 'Should return an error for a nonexistent file path');
147
+ });
148
+ });
@@ -78,8 +78,12 @@ export class MockGitLabServer {
78
78
  this.app.use('/api/v4', (req, res, next) => {
79
79
  const authHeader = req.headers['authorization'];
80
80
  const privateToken = req.headers['private-token'];
81
+ const jobToken = req.headers['job-token'];
81
82
  let token = null;
82
- if (authHeader) {
83
+ if (jobToken) {
84
+ token = jobToken.trim();
85
+ }
86
+ else if (authHeader) {
83
87
  // Extract token from "Bearer <token>"
84
88
  const match = authHeader.match(/^Bearer\s+(.+)$/i);
85
89
  token = match ? match[1].trim() : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.0.32",
3
+ "version": "2.0.34",
4
4
  "description": "MCP server for using the GitLab API",
5
5
  "license": "MIT",
6
6
  "author": "zereight",