@zereight/mcp-gitlab 2.0.33 → 2.0.35
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 +233 -90
- package/build/gitlab-client-pool.js +114 -6
- package/build/index.js +2244 -98
- package/build/oauth-proxy.js +257 -0
- package/build/oauth.js +11 -6
- package/build/schemas.js +458 -199
- package/build/test/mcp-oauth-tests.js +443 -0
- package/build/test/multi-server-test.js +16 -8
- package/build/test/no-proxy-integration-test.js +183 -0
- package/build/test/no-proxy-test.js +138 -0
- package/build/test/remote-auth-simple-test.js +12 -1
- package/build/test/test-geteffectiveprojectid.js +245 -0
- package/build/test/test-mr-file-diffs.js +251 -0
- package/build/test/test-search-code.js +272 -0
- package/build/test/test-toolset-filtering.js +22 -17
- package/build/test/test-upload-markdown.js +148 -0
- package/build/test/utils/mock-gitlab-server.js +267 -163
- package/build/test/utils/server-launcher.js +45 -41
- package/package.json +3 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NO_PROXY Test Suite
|
|
3
|
+
* Tests NO_PROXY pattern matching and proxy bypass functionality
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { GitLabClientPool } from '../gitlab-client-pool.js';
|
|
8
|
+
console.log('🚫 NO_PROXY Test Suite');
|
|
9
|
+
console.log('');
|
|
10
|
+
describe('NO_PROXY Pattern Matching', () => {
|
|
11
|
+
test('should bypass proxy for exact hostname match', () => {
|
|
12
|
+
const pool = new GitLabClientPool({
|
|
13
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
14
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
15
|
+
noProxy: 'gitlab.internal.com',
|
|
16
|
+
});
|
|
17
|
+
// Create agent for the NO_PROXY matched host
|
|
18
|
+
const agent = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
|
|
19
|
+
// The agent should NOT be a proxy agent
|
|
20
|
+
// It should be a standard HTTPS agent
|
|
21
|
+
assert.ok(agent, 'Agent should be created');
|
|
22
|
+
assert.strictEqual(agent.constructor.name, 'Agent', 'Should be a standard Agent, not a proxy agent');
|
|
23
|
+
});
|
|
24
|
+
test('should use proxy for non-matching hostname', () => {
|
|
25
|
+
const pool = new GitLabClientPool({
|
|
26
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
27
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
28
|
+
noProxy: 'gitlab.internal.com',
|
|
29
|
+
});
|
|
30
|
+
// Create agent for a host that should use proxy
|
|
31
|
+
const agent = pool.getOrCreateAgentForUrl('https://gitlab.external.com/api/v4');
|
|
32
|
+
// The agent should be a proxy agent
|
|
33
|
+
assert.ok(agent, 'Agent should be created');
|
|
34
|
+
assert.notStrictEqual(agent.constructor.name, 'Agent', 'Should be a proxy agent, not standard Agent');
|
|
35
|
+
});
|
|
36
|
+
test('should bypass proxy for domain suffix match', () => {
|
|
37
|
+
const pool = new GitLabClientPool({
|
|
38
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
39
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
40
|
+
noProxy: '.internal.com',
|
|
41
|
+
});
|
|
42
|
+
// Test multiple subdomains
|
|
43
|
+
const agent1 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
|
|
44
|
+
const agent2 = pool.getOrCreateAgentForUrl('https://api.internal.com/api/v4');
|
|
45
|
+
const agent3 = pool.getOrCreateAgentForUrl('https://dev.gitlab.internal.com/api/v4');
|
|
46
|
+
assert.strictEqual(agent1.constructor.name, 'Agent', 'gitlab.internal.com should bypass proxy');
|
|
47
|
+
assert.strictEqual(agent2.constructor.name, 'Agent', 'api.internal.com should bypass proxy');
|
|
48
|
+
assert.strictEqual(agent3.constructor.name, 'Agent', 'dev.gitlab.internal.com should bypass proxy');
|
|
49
|
+
});
|
|
50
|
+
test('should bypass proxy for localhost', () => {
|
|
51
|
+
const pool = new GitLabClientPool({
|
|
52
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
53
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
54
|
+
noProxy: 'localhost,127.0.0.1',
|
|
55
|
+
});
|
|
56
|
+
const agent1 = pool.getOrCreateAgentForUrl('http://localhost:8080/api/v4');
|
|
57
|
+
const agent2 = pool.getOrCreateAgentForUrl('http://127.0.0.1:8080/api/v4');
|
|
58
|
+
assert.strictEqual(agent1.constructor.name, 'Agent', 'localhost should bypass proxy');
|
|
59
|
+
assert.strictEqual(agent2.constructor.name, 'Agent', '127.0.0.1 should bypass proxy');
|
|
60
|
+
});
|
|
61
|
+
test('should bypass proxy for wildcard pattern', () => {
|
|
62
|
+
const pool = new GitLabClientPool({
|
|
63
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
64
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
65
|
+
noProxy: '*',
|
|
66
|
+
});
|
|
67
|
+
const agent = pool.getOrCreateAgentForUrl('https://gitlab.com/api/v4');
|
|
68
|
+
assert.strictEqual(agent.constructor.name, 'Agent', 'Wildcard should bypass all proxies');
|
|
69
|
+
});
|
|
70
|
+
test('should handle multiple NO_PROXY patterns', () => {
|
|
71
|
+
const pool = new GitLabClientPool({
|
|
72
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
73
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
74
|
+
noProxy: 'localhost,.internal.com,192.168.1.1',
|
|
75
|
+
});
|
|
76
|
+
const agent1 = pool.getOrCreateAgentForUrl('http://localhost/api/v4');
|
|
77
|
+
const agent2 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
|
|
78
|
+
const agent3 = pool.getOrCreateAgentForUrl('http://192.168.1.1/api/v4');
|
|
79
|
+
const agent4 = pool.getOrCreateAgentForUrl('https://gitlab.com/api/v4');
|
|
80
|
+
assert.strictEqual(agent1.constructor.name, 'Agent', 'localhost should bypass proxy');
|
|
81
|
+
assert.strictEqual(agent2.constructor.name, 'Agent', '.internal.com should bypass proxy');
|
|
82
|
+
assert.strictEqual(agent3.constructor.name, 'Agent', '192.168.1.1 should bypass proxy');
|
|
83
|
+
assert.notStrictEqual(agent4.constructor.name, 'Agent', 'gitlab.com should use proxy');
|
|
84
|
+
});
|
|
85
|
+
test('should handle NO_PROXY with whitespace', () => {
|
|
86
|
+
const pool = new GitLabClientPool({
|
|
87
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
88
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
89
|
+
noProxy: ' localhost , .internal.com , 192.168.1.1 ',
|
|
90
|
+
});
|
|
91
|
+
const agent1 = pool.getOrCreateAgentForUrl('http://localhost/api/v4');
|
|
92
|
+
const agent2 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
|
|
93
|
+
assert.strictEqual(agent1.constructor.name, 'Agent', 'Should handle whitespace in NO_PROXY');
|
|
94
|
+
assert.strictEqual(agent2.constructor.name, 'Agent', 'Should handle whitespace in NO_PROXY');
|
|
95
|
+
});
|
|
96
|
+
test('should work without NO_PROXY set', () => {
|
|
97
|
+
const pool = new GitLabClientPool({
|
|
98
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
99
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
100
|
+
});
|
|
101
|
+
const agent = pool.getOrCreateAgentForUrl('https://gitlab.com/api/v4');
|
|
102
|
+
// Should use proxy when NO_PROXY is not set
|
|
103
|
+
assert.notStrictEqual(agent.constructor.name, 'Agent', 'Should use proxy when NO_PROXY is not set');
|
|
104
|
+
});
|
|
105
|
+
test('should work with NO_PROXY set but empty', () => {
|
|
106
|
+
const pool = new GitLabClientPool({
|
|
107
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
108
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
109
|
+
noProxy: '',
|
|
110
|
+
});
|
|
111
|
+
const agent = pool.getOrCreateAgentForUrl('https://gitlab.com/api/v4');
|
|
112
|
+
// Should use proxy when NO_PROXY is empty
|
|
113
|
+
assert.notStrictEqual(agent.constructor.name, 'Agent', 'Should use proxy when NO_PROXY is empty');
|
|
114
|
+
});
|
|
115
|
+
test('should handle port-specific patterns', () => {
|
|
116
|
+
const pool = new GitLabClientPool({
|
|
117
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
118
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
119
|
+
noProxy: 'gitlab.internal.com:443',
|
|
120
|
+
});
|
|
121
|
+
const agent1 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4'); // HTTPS uses port 443 by default
|
|
122
|
+
const agent2 = pool.getOrCreateAgentForUrl('http://gitlab.internal.com/api/v4'); // HTTP uses port 80 by default
|
|
123
|
+
assert.strictEqual(agent1.constructor.name, 'Agent', 'Should bypass proxy for matching port');
|
|
124
|
+
assert.notStrictEqual(agent2.constructor.name, 'Agent', 'Should use proxy for non-matching port');
|
|
125
|
+
});
|
|
126
|
+
test('should handle case-insensitive matching', () => {
|
|
127
|
+
const pool = new GitLabClientPool({
|
|
128
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
129
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
130
|
+
noProxy: 'GitLab.Internal.COM',
|
|
131
|
+
});
|
|
132
|
+
const agent1 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
|
|
133
|
+
const agent2 = pool.getOrCreateAgentForUrl('https://GITLAB.INTERNAL.COM/api/v4');
|
|
134
|
+
assert.strictEqual(agent1.constructor.name, 'Agent', 'Should match case-insensitively (lowercase)');
|
|
135
|
+
assert.strictEqual(agent2.constructor.name, 'Agent', 'Should match case-insensitively (uppercase)');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
console.log('✅ NO_PROXY tests completed');
|
|
@@ -9,6 +9,7 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server
|
|
|
9
9
|
import { CustomHeaderClient } from './clients/custom-header-client.js';
|
|
10
10
|
// Test constants
|
|
11
11
|
const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
12
|
+
const MOCK_JOB_TOKEN = 'glcbt-mock-job-token-9876';
|
|
12
13
|
const TEST_PROJECT_ID = '123';
|
|
13
14
|
// Port ranges to avoid collisions
|
|
14
15
|
const MOCK_GITLAB_PORT_BASE = 9000;
|
|
@@ -32,7 +33,7 @@ describe('Remote Authorization - Basic Functionality', () => {
|
|
|
32
33
|
const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
|
|
33
34
|
mockGitLab = new MockGitLabServer({
|
|
34
35
|
port: mockPort,
|
|
35
|
-
validTokens: [MOCK_TOKEN]
|
|
36
|
+
validTokens: [MOCK_TOKEN, MOCK_JOB_TOKEN],
|
|
36
37
|
});
|
|
37
38
|
await mockGitLab.start();
|
|
38
39
|
const mockGitLabUrl = mockGitLab.getUrl();
|
|
@@ -80,6 +81,16 @@ describe('Remote Authorization - Basic Functionality', () => {
|
|
|
80
81
|
console.log(` ✓ Connected with Private-Token, got ${tools.tools.length} tools`);
|
|
81
82
|
await client.disconnect();
|
|
82
83
|
});
|
|
84
|
+
test('should connect with JOB-TOKEN header', async () => {
|
|
85
|
+
const client = new CustomHeaderClient({
|
|
86
|
+
'job-token': MOCK_JOB_TOKEN,
|
|
87
|
+
});
|
|
88
|
+
await client.connect(mcpUrl);
|
|
89
|
+
const tools = await client.listTools();
|
|
90
|
+
assert.ok(tools.tools.length > 0, 'Should have tools');
|
|
91
|
+
console.log(` ✓ Connected with JOB-TOKEN, got ${tools.tools.length} tools`);
|
|
92
|
+
await client.disconnect();
|
|
93
|
+
});
|
|
83
94
|
test('should successfully call listTools with auth', async () => {
|
|
84
95
|
const client = new CustomHeaderClient({
|
|
85
96
|
'authorization': `Bearer ${MOCK_TOKEN}`
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
// Ensure GITLAB_TOKEN_TEST is set for launchServer() validation
|
|
15
|
+
if (!process.env.GITLAB_TOKEN_TEST && !process.env.GITLAB_TOKEN) {
|
|
16
|
+
process.env.GITLAB_TOKEN_TEST = MOCK_TOKEN;
|
|
17
|
+
}
|
|
18
|
+
if (!process.env.TEST_PROJECT_ID) {
|
|
19
|
+
process.env.TEST_PROJECT_ID = DEFAULT_PROJECT_ID;
|
|
20
|
+
}
|
|
21
|
+
console.log('🔍 Testing getEffectiveProjectId functionality');
|
|
22
|
+
console.log('');
|
|
23
|
+
describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
24
|
+
describe('getEffectiveProjectId - No GITLAB_ALLOWED_PROJECT_IDS', () => {
|
|
25
|
+
let mcpUrl;
|
|
26
|
+
let mockGitLab;
|
|
27
|
+
let servers = [];
|
|
28
|
+
let client;
|
|
29
|
+
before(async () => {
|
|
30
|
+
// Start mock GitLab server
|
|
31
|
+
const mockPort = await findMockServerPort(9100);
|
|
32
|
+
mockGitLab = new MockGitLabServer({
|
|
33
|
+
port: mockPort,
|
|
34
|
+
validTokens: [MOCK_TOKEN]
|
|
35
|
+
});
|
|
36
|
+
await mockGitLab.start();
|
|
37
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
38
|
+
// Start MCP server WITHOUT GITLAB_ALLOWED_PROJECT_IDS
|
|
39
|
+
const mcpPort = await findAvailablePort(3100);
|
|
40
|
+
const server = await launchServer({
|
|
41
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
42
|
+
port: mcpPort,
|
|
43
|
+
timeout: 5000,
|
|
44
|
+
env: {
|
|
45
|
+
STREAMABLE_HTTP: 'true',
|
|
46
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
47
|
+
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
48
|
+
GITLAB_READ_ONLY_MODE: 'true',
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
servers.push(server);
|
|
52
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
53
|
+
client = new StreamableHTTPTestClient();
|
|
54
|
+
await client.connect(mcpUrl);
|
|
55
|
+
console.log(`Mock GitLab: ${mockGitLabUrl}`);
|
|
56
|
+
console.log(`MCP Server: ${mcpUrl}`);
|
|
57
|
+
console.log(`Default Project: ${DEFAULT_PROJECT_ID}`);
|
|
58
|
+
});
|
|
59
|
+
after(async () => {
|
|
60
|
+
if (client) {
|
|
61
|
+
await client.disconnect();
|
|
62
|
+
}
|
|
63
|
+
cleanupServers(servers);
|
|
64
|
+
if (mockGitLab) {
|
|
65
|
+
await mockGitLab.stop();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
test('should use GITLAB_PROJECT_ID when no project_id is provided', async () => {
|
|
69
|
+
// Call get_project without specifying project_id
|
|
70
|
+
const result = await client.callTool('get_project', {
|
|
71
|
+
project_id: ''
|
|
72
|
+
});
|
|
73
|
+
assert.ok(result.content, 'Should have content');
|
|
74
|
+
const content = result.content[0];
|
|
75
|
+
assert.ok('text' in content, 'Content should have text');
|
|
76
|
+
const project = JSON.parse(content.text);
|
|
77
|
+
// The mock server should receive a request for the default project
|
|
78
|
+
assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use GITLAB_PROJECT_ID as default');
|
|
79
|
+
console.log(` ✓ Used default project ${DEFAULT_PROJECT_ID} when no project_id provided`);
|
|
80
|
+
});
|
|
81
|
+
test('should prioritize passed project_id over GITLAB_PROJECT_ID', async () => {
|
|
82
|
+
// Call get_project with a different project_id
|
|
83
|
+
const result = await client.callTool('get_project', {
|
|
84
|
+
project_id: OTHER_PROJECT_ID
|
|
85
|
+
});
|
|
86
|
+
assert.ok(result.content, 'Should have content');
|
|
87
|
+
const content = result.content[0];
|
|
88
|
+
assert.ok('text' in content, 'Content should have text');
|
|
89
|
+
const project = JSON.parse(content.text);
|
|
90
|
+
// Should use the passed project_id, not GITLAB_PROJECT_ID
|
|
91
|
+
assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should use passed project_id');
|
|
92
|
+
console.log(` ✓ Used passed project_id ${OTHER_PROJECT_ID} instead of default ${DEFAULT_PROJECT_ID}`);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () => {
|
|
96
|
+
let mcpUrl;
|
|
97
|
+
let mockGitLab;
|
|
98
|
+
let servers = [];
|
|
99
|
+
let client;
|
|
100
|
+
before(async () => {
|
|
101
|
+
// Start mock GitLab server
|
|
102
|
+
const mockPort = await findMockServerPort(9200);
|
|
103
|
+
mockGitLab = new MockGitLabServer({
|
|
104
|
+
port: mockPort,
|
|
105
|
+
validTokens: [MOCK_TOKEN]
|
|
106
|
+
});
|
|
107
|
+
await mockGitLab.start();
|
|
108
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
109
|
+
// Start MCP server WITH single GITLAB_ALLOWED_PROJECT_IDS
|
|
110
|
+
const mcpPort = await findAvailablePort(3200);
|
|
111
|
+
const server = await launchServer({
|
|
112
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
113
|
+
port: mcpPort,
|
|
114
|
+
timeout: 5000,
|
|
115
|
+
env: {
|
|
116
|
+
STREAMABLE_HTTP: 'true',
|
|
117
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
118
|
+
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
119
|
+
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
120
|
+
GITLAB_READ_ONLY_MODE: 'true',
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
servers.push(server);
|
|
124
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
125
|
+
client = new StreamableHTTPTestClient();
|
|
126
|
+
await client.connect(mcpUrl);
|
|
127
|
+
console.log(`Mock GitLab: ${mockGitLabUrl}`);
|
|
128
|
+
console.log(`MCP Server: ${mcpUrl}`);
|
|
129
|
+
console.log(`Allowed Project: ${DEFAULT_PROJECT_ID}`);
|
|
130
|
+
});
|
|
131
|
+
after(async () => {
|
|
132
|
+
if (client) {
|
|
133
|
+
await client.disconnect();
|
|
134
|
+
}
|
|
135
|
+
cleanupServers(servers);
|
|
136
|
+
if (mockGitLab) {
|
|
137
|
+
await mockGitLab.stop();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
test('should use single allowed project as default', async () => {
|
|
141
|
+
const result = await client.callTool('get_project', {
|
|
142
|
+
project_id: ''
|
|
143
|
+
});
|
|
144
|
+
assert.ok(result.content, 'Should have content');
|
|
145
|
+
const content = result.content[0];
|
|
146
|
+
assert.ok('text' in content, 'Content should have text');
|
|
147
|
+
const project = JSON.parse(content.text);
|
|
148
|
+
assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use allowed project as default');
|
|
149
|
+
console.log(` ✓ Used allowed project ${DEFAULT_PROJECT_ID} as default`);
|
|
150
|
+
});
|
|
151
|
+
test('should reject access to non-allowed project', async () => {
|
|
152
|
+
try {
|
|
153
|
+
await client.callTool('get_project', {
|
|
154
|
+
project_id: OTHER_PROJECT_ID
|
|
155
|
+
});
|
|
156
|
+
assert.fail('Should have rejected access to non-allowed project');
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
assert.ok(error instanceof Error);
|
|
160
|
+
assert.ok(error.message.includes('Access denied'), 'Should indicate access denied');
|
|
161
|
+
console.log(' ✓ Correctly rejected access to non-allowed project');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', () => {
|
|
166
|
+
let mcpUrl;
|
|
167
|
+
let mockGitLab;
|
|
168
|
+
let servers = [];
|
|
169
|
+
let client;
|
|
170
|
+
before(async () => {
|
|
171
|
+
// Start mock GitLab server
|
|
172
|
+
const mockPort = await findMockServerPort(9300);
|
|
173
|
+
mockGitLab = new MockGitLabServer({
|
|
174
|
+
port: mockPort,
|
|
175
|
+
validTokens: [MOCK_TOKEN]
|
|
176
|
+
});
|
|
177
|
+
await mockGitLab.start();
|
|
178
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
179
|
+
// Start MCP server WITH multiple GITLAB_ALLOWED_PROJECT_IDS
|
|
180
|
+
const mcpPort = await findAvailablePort(3300);
|
|
181
|
+
const server = await launchServer({
|
|
182
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
183
|
+
port: mcpPort,
|
|
184
|
+
timeout: 5000,
|
|
185
|
+
env: {
|
|
186
|
+
STREAMABLE_HTTP: 'true',
|
|
187
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
188
|
+
GITLAB_ALLOWED_PROJECT_IDS: `${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`,
|
|
189
|
+
GITLAB_READ_ONLY_MODE: 'true',
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
servers.push(server);
|
|
193
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
194
|
+
client = new StreamableHTTPTestClient();
|
|
195
|
+
await client.connect(mcpUrl);
|
|
196
|
+
console.log(`Mock GitLab: ${mockGitLabUrl}`);
|
|
197
|
+
console.log(`MCP Server: ${mcpUrl}`);
|
|
198
|
+
console.log(`Allowed Projects: ${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`);
|
|
199
|
+
});
|
|
200
|
+
after(async () => {
|
|
201
|
+
if (client) {
|
|
202
|
+
await client.disconnect();
|
|
203
|
+
}
|
|
204
|
+
cleanupServers(servers);
|
|
205
|
+
if (mockGitLab) {
|
|
206
|
+
await mockGitLab.stop();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
test('should require explicit project_id when multiple projects allowed', async () => {
|
|
210
|
+
try {
|
|
211
|
+
await client.callTool('get_project', {
|
|
212
|
+
project_id: ''
|
|
213
|
+
});
|
|
214
|
+
assert.fail('Should have required explicit project_id');
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
assert.ok(error instanceof Error);
|
|
218
|
+
assert.ok(error.message.includes('Please specify a project ID'), 'Should require project ID');
|
|
219
|
+
console.log(' ✓ Correctly required explicit project_id');
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
test('should allow access to first allowed project', async () => {
|
|
223
|
+
const result = await client.callTool('get_project', {
|
|
224
|
+
project_id: DEFAULT_PROJECT_ID
|
|
225
|
+
});
|
|
226
|
+
assert.ok(result.content, 'Should have content');
|
|
227
|
+
const content = result.content[0];
|
|
228
|
+
assert.ok('text' in content, 'Content should have text');
|
|
229
|
+
const project = JSON.parse(content.text);
|
|
230
|
+
assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should allow first project');
|
|
231
|
+
console.log(` ✓ Allowed access to first project ${DEFAULT_PROJECT_ID}`);
|
|
232
|
+
});
|
|
233
|
+
test('should allow access to second allowed project', async () => {
|
|
234
|
+
const result = await client.callTool('get_project', {
|
|
235
|
+
project_id: OTHER_PROJECT_ID
|
|
236
|
+
});
|
|
237
|
+
assert.ok(result.content, 'Should have content');
|
|
238
|
+
const content = result.content[0];
|
|
239
|
+
assert.ok('text' in content, 'Content should have text');
|
|
240
|
+
const project = JSON.parse(content.text);
|
|
241
|
+
assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should allow second project');
|
|
242
|
+
console.log(` ✓ Allowed access to second project ${OTHER_PROJECT_ID}`);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}); // end wrapper describe
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
|
|
5
|
+
const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
6
|
+
const TEST_PROJECT_ID = '123';
|
|
7
|
+
const TEST_MR_IID = '1';
|
|
8
|
+
// Helper to call list_merge_request_changed_files
|
|
9
|
+
async function callListMergeRequestChangedFiles(args = {}, env) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const proc = spawn('node', ['build/index.js'], {
|
|
12
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
13
|
+
env: {
|
|
14
|
+
...process.env,
|
|
15
|
+
...env,
|
|
16
|
+
GITLAB_READ_ONLY_MODE: 'true'
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
let output = '';
|
|
20
|
+
let errorOutput = '';
|
|
21
|
+
proc.stdout?.on('data', d => output += d);
|
|
22
|
+
proc.stderr?.on('data', d => errorOutput += d);
|
|
23
|
+
proc.on('close', (code) => {
|
|
24
|
+
if (code !== 0)
|
|
25
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
26
|
+
// Find the JSON line in stdout
|
|
27
|
+
const line = output.split('\n').find(l => l.startsWith('{'));
|
|
28
|
+
if (!line)
|
|
29
|
+
return reject(new Error('No JSON output found'));
|
|
30
|
+
try {
|
|
31
|
+
const response = JSON.parse(line);
|
|
32
|
+
if (response.error) {
|
|
33
|
+
reject(response.error);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Parse the tool result content
|
|
37
|
+
const content = response.result?.content?.[0]?.text;
|
|
38
|
+
if (content) {
|
|
39
|
+
try {
|
|
40
|
+
resolve(JSON.parse(content));
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
resolve(response.result);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
reject(e);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
proc.stdin?.end(JSON.stringify({
|
|
56
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
57
|
+
params: { name: "list_merge_request_changed_files", arguments: args }
|
|
58
|
+
}) + '\n');
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Helper to call get_merge_request_file_diff
|
|
62
|
+
async function callGetMergeRequestFileDiff(args = {}, env) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const proc = spawn('node', ['build/index.js'], {
|
|
65
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
66
|
+
env: {
|
|
67
|
+
...process.env,
|
|
68
|
+
...env,
|
|
69
|
+
GITLAB_READ_ONLY_MODE: 'true'
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
let output = '';
|
|
73
|
+
let errorOutput = '';
|
|
74
|
+
proc.stdout?.on('data', d => output += d);
|
|
75
|
+
proc.stderr?.on('data', d => errorOutput += d);
|
|
76
|
+
proc.on('close', (code) => {
|
|
77
|
+
if (code !== 0)
|
|
78
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
79
|
+
// Find the JSON line in stdout
|
|
80
|
+
const line = output.split('\n').find(l => l.startsWith('{'));
|
|
81
|
+
if (!line)
|
|
82
|
+
return reject(new Error('No JSON output found'));
|
|
83
|
+
try {
|
|
84
|
+
const response = JSON.parse(line);
|
|
85
|
+
if (response.error) {
|
|
86
|
+
reject(response.error);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// Parse the tool result content
|
|
90
|
+
const content = response.result?.content?.[0]?.text;
|
|
91
|
+
if (content) {
|
|
92
|
+
try {
|
|
93
|
+
resolve(JSON.parse(content));
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
resolve(response.result);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
reject(e);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
proc.stdin?.end(JSON.stringify({
|
|
109
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
110
|
+
params: { name: "get_merge_request_file_diff", arguments: args }
|
|
111
|
+
}) + '\n');
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
describe('list_merge_request_changed_files', () => {
|
|
115
|
+
let mockGitLab;
|
|
116
|
+
let mockGitLabUrl;
|
|
117
|
+
before(async () => {
|
|
118
|
+
const mockPort = await findMockServerPort(9150);
|
|
119
|
+
mockGitLab = new MockGitLabServer({
|
|
120
|
+
port: mockPort,
|
|
121
|
+
validTokens: [MOCK_TOKEN]
|
|
122
|
+
});
|
|
123
|
+
await mockGitLab.start();
|
|
124
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
125
|
+
});
|
|
126
|
+
after(async () => {
|
|
127
|
+
await mockGitLab.stop();
|
|
128
|
+
});
|
|
129
|
+
test('returns all changed files without filtering', async () => {
|
|
130
|
+
const files = await callListMergeRequestChangedFiles({ project_id: TEST_PROJECT_ID, merge_request_iid: TEST_MR_IID }, {
|
|
131
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
132
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
133
|
+
});
|
|
134
|
+
assert.ok(Array.isArray(files), 'Response should be an array');
|
|
135
|
+
assert.strictEqual(files.length, 4, 'Should return 4 files');
|
|
136
|
+
// Check structure of returned files
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
assert.ok(file.new_path !== undefined, 'Each file should have new_path');
|
|
139
|
+
assert.ok(file.old_path !== undefined, 'Each file should have old_path');
|
|
140
|
+
}
|
|
141
|
+
assert.strictEqual(files[0].new_path, 'src/index.ts');
|
|
142
|
+
assert.strictEqual(files[1].new_path, 'vendor/package/file.js');
|
|
143
|
+
assert.strictEqual(files[2].new_path, 'README.md');
|
|
144
|
+
assert.strictEqual(files[3].new_path, 'package-lock.json');
|
|
145
|
+
});
|
|
146
|
+
test('filters out vendor folder with ^vendor/ pattern', async () => {
|
|
147
|
+
const files = await callListMergeRequestChangedFiles({
|
|
148
|
+
project_id: TEST_PROJECT_ID,
|
|
149
|
+
merge_request_iid: TEST_MR_IID,
|
|
150
|
+
excluded_file_patterns: ['^vendor/']
|
|
151
|
+
}, {
|
|
152
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
153
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
154
|
+
});
|
|
155
|
+
assert.ok(Array.isArray(files), 'Response should be an array');
|
|
156
|
+
assert.strictEqual(files.length, 3, 'Should return 3 files (vendor filtered out)');
|
|
157
|
+
assert.strictEqual(files[0].new_path, 'src/index.ts');
|
|
158
|
+
assert.strictEqual(files[1].new_path, 'README.md');
|
|
159
|
+
assert.strictEqual(files[2].new_path, 'package-lock.json');
|
|
160
|
+
});
|
|
161
|
+
test('filters multiple patterns at once', async () => {
|
|
162
|
+
const files = await callListMergeRequestChangedFiles({
|
|
163
|
+
project_id: TEST_PROJECT_ID,
|
|
164
|
+
merge_request_iid: TEST_MR_IID,
|
|
165
|
+
excluded_file_patterns: ['^vendor/', 'package-lock\\.json']
|
|
166
|
+
}, {
|
|
167
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
168
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
169
|
+
});
|
|
170
|
+
assert.ok(Array.isArray(files), 'Response should be an array');
|
|
171
|
+
assert.strictEqual(files.length, 2, 'Should return 2 files (vendor and package-lock filtered out)');
|
|
172
|
+
assert.strictEqual(files[0].new_path, 'src/index.ts');
|
|
173
|
+
assert.strictEqual(files[1].new_path, 'README.md');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe('get_merge_request_file_diff', () => {
|
|
177
|
+
let mockGitLab;
|
|
178
|
+
let mockGitLabUrl;
|
|
179
|
+
before(async () => {
|
|
180
|
+
const mockPort = await findMockServerPort(9200);
|
|
181
|
+
mockGitLab = new MockGitLabServer({
|
|
182
|
+
port: mockPort,
|
|
183
|
+
validTokens: [MOCK_TOKEN]
|
|
184
|
+
});
|
|
185
|
+
await mockGitLab.start();
|
|
186
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
187
|
+
});
|
|
188
|
+
after(async () => {
|
|
189
|
+
await mockGitLab.stop();
|
|
190
|
+
});
|
|
191
|
+
test('returns diffs for existing files in single page', async () => {
|
|
192
|
+
// Request only first few files that fit in one page (per_page=20)
|
|
193
|
+
const fileDiff = await callGetMergeRequestFileDiff({
|
|
194
|
+
project_id: TEST_PROJECT_ID,
|
|
195
|
+
merge_request_iid: TEST_MR_IID,
|
|
196
|
+
file_paths: ['src/index.ts', 'README.md']
|
|
197
|
+
}, {
|
|
198
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
199
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
200
|
+
});
|
|
201
|
+
assert.ok(Array.isArray(fileDiff), 'Response should be an array');
|
|
202
|
+
assert.strictEqual(fileDiff.length, 2, 'Should return 2 diff results');
|
|
203
|
+
// Check that we got the correct files
|
|
204
|
+
const paths = fileDiff.map((f) => f.new_path || f.old_path).sort();
|
|
205
|
+
assert.deepStrictEqual(paths, ['README.md', 'src/index.ts'].sort());
|
|
206
|
+
});
|
|
207
|
+
test('handles pagination when result spans multiple pages', async () => {
|
|
208
|
+
// Request more files than fit in one page (we have 15 total, per_page defaults to 20)
|
|
209
|
+
// but let's use a smaller per_page by testing with unidiff param
|
|
210
|
+
const fileDiff = await callGetMergeRequestFileDiff({
|
|
211
|
+
project_id: TEST_PROJECT_ID,
|
|
212
|
+
merge_request_iid: TEST_MR_IID,
|
|
213
|
+
file_paths: [
|
|
214
|
+
'src/index.ts',
|
|
215
|
+
'config/settings.yml',
|
|
216
|
+
'models/user.go'
|
|
217
|
+
]
|
|
218
|
+
}, {
|
|
219
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
220
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
221
|
+
});
|
|
222
|
+
assert.ok(Array.isArray(fileDiff), 'Response should be an array');
|
|
223
|
+
assert.strictEqual(fileDiff.length, 3, 'Should return 3 diff results');
|
|
224
|
+
});
|
|
225
|
+
test('returns error objects for not-found files', async () => {
|
|
226
|
+
// Request some existing + non-existing files
|
|
227
|
+
const fileDiff = await callGetMergeRequestFileDiff({
|
|
228
|
+
project_id: TEST_PROJECT_ID,
|
|
229
|
+
merge_request_iid: TEST_MR_IID,
|
|
230
|
+
file_paths: ['src/index.ts', 'nonexistent/file.txt', 'also_missing.py']
|
|
231
|
+
}, {
|
|
232
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
233
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
234
|
+
});
|
|
235
|
+
assert.ok(Array.isArray(fileDiff), 'Response should be an array');
|
|
236
|
+
// Should return 3 results: 1 success + 2 errors
|
|
237
|
+
assert.strictEqual(fileDiff.length, 3);
|
|
238
|
+
// Find the error entries
|
|
239
|
+
const errorEntries = fileDiff.filter((f) => f.error !== undefined);
|
|
240
|
+
const successEntries = fileDiff.filter((f) => f.error === undefined);
|
|
241
|
+
assert.strictEqual(errorEntries.length, 2, 'Should have 2 error entries');
|
|
242
|
+
assert.strictEqual(successEntries.length, 1, 'Should have 1 success entry');
|
|
243
|
+
// Verify error messages are helpful
|
|
244
|
+
const errorMsgs = errorEntries.map((e) => e.error);
|
|
245
|
+
assert.ok(errorMsgs.some(msg => msg.includes('nonexistent/file.txt')), 'Error message should mention nonexistent file');
|
|
246
|
+
assert.ok(errorMsgs.some(msg => msg.includes('also_missing.py')), 'Error message should mention other missing file');
|
|
247
|
+
// Check hint is present in at least one error
|
|
248
|
+
const hints = errorEntries.map((e) => e.hint).filter(Boolean);
|
|
249
|
+
assert.ok(hints.length > 0, 'Errors should include hints to check list_merge_request_changed_files');
|
|
250
|
+
});
|
|
251
|
+
});
|