@zereight/mcp-gitlab 2.0.33 → 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.
- package/README.md +12 -1
- package/build/gitlab-client-pool.js +108 -6
- package/build/index.js +110 -59
- package/build/oauth.js +11 -6
- package/build/schemas.js +3 -0
- 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 +236 -0
- package/build/test/test-upload-markdown.js +148 -0
- package/build/test/utils/mock-gitlab-server.js +5 -1
- package/package.json +1 -1
|
@@ -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 (
|
|
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;
|