gitlab-mcp-agent-server 0.2.2 → 0.2.4
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 +7 -3
- package/build/src/infrastructure/auth/gitlab-oauth-manager.js +233 -13
- package/build/src/infrastructure/auth/oauth-token-store.js +21 -2
- package/build/src/infrastructure/gitlab/gitlab-api-client.js +25 -20
- package/build/src/infrastructure/http/http-client.js +209 -0
- package/build/src/interface/mcp/create-mcp-server.js +9 -14
- package/build/src/shared/config.js +7 -66
- package/build/src/shared/errors.js +19 -1
- package/docs/USER_GUIDE.md +78 -29
- package/package.json +1 -1
- package/build/src/infrastructure/auth/group-oauth-token-provider.js +0 -51
package/README.md
CHANGED
|
@@ -5,7 +5,10 @@ MCP server for GitLab integration (TypeScript + Node.js).
|
|
|
5
5
|
Полный пользовательский сценарий подключения к ИИ-агенту:
|
|
6
6
|
- `docs/USER_GUIDE.md`
|
|
7
7
|
|
|
8
|
-
Основной сценарий:
|
|
8
|
+
Основной сценарий: модель `multi-instance`.
|
|
9
|
+
- Один MCP-блок в `~/.codex/config.toml` на один GitLab instance.
|
|
10
|
+
- Для `gitlab.com` и каждого self-hosted GitLab добавляется отдельный блок.
|
|
11
|
+
- Готовые блоки есть в `docs/USER_GUIDE.md`.
|
|
9
12
|
|
|
10
13
|
Для конечного пользователя обычно достаточно:
|
|
11
14
|
1. Зарегистрировать GitLab OAuth application.
|
|
@@ -13,9 +16,10 @@ MCP server for GitLab integration (TypeScript + Node.js).
|
|
|
13
16
|
|
|
14
17
|
Остальное работает по дефолту:
|
|
15
18
|
- OAuth auto-login при отсутствии токена.
|
|
16
|
-
- token store в `~/.config/gitlab-mcp
|
|
19
|
+
- instance-aware token store в `~/.config/gitlab-mcp/<gitlab-host>/token.json`.
|
|
20
|
+
- OAuth-flow lock на instance (`<tokenStorePath>.oauth.lock`) для исключения гонки callback-порта.
|
|
17
21
|
- auto-refresh access token.
|
|
18
|
-
-
|
|
22
|
+
- поддержка явного `project` в tool input и fallback-резолва проекта.
|
|
19
23
|
|
|
20
24
|
## Local setup (development)
|
|
21
25
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GitLabOAuthManager = void 0;
|
|
4
|
-
const node_http_1 = require("node:http");
|
|
5
|
-
const node_crypto_1 = require("node:crypto");
|
|
6
4
|
const node_child_process_1 = require("node:child_process");
|
|
7
|
-
const
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const node_http_1 = require("node:http");
|
|
8
8
|
const errors_1 = require("../../shared/errors");
|
|
9
9
|
const oauth_token_store_1 = require("./oauth-token-store");
|
|
10
10
|
class GitLabOAuthManager {
|
|
@@ -22,9 +22,20 @@ class GitLabOAuthManager {
|
|
|
22
22
|
return stored.accessToken;
|
|
23
23
|
}
|
|
24
24
|
if (stored?.refreshToken) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
try {
|
|
26
|
+
const refreshed = await this.refreshToken(stored.refreshToken);
|
|
27
|
+
this.tokenStore.write(refreshed);
|
|
28
|
+
return refreshed.accessToken;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (!shouldReloginOnRefreshFailure(error)) {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
this.tokenStore.delete();
|
|
35
|
+
if (!this.options.autoLogin) {
|
|
36
|
+
throw new errors_1.ConfigurationError('Stored OAuth refresh token is invalid or expired. Enable GITLAB_OAUTH_AUTO_LOGIN or re-authorize manually.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
28
39
|
}
|
|
29
40
|
if (this.options.bootstrapAccessToken) {
|
|
30
41
|
return this.options.bootstrapAccessToken;
|
|
@@ -32,10 +43,51 @@ class GitLabOAuthManager {
|
|
|
32
43
|
if (!this.options.autoLogin) {
|
|
33
44
|
throw new errors_1.ConfigurationError('OAuth token is missing. Enable GITLAB_OAUTH_AUTO_LOGIN or provide GITLAB_OAUTH_ACCESS_TOKEN.');
|
|
34
45
|
}
|
|
35
|
-
const interactiveToken = await this.
|
|
46
|
+
const interactiveToken = await this.loginInteractivelyWithLock();
|
|
36
47
|
this.tokenStore.write(interactiveToken);
|
|
37
48
|
return interactiveToken.accessToken;
|
|
38
49
|
}
|
|
50
|
+
async loginInteractivelyWithLock() {
|
|
51
|
+
const lockFilePath = `${this.options.tokenStorePath}.oauth.lock`;
|
|
52
|
+
const lock = acquireOauthLock(lockFilePath);
|
|
53
|
+
if (lock.acquired) {
|
|
54
|
+
try {
|
|
55
|
+
return await this.loginInteractively();
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
lock.release();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return this.waitForTokenFromOtherProcess(lockFilePath);
|
|
62
|
+
}
|
|
63
|
+
async waitForTokenFromOtherProcess(lockFilePath) {
|
|
64
|
+
const maxWaitMs = 2 * 60 * 1000;
|
|
65
|
+
const pollMs = 1000;
|
|
66
|
+
let elapsed = 0;
|
|
67
|
+
console.error('OAuth flow is already running in another process for this instance. Waiting for token...');
|
|
68
|
+
while (elapsed < maxWaitMs) {
|
|
69
|
+
const stored = this.tokenStore.read();
|
|
70
|
+
if (stored && !isExpiringSoon(stored.expiresAt)) {
|
|
71
|
+
return stored;
|
|
72
|
+
}
|
|
73
|
+
if (!(0, node_fs_1.existsSync)(lockFilePath) && stored?.refreshToken) {
|
|
74
|
+
try {
|
|
75
|
+
const refreshed = await this.refreshToken(stored.refreshToken);
|
|
76
|
+
this.tokenStore.write(refreshed);
|
|
77
|
+
return refreshed;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (shouldReloginOnRefreshFailure(error) && this.options.autoLogin) {
|
|
81
|
+
return this.loginInteractivelyWithLock();
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await sleep(pollMs);
|
|
87
|
+
elapsed += pollMs;
|
|
88
|
+
}
|
|
89
|
+
throw new Error('Timed out waiting for OAuth token from another process. Retry request or complete authorization in the first window.');
|
|
90
|
+
}
|
|
39
91
|
async refreshToken(refreshToken) {
|
|
40
92
|
this.assertOAuthClientCredentials();
|
|
41
93
|
this.assertRedirectUri();
|
|
@@ -54,7 +106,7 @@ class GitLabOAuthManager {
|
|
|
54
106
|
});
|
|
55
107
|
if (!response.ok) {
|
|
56
108
|
const body = await response.text();
|
|
57
|
-
throw new
|
|
109
|
+
throw new OAuthRefreshError(response.status, body);
|
|
58
110
|
}
|
|
59
111
|
const payload = (await response.json());
|
|
60
112
|
return mapTokenResponse(payload);
|
|
@@ -93,21 +145,42 @@ class GitLabOAuthManager {
|
|
|
93
145
|
const authCode = url.searchParams.get('code');
|
|
94
146
|
if (error) {
|
|
95
147
|
res.statusCode = 400;
|
|
96
|
-
res.
|
|
148
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
149
|
+
res.end(renderOAuthResultPage({
|
|
150
|
+
status: 'error',
|
|
151
|
+
title: 'Authorization Failed',
|
|
152
|
+
message: `GitLab returned error: ${escapeHtml(error)}.`,
|
|
153
|
+
hint: 'Return to your AI agent and retry the request.',
|
|
154
|
+
actionHref: localEntryUrl,
|
|
155
|
+
actionLabel: 'Start OAuth Again'
|
|
156
|
+
}));
|
|
97
157
|
server.close();
|
|
98
158
|
reject(new Error(`OAuth authorization failed: ${error}`));
|
|
99
159
|
return;
|
|
100
160
|
}
|
|
101
161
|
if (!authCode || responseState !== state) {
|
|
102
162
|
res.statusCode = 400;
|
|
103
|
-
res.
|
|
163
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
164
|
+
res.end(renderOAuthResultPage({
|
|
165
|
+
status: 'error',
|
|
166
|
+
title: 'Invalid OAuth Callback',
|
|
167
|
+
message: 'Authorization code is missing or state verification failed.',
|
|
168
|
+
hint: 'Return to your AI agent and retry the request.',
|
|
169
|
+
actionHref: localEntryUrl,
|
|
170
|
+
actionLabel: 'Start OAuth Again'
|
|
171
|
+
}));
|
|
104
172
|
server.close();
|
|
105
173
|
reject(new Error('Invalid OAuth callback: code/state mismatch.'));
|
|
106
174
|
return;
|
|
107
175
|
}
|
|
108
176
|
res.statusCode = 200;
|
|
109
177
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
110
|
-
res.end(
|
|
178
|
+
res.end(renderOAuthResultPage({
|
|
179
|
+
status: 'success',
|
|
180
|
+
title: 'Authorization Completed',
|
|
181
|
+
message: 'GitLab token is saved. You can return to your AI agent.',
|
|
182
|
+
hint: 'This tab can be closed now.'
|
|
183
|
+
}));
|
|
111
184
|
server.close();
|
|
112
185
|
resolve(authCode);
|
|
113
186
|
});
|
|
@@ -206,19 +279,114 @@ function resolvePort(url) {
|
|
|
206
279
|
function hasOpenCommand(platform) {
|
|
207
280
|
try {
|
|
208
281
|
if (platform === 'darwin') {
|
|
209
|
-
(0,
|
|
282
|
+
(0, node_child_process_1.execSync)('command -v open', { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
210
283
|
return true;
|
|
211
284
|
}
|
|
212
285
|
if (platform === 'win32') {
|
|
213
286
|
return true;
|
|
214
287
|
}
|
|
215
|
-
(0,
|
|
288
|
+
(0, node_child_process_1.execSync)('command -v xdg-open', { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
216
289
|
return true;
|
|
217
290
|
}
|
|
218
291
|
catch {
|
|
219
292
|
return false;
|
|
220
293
|
}
|
|
221
294
|
}
|
|
295
|
+
class OAuthRefreshError extends Error {
|
|
296
|
+
status;
|
|
297
|
+
body;
|
|
298
|
+
constructor(status, body) {
|
|
299
|
+
super(`Failed to refresh OAuth token: ${status} ${body}`);
|
|
300
|
+
this.status = status;
|
|
301
|
+
this.body = body;
|
|
302
|
+
this.name = 'OAuthRefreshError';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function shouldReloginOnRefreshFailure(error) {
|
|
306
|
+
if (!(error instanceof OAuthRefreshError)) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (error.status === 400 || error.status === 401) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
function acquireOauthLock(lockFilePath) {
|
|
315
|
+
let fd;
|
|
316
|
+
try {
|
|
317
|
+
fd = (0, node_fs_1.openSync)(lockFilePath, 'wx');
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
const e = error;
|
|
321
|
+
if (e.code !== 'EEXIST') {
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
if (isStaleLock(lockFilePath)) {
|
|
325
|
+
try {
|
|
326
|
+
(0, node_fs_1.unlinkSync)(lockFilePath);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// ignore race
|
|
330
|
+
}
|
|
331
|
+
return acquireOauthLock(lockFilePath);
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
acquired: false,
|
|
335
|
+
release: () => { }
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const payload = JSON.stringify({
|
|
339
|
+
pid: process.pid,
|
|
340
|
+
startedAt: new Date().toISOString()
|
|
341
|
+
}, null, 2);
|
|
342
|
+
(0, node_fs_1.writeFileSync)(fd, payload, 'utf8');
|
|
343
|
+
(0, node_fs_1.closeSync)(fd);
|
|
344
|
+
let released = false;
|
|
345
|
+
return {
|
|
346
|
+
acquired: true,
|
|
347
|
+
release: () => {
|
|
348
|
+
if (released) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
released = true;
|
|
352
|
+
try {
|
|
353
|
+
(0, node_fs_1.unlinkSync)(lockFilePath);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// ignore
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function isStaleLock(lockFilePath) {
|
|
362
|
+
try {
|
|
363
|
+
const raw = (0, node_fs_1.readFileSync)(lockFilePath, 'utf8');
|
|
364
|
+
const parsed = JSON.parse(raw);
|
|
365
|
+
if (!parsed.pid || !Number.isInteger(parsed.pid)) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
return !isProcessAlive(parsed.pid);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function isProcessAlive(pid) {
|
|
375
|
+
try {
|
|
376
|
+
process.kill(pid, 0);
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
const e = error;
|
|
381
|
+
if (e.code === 'EPERM') {
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function sleep(ms) {
|
|
388
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
389
|
+
}
|
|
222
390
|
function renderOAuthEntryPage(authorizeUrl) {
|
|
223
391
|
const escapedUrl = authorizeUrl.replace(/&/g, '&').replace(/"/g, '"');
|
|
224
392
|
return `<!doctype html>
|
|
@@ -251,3 +419,55 @@ function renderOAuthEntryPage(authorizeUrl) {
|
|
|
251
419
|
</body>
|
|
252
420
|
</html>`;
|
|
253
421
|
}
|
|
422
|
+
function renderOAuthResultPage(input) {
|
|
423
|
+
const title = escapeHtml(input.title);
|
|
424
|
+
const message = escapeHtml(input.message);
|
|
425
|
+
const hint = input.hint ? escapeHtml(input.hint) : '';
|
|
426
|
+
const isSuccess = input.status === 'success';
|
|
427
|
+
const borderColor = isSuccess ? '#14532d' : '#7f1d1d';
|
|
428
|
+
const badgeColor = isSuccess ? '#22c55e' : '#ef4444';
|
|
429
|
+
const bgTop = isSuccess ? '#052e16' : '#450a0a';
|
|
430
|
+
const button = input.actionHref && input.actionLabel
|
|
431
|
+
? `<a class="btn" href="${escapeHtmlAttr(input.actionHref)}">${escapeHtml(input.actionLabel)}</a>`
|
|
432
|
+
: '';
|
|
433
|
+
return `<!doctype html>
|
|
434
|
+
<html lang="en">
|
|
435
|
+
<head>
|
|
436
|
+
<meta charset="utf-8" />
|
|
437
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
438
|
+
<title>${title}</title>
|
|
439
|
+
<style>
|
|
440
|
+
body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; margin: 0; padding: 24px; background: radial-gradient(circle at top, ${bgTop}, #0b1220 55%); color: #e5e7eb; min-height: 100vh; }
|
|
441
|
+
.wrap { max-width: 680px; margin: 40px auto; }
|
|
442
|
+
.card { background: #121a2b; border: 1px solid ${borderColor}; border-radius: 14px; padding: 28px; box-shadow: 0 10px 30px rgba(0, 0, 0, .35); }
|
|
443
|
+
.badge { display: inline-block; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; background: ${badgeColor}; color: #fff; }
|
|
444
|
+
h1 { margin: 12px 0 8px; font-size: 24px; }
|
|
445
|
+
p { margin: 0 0 12px; color: #cbd5e1; line-height: 1.55; }
|
|
446
|
+
.hint { font-size: 13px; color: #94a3b8; }
|
|
447
|
+
.btn { display: inline-block; margin-top: 10px; background: #2563eb; color: #fff; text-decoration: none; padding: 10px 14px; border-radius: 8px; font-weight: 600; }
|
|
448
|
+
</style>
|
|
449
|
+
</head>
|
|
450
|
+
<body>
|
|
451
|
+
<div class="wrap">
|
|
452
|
+
<div class="card">
|
|
453
|
+
<span class="badge">${isSuccess ? 'Success' : 'Error'}</span>
|
|
454
|
+
<h1>${title}</h1>
|
|
455
|
+
<p>${message}</p>
|
|
456
|
+
${hint ? `<p class="hint">${hint}</p>` : ''}
|
|
457
|
+
${button}
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
</body>
|
|
461
|
+
</html>`;
|
|
462
|
+
}
|
|
463
|
+
function escapeHtml(value) {
|
|
464
|
+
return value
|
|
465
|
+
.replace(/&/g, '&')
|
|
466
|
+
.replace(/</g, '<')
|
|
467
|
+
.replace(/>/g, '>')
|
|
468
|
+
.replace(/"/g, '"')
|
|
469
|
+
.replace(/'/g, ''');
|
|
470
|
+
}
|
|
471
|
+
function escapeHtmlAttr(value) {
|
|
472
|
+
return escapeHtml(value);
|
|
473
|
+
}
|
|
@@ -12,9 +12,17 @@ class OAuthTokenStore {
|
|
|
12
12
|
if (!(0, node_fs_1.existsSync)(this.filePath)) {
|
|
13
13
|
return undefined;
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
const raw = (0, node_fs_1.readFileSync)(this.filePath, 'utf8');
|
|
18
|
+
parsed = JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
this.delete();
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
17
24
|
if (!parsed.accessToken || !parsed.expiresAt) {
|
|
25
|
+
this.delete();
|
|
18
26
|
return undefined;
|
|
19
27
|
}
|
|
20
28
|
return parsed;
|
|
@@ -24,5 +32,16 @@ class OAuthTokenStore {
|
|
|
24
32
|
(0, node_fs_1.writeFileSync)(this.filePath, JSON.stringify(token, null, 2), 'utf8');
|
|
25
33
|
(0, node_fs_1.chmodSync)(this.filePath, 0o600);
|
|
26
34
|
}
|
|
35
|
+
delete() {
|
|
36
|
+
if (!(0, node_fs_1.existsSync)(this.filePath)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
(0, node_fs_1.unlinkSync)(this.filePath);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
27
46
|
}
|
|
28
47
|
exports.OAuthTokenStore = OAuthTokenStore;
|
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GitLabApiClient = void 0;
|
|
4
4
|
const errors_1 = require("../../shared/errors");
|
|
5
|
+
const http_client_1 = require("../http/http-client");
|
|
5
6
|
class GitLabApiClient {
|
|
6
7
|
options;
|
|
8
|
+
httpClient;
|
|
7
9
|
constructor(options) {
|
|
8
10
|
this.options = options;
|
|
11
|
+
this.httpClient = new http_client_1.HttpClient({
|
|
12
|
+
service: 'gitlab',
|
|
13
|
+
timeoutMs: 12_000,
|
|
14
|
+
maxRetries: 2,
|
|
15
|
+
retryBaseDelayMs: 200
|
|
16
|
+
});
|
|
9
17
|
}
|
|
10
18
|
async listProjects() {
|
|
11
19
|
const data = await this.request('/projects?simple=true&membership=true');
|
|
@@ -93,19 +101,24 @@ class GitLabApiClient {
|
|
|
93
101
|
if (!token) {
|
|
94
102
|
throw new errors_1.ConfigurationError('GitLab access token is not configured. Set GITLAB_OAUTH_ACCESS_TOKEN (oauth) or GITLAB_PAT (pat).');
|
|
95
103
|
}
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
const mergedHeaders = {
|
|
105
|
+
Authorization: `Bearer ${token}`,
|
|
106
|
+
Accept: 'application/json'
|
|
107
|
+
};
|
|
108
|
+
const hasBody = Boolean(init?.body);
|
|
109
|
+
if (hasBody) {
|
|
110
|
+
mergedHeaders['Content-Type'] = 'application/json';
|
|
111
|
+
}
|
|
112
|
+
const fromInit = init?.headers;
|
|
113
|
+
if (fromInit && typeof fromInit === 'object' && !Array.isArray(fromInit)) {
|
|
114
|
+
Object.assign(mergedHeaders, fromInit);
|
|
107
115
|
}
|
|
108
|
-
return
|
|
116
|
+
return this.httpClient.requestJson({
|
|
117
|
+
url: `${this.options.apiUrl}${path}`,
|
|
118
|
+
method: init?.method ?? 'GET',
|
|
119
|
+
headers: mergedHeaders,
|
|
120
|
+
body: typeof init?.body === 'string' ? init.body : undefined
|
|
121
|
+
});
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
124
|
exports.GitLabApiClient = GitLabApiClient;
|
|
@@ -140,11 +153,3 @@ function mapLabel(label) {
|
|
|
140
153
|
function encodeProjectRef(project) {
|
|
141
154
|
return encodeURIComponent(String(project));
|
|
142
155
|
}
|
|
143
|
-
async function safeReadBody(response) {
|
|
144
|
-
try {
|
|
145
|
-
return await response.text();
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
return 'Unable to read response body';
|
|
149
|
-
}
|
|
150
|
-
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HttpClient = void 0;
|
|
4
|
+
const errors_1 = require("../../shared/errors");
|
|
5
|
+
class HttpClient {
|
|
6
|
+
service;
|
|
7
|
+
timeoutMs;
|
|
8
|
+
maxRetries;
|
|
9
|
+
retryBaseDelayMs;
|
|
10
|
+
logger;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.service = options.service;
|
|
13
|
+
this.timeoutMs = options.timeoutMs ?? 10_000;
|
|
14
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
15
|
+
this.retryBaseDelayMs = options.retryBaseDelayMs ?? 200;
|
|
16
|
+
this.logger = options.logger;
|
|
17
|
+
}
|
|
18
|
+
async requestJson(options) {
|
|
19
|
+
const method = (options.method ?? 'GET').toUpperCase();
|
|
20
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(options.url, {
|
|
25
|
+
method,
|
|
26
|
+
headers: options.headers,
|
|
27
|
+
body: options.body,
|
|
28
|
+
signal: controller.signal
|
|
29
|
+
});
|
|
30
|
+
clearTimeout(timeout);
|
|
31
|
+
if (response.ok) {
|
|
32
|
+
return (await response.json());
|
|
33
|
+
}
|
|
34
|
+
const body = await safeReadBody(response);
|
|
35
|
+
const requestId = response.headers.get('x-request-id') ?? response.headers.get('x-gitlab-request-id') ?? undefined;
|
|
36
|
+
const error = mapHttpError(this.service, response.status, body, requestId);
|
|
37
|
+
this.log({
|
|
38
|
+
level: 'error',
|
|
39
|
+
event: 'http.request.failed',
|
|
40
|
+
service: this.service,
|
|
41
|
+
method,
|
|
42
|
+
url: options.url,
|
|
43
|
+
attempt,
|
|
44
|
+
status: response.status,
|
|
45
|
+
requestId,
|
|
46
|
+
retriable: Boolean(error.options?.retriable)
|
|
47
|
+
});
|
|
48
|
+
if (attempt < this.maxRetries && isRetryableHttpStatus(response.status) && isRetryableMethod(method)) {
|
|
49
|
+
const delayMs = backoff(attempt, this.retryBaseDelayMs);
|
|
50
|
+
this.log({
|
|
51
|
+
level: 'warn',
|
|
52
|
+
event: 'http.request.retry',
|
|
53
|
+
service: this.service,
|
|
54
|
+
method,
|
|
55
|
+
url: options.url,
|
|
56
|
+
attempt,
|
|
57
|
+
status: response.status,
|
|
58
|
+
requestId,
|
|
59
|
+
delayMs
|
|
60
|
+
});
|
|
61
|
+
await sleep(delayMs);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
clearTimeout(timeout);
|
|
68
|
+
const mapped = mapTransportError(this.service, error);
|
|
69
|
+
this.log({
|
|
70
|
+
level: 'error',
|
|
71
|
+
event: 'http.request.transport_error',
|
|
72
|
+
service: this.service,
|
|
73
|
+
method,
|
|
74
|
+
url: options.url,
|
|
75
|
+
attempt,
|
|
76
|
+
kind: mapped.kind,
|
|
77
|
+
retriable: Boolean(mapped.options?.retriable)
|
|
78
|
+
});
|
|
79
|
+
if (attempt < this.maxRetries && mapped.options?.retriable && isRetryableMethod(method)) {
|
|
80
|
+
const delayMs = backoff(attempt, this.retryBaseDelayMs);
|
|
81
|
+
this.log({
|
|
82
|
+
level: 'warn',
|
|
83
|
+
event: 'http.request.retry',
|
|
84
|
+
service: this.service,
|
|
85
|
+
method,
|
|
86
|
+
url: options.url,
|
|
87
|
+
attempt,
|
|
88
|
+
kind: mapped.kind,
|
|
89
|
+
delayMs
|
|
90
|
+
});
|
|
91
|
+
await sleep(delayMs);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw mapped;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new errors_1.ExternalServiceError(this.service, 'unknown', 'Request failed after retries.', {
|
|
98
|
+
retriable: false
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
log(event) {
|
|
102
|
+
if (this.logger) {
|
|
103
|
+
this.logger(event);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const line = JSON.stringify(event);
|
|
107
|
+
if (event.level === 'error') {
|
|
108
|
+
console.error(line);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (event.level === 'warn') {
|
|
112
|
+
console.warn(line);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
exports.HttpClient = HttpClient;
|
|
117
|
+
function mapHttpError(service, status, body, requestId) {
|
|
118
|
+
if (status === 401 || status === 403) {
|
|
119
|
+
return new errors_1.ExternalServiceError(service, 'auth', `Unauthorized (${status})`, {
|
|
120
|
+
status,
|
|
121
|
+
requestId,
|
|
122
|
+
body,
|
|
123
|
+
retriable: false
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (status === 404) {
|
|
127
|
+
return new errors_1.ExternalServiceError(service, 'not_found', 'Resource not found (404).', {
|
|
128
|
+
status,
|
|
129
|
+
requestId,
|
|
130
|
+
body,
|
|
131
|
+
retriable: false
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (status === 429) {
|
|
135
|
+
return new errors_1.ExternalServiceError(service, 'rate_limit', 'Rate limit reached (429).', {
|
|
136
|
+
status,
|
|
137
|
+
requestId,
|
|
138
|
+
body,
|
|
139
|
+
retriable: true
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (status >= 500) {
|
|
143
|
+
return new errors_1.ExternalServiceError(service, 'server', `Server error (${status}).`, {
|
|
144
|
+
status,
|
|
145
|
+
requestId,
|
|
146
|
+
body,
|
|
147
|
+
retriable: true
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (status >= 400) {
|
|
151
|
+
return new errors_1.ExternalServiceError(service, 'validation', `Request failed (${status}).`, {
|
|
152
|
+
status,
|
|
153
|
+
requestId,
|
|
154
|
+
body,
|
|
155
|
+
retriable: false
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return new errors_1.ExternalServiceError(service, 'unknown', `Unexpected HTTP status (${status}).`, {
|
|
159
|
+
status,
|
|
160
|
+
requestId,
|
|
161
|
+
body,
|
|
162
|
+
retriable: false
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function mapTransportError(service, error) {
|
|
166
|
+
if (error instanceof errors_1.ExternalServiceError) {
|
|
167
|
+
return error;
|
|
168
|
+
}
|
|
169
|
+
if (isAbortError(error)) {
|
|
170
|
+
return new errors_1.ExternalServiceError(service, 'timeout', 'Request timed out.', {
|
|
171
|
+
retriable: true,
|
|
172
|
+
cause: error
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return new errors_1.ExternalServiceError(service, 'network', 'Network error during request.', {
|
|
176
|
+
retriable: true,
|
|
177
|
+
cause: error
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function isAbortError(error) {
|
|
181
|
+
if (typeof error !== 'object' || error === null) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const maybe = error;
|
|
185
|
+
const name = (maybe.name ?? '').toLowerCase();
|
|
186
|
+
const message = (maybe.message ?? '').toLowerCase();
|
|
187
|
+
return name.includes('abort') || name.includes('timeout') || message.includes('aborted');
|
|
188
|
+
}
|
|
189
|
+
function isRetryableHttpStatus(status) {
|
|
190
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
191
|
+
}
|
|
192
|
+
function isRetryableMethod(method) {
|
|
193
|
+
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
|
|
194
|
+
}
|
|
195
|
+
function backoff(attempt, baseMs) {
|
|
196
|
+
const jitter = Math.floor(Math.random() * 40);
|
|
197
|
+
return baseMs * 2 ** attempt + jitter;
|
|
198
|
+
}
|
|
199
|
+
async function safeReadBody(response) {
|
|
200
|
+
try {
|
|
201
|
+
return await response.text();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function sleep(ms) {
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
209
|
+
}
|
|
@@ -12,7 +12,6 @@ const health_check_1 = require("../../application/use-cases/health-check");
|
|
|
12
12
|
const list_labels_1 = require("../../application/use-cases/list-labels");
|
|
13
13
|
const list_issues_1 = require("../../application/use-cases/list-issues");
|
|
14
14
|
const update_issue_labels_1 = require("../../application/use-cases/update-issue-labels");
|
|
15
|
-
const group_oauth_token_provider_1 = require("../../infrastructure/auth/group-oauth-token-provider");
|
|
16
15
|
const gitlab_oauth_manager_1 = require("../../infrastructure/auth/gitlab-oauth-manager");
|
|
17
16
|
const token_provider_1 = require("../../infrastructure/auth/token-provider");
|
|
18
17
|
const gitlab_api_client_1 = require("../../infrastructure/gitlab/gitlab-api-client");
|
|
@@ -21,20 +20,16 @@ const register_tools_1 = require("./register-tools");
|
|
|
21
20
|
function createMcpServer() {
|
|
22
21
|
const config = (0, config_1.loadConfig)();
|
|
23
22
|
const tokenProvider = config.gitlab.authMode === 'oauth'
|
|
24
|
-
? new
|
|
23
|
+
? new gitlab_oauth_manager_1.GitLabOAuthManager({
|
|
25
24
|
apiUrl: config.gitlab.apiUrl,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
autoLogin: config.gitlab.oauth.autoLogin,
|
|
35
|
-
openBrowser: config.gitlab.oauth.openBrowser
|
|
36
|
-
}),
|
|
37
|
-
groupConfigs: config.gitlab.groupOAuthConfigs
|
|
25
|
+
clientId: config.gitlab.oauth.clientId,
|
|
26
|
+
clientSecret: config.gitlab.oauth.clientSecret,
|
|
27
|
+
redirectUri: config.gitlab.oauth.redirectUri,
|
|
28
|
+
scopes: config.gitlab.oauth.scopes,
|
|
29
|
+
bootstrapAccessToken: config.gitlab.accessToken,
|
|
30
|
+
tokenStorePath: config.gitlab.oauth.tokenStorePath,
|
|
31
|
+
autoLogin: config.gitlab.oauth.autoLogin,
|
|
32
|
+
openBrowser: config.gitlab.oauth.openBrowser
|
|
38
33
|
})
|
|
39
34
|
: new token_provider_1.StaticTokenProvider(config.gitlab.accessToken);
|
|
40
35
|
const gitlabApiClient = new gitlab_api_client_1.GitLabApiClient({
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.loadConfig = loadConfig;
|
|
4
|
+
exports.resolveDefaultTokenStorePath = resolveDefaultTokenStorePath;
|
|
4
5
|
const node_child_process_1 = require("node:child_process");
|
|
5
6
|
const node_os_1 = require("node:os");
|
|
6
7
|
const node_path_1 = require("node:path");
|
|
7
8
|
const zod_1 = require("zod");
|
|
8
|
-
const DEFAULT_TOKEN_STORE_PATH = (0, node_path_1.join)((0, node_os_1.homedir)(), '.config', 'gitlab-mcp', 'token.json');
|
|
9
9
|
const EnvSchema = zod_1.z.object({
|
|
10
10
|
GITLAB_API_URL: zod_1.z.string().url().default('https://gitlab.com/api/v4'),
|
|
11
11
|
GITLAB_AUTH_MODE: zod_1.z.enum(['oauth', 'pat']).default('oauth'),
|
|
@@ -14,7 +14,7 @@ const EnvSchema = zod_1.z.object({
|
|
|
14
14
|
GITLAB_OAUTH_CLIENT_SECRET: zod_1.z.string().optional(),
|
|
15
15
|
GITLAB_OAUTH_REDIRECT_URI: zod_1.z.string().optional(),
|
|
16
16
|
GITLAB_OAUTH_SCOPES: zod_1.z.string().default('api'),
|
|
17
|
-
GITLAB_OAUTH_TOKEN_STORE_PATH: zod_1.z.string().
|
|
17
|
+
GITLAB_OAUTH_TOKEN_STORE_PATH: zod_1.z.string().optional(),
|
|
18
18
|
GITLAB_OAUTH_AUTO_LOGIN: zod_1.z
|
|
19
19
|
.enum(['true', 'false'])
|
|
20
20
|
.optional()
|
|
@@ -23,7 +23,6 @@ const EnvSchema = zod_1.z.object({
|
|
|
23
23
|
.enum(['true', 'false'])
|
|
24
24
|
.optional()
|
|
25
25
|
.transform((value) => value !== 'false'),
|
|
26
|
-
GITLAB_GROUP_OAUTH_CONFIG_JSON: zod_1.z.string().optional(),
|
|
27
26
|
GITLAB_PAT: zod_1.z.string().optional(),
|
|
28
27
|
GITLAB_DEFAULT_PROJECT: zod_1.z.string().optional(),
|
|
29
28
|
GITLAB_AUTO_RESOLVE_PROJECT_FROM_GIT: zod_1.z
|
|
@@ -57,6 +56,7 @@ function loadConfig() {
|
|
|
57
56
|
const autoDetectedProject = env.GITLAB_AUTO_RESOLVE_PROJECT_FROM_GIT
|
|
58
57
|
? detectProjectFromGitRemote()
|
|
59
58
|
: undefined;
|
|
59
|
+
const defaultTokenStorePath = env.GITLAB_OAUTH_TOKEN_STORE_PATH ?? resolveDefaultTokenStorePath(env.GITLAB_API_URL);
|
|
60
60
|
return {
|
|
61
61
|
gitlab: {
|
|
62
62
|
apiUrl: env.GITLAB_API_URL,
|
|
@@ -67,16 +67,10 @@ function loadConfig() {
|
|
|
67
67
|
clientSecret: env.GITLAB_OAUTH_CLIENT_SECRET,
|
|
68
68
|
redirectUri: env.GITLAB_OAUTH_REDIRECT_URI,
|
|
69
69
|
scopes: splitCsv(env.GITLAB_OAUTH_SCOPES),
|
|
70
|
-
tokenStorePath:
|
|
70
|
+
tokenStorePath: defaultTokenStorePath,
|
|
71
71
|
autoLogin: env.GITLAB_OAUTH_AUTO_LOGIN,
|
|
72
72
|
openBrowser: env.GITLAB_OAUTH_OPEN_BROWSER
|
|
73
73
|
},
|
|
74
|
-
groupOAuthConfigs: parseGroupOAuthConfigJson(env.GITLAB_GROUP_OAUTH_CONFIG_JSON, {
|
|
75
|
-
redirectUri: env.GITLAB_OAUTH_REDIRECT_URI,
|
|
76
|
-
scopes: splitCsv(env.GITLAB_OAUTH_SCOPES),
|
|
77
|
-
autoLogin: env.GITLAB_OAUTH_AUTO_LOGIN,
|
|
78
|
-
openBrowser: env.GITLAB_OAUTH_OPEN_BROWSER
|
|
79
|
-
}),
|
|
80
74
|
defaultProject: env.GITLAB_DEFAULT_PROJECT,
|
|
81
75
|
autoResolveProjectFromGit: env.GITLAB_AUTO_RESOLVE_PROJECT_FROM_GIT,
|
|
82
76
|
autoDetectedProject
|
|
@@ -91,62 +85,9 @@ function loadConfig() {
|
|
|
91
85
|
}
|
|
92
86
|
};
|
|
93
87
|
}
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
let parsed;
|
|
99
|
-
try {
|
|
100
|
-
parsed = JSON.parse(raw);
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
throw new Error('GITLAB_GROUP_OAUTH_CONFIG_JSON must be valid JSON.');
|
|
104
|
-
}
|
|
105
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
106
|
-
throw new Error('GITLAB_GROUP_OAUTH_CONFIG_JSON must be an object map.');
|
|
107
|
-
}
|
|
108
|
-
const result = {};
|
|
109
|
-
for (const [groupKey, value] of Object.entries(parsed)) {
|
|
110
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
const rec = value;
|
|
114
|
-
result[groupKey] = {
|
|
115
|
-
clientId: toStringOrUndefined(rec.clientId ?? rec.client_id),
|
|
116
|
-
clientSecret: toStringOrUndefined(rec.clientSecret ?? rec.client_secret),
|
|
117
|
-
redirectUri: toStringOrUndefined(rec.redirectUri ?? rec.redirect_uri ?? defaults.redirectUri),
|
|
118
|
-
scopes: parseScopes(rec.scopes, defaults.scopes),
|
|
119
|
-
tokenStorePath: toStringOrUndefined(rec.tokenStorePath ?? rec.token_store_path) ??
|
|
120
|
-
(0, node_path_1.join)((0, node_os_1.homedir)(), '.config', 'gitlab-mcp', `${sanitizeForFilename(groupKey)}-token.json`),
|
|
121
|
-
autoLogin: toBooleanOrDefault(rec.autoLogin ?? rec.auto_login, defaults.autoLogin),
|
|
122
|
-
openBrowser: toBooleanOrDefault(rec.openBrowser ?? rec.open_browser, defaults.openBrowser),
|
|
123
|
-
bootstrapAccessToken: toStringOrUndefined(rec.bootstrapAccessToken ?? rec.bootstrap_access_token)
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
return result;
|
|
127
|
-
}
|
|
128
|
-
function parseScopes(value, fallback) {
|
|
129
|
-
if (typeof value === 'string') {
|
|
130
|
-
return splitCsv(value);
|
|
131
|
-
}
|
|
132
|
-
if (Array.isArray(value)) {
|
|
133
|
-
return value
|
|
134
|
-
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
|
135
|
-
.filter((item) => item.length > 0);
|
|
136
|
-
}
|
|
137
|
-
return fallback;
|
|
138
|
-
}
|
|
139
|
-
function toStringOrUndefined(value) {
|
|
140
|
-
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
141
|
-
}
|
|
142
|
-
function toBooleanOrDefault(value, fallback) {
|
|
143
|
-
if (typeof value === 'boolean') {
|
|
144
|
-
return value;
|
|
145
|
-
}
|
|
146
|
-
if (typeof value === 'string') {
|
|
147
|
-
return value !== 'false';
|
|
148
|
-
}
|
|
149
|
-
return fallback;
|
|
88
|
+
function resolveDefaultTokenStorePath(apiUrl, filePrefix = 'token') {
|
|
89
|
+
const hostKey = sanitizeForFilename(new URL(apiUrl).host.toLowerCase());
|
|
90
|
+
return (0, node_path_1.join)((0, node_os_1.homedir)(), '.config', 'gitlab-mcp', hostKey, `${filePrefix}.json`);
|
|
150
91
|
}
|
|
151
92
|
function sanitizeForFilename(value) {
|
|
152
93
|
return value.replace(/[^a-zA-Z0-9._-]+/g, '_');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ConfigurationError = exports.PolicyError = void 0;
|
|
3
|
+
exports.ExternalServiceError = exports.ConfigurationError = exports.PolicyError = void 0;
|
|
4
4
|
class PolicyError extends Error {
|
|
5
5
|
constructor(message) {
|
|
6
6
|
super(message);
|
|
@@ -15,3 +15,21 @@ class ConfigurationError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
exports.ConfigurationError = ConfigurationError;
|
|
18
|
+
class ExternalServiceError extends Error {
|
|
19
|
+
service;
|
|
20
|
+
kind;
|
|
21
|
+
message;
|
|
22
|
+
options;
|
|
23
|
+
constructor(service, kind, message, options) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.service = service;
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
this.message = message;
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.name = 'ExternalServiceError';
|
|
30
|
+
if (options?.cause !== undefined) {
|
|
31
|
+
this.cause = options.cause;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
exports.ExternalServiceError = ExternalServiceError;
|
package/docs/USER_GUIDE.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# User Guide
|
|
2
2
|
|
|
3
|
-
Этот гайд для конечного пользователя: как подключить `gitlab-mcp-agent-server` в **Codex
|
|
3
|
+
Этот гайд для конечного пользователя: как подключить `gitlab-mcp-agent-server` в **Codex**.
|
|
4
|
+
|
|
5
|
+
Основная модель: **multi-instance**.
|
|
6
|
+
- Один блок MCP в `config.toml` = один GitLab instance.
|
|
7
|
+
- Для `gitlab.com` и каждого self-hosted GitLab создаётся отдельный MCP-блок.
|
|
4
8
|
|
|
5
9
|
## 1. Подготовка GitLab OAuth Application
|
|
6
10
|
|
|
@@ -25,15 +29,16 @@
|
|
|
25
29
|
|
|
26
30
|
## 3. Конфиг Codex (`~/.codex/config.toml`)
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
### 3.1 Один instance (минимально)
|
|
29
33
|
|
|
30
34
|
```toml
|
|
31
|
-
[mcp_servers.
|
|
35
|
+
[mcp_servers.gitlab_com]
|
|
32
36
|
command = "bash"
|
|
33
37
|
args = ["-lc", """
|
|
34
38
|
export NVM_DIR="$HOME/.nvm";
|
|
35
39
|
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh";
|
|
36
40
|
|
|
41
|
+
export GITLAB_API_URL="https://gitlab.com/api/v4";
|
|
37
42
|
export GITLAB_OAUTH_CLIENT_ID="<APPLICATION_ID>";
|
|
38
43
|
export GITLAB_OAUTH_CLIENT_SECRET="<SECRET>";
|
|
39
44
|
|
|
@@ -41,10 +46,10 @@ npx -y gitlab-mcp-agent-server
|
|
|
41
46
|
"""]
|
|
42
47
|
```
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
### 3.2 Несколько instances (рекомендуемая модель)
|
|
45
50
|
|
|
46
51
|
```toml
|
|
47
|
-
[mcp_servers.
|
|
52
|
+
[mcp_servers.gitlab_com]
|
|
48
53
|
command = "bash"
|
|
49
54
|
args = ["-lc", """
|
|
50
55
|
export NVM_DIR="$HOME/.nvm";
|
|
@@ -52,18 +57,28 @@ export NVM_DIR="$HOME/.nvm";
|
|
|
52
57
|
|
|
53
58
|
export GITLAB_API_URL="https://gitlab.com/api/v4";
|
|
54
59
|
export GITLAB_AUTH_MODE="oauth";
|
|
55
|
-
export GITLAB_OAUTH_CLIENT_ID="<
|
|
56
|
-
export GITLAB_OAUTH_CLIENT_SECRET="<
|
|
60
|
+
export GITLAB_OAUTH_CLIENT_ID="<GITLAB_COM_APP_ID>";
|
|
61
|
+
export GITLAB_OAUTH_CLIENT_SECRET="<GITLAB_COM_APP_SECRET>";
|
|
57
62
|
export GITLAB_OAUTH_REDIRECT_URI="http://127.0.0.1:8787/oauth/callback";
|
|
58
|
-
export GITLAB_OAUTH_SCOPES="api";
|
|
59
|
-
export GITLAB_OAUTH_TOKEN_STORE_PATH="$HOME/.config/gitlab-mcp/token.json";
|
|
60
63
|
export GITLAB_OAUTH_AUTO_LOGIN="true";
|
|
61
|
-
export GITLAB_OAUTH_OPEN_BROWSER="
|
|
62
|
-
|
|
64
|
+
export GITLAB_OAUTH_OPEN_BROWSER="true";
|
|
65
|
+
|
|
66
|
+
npx -y gitlab-mcp-agent-server
|
|
67
|
+
"""]
|
|
63
68
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
[mcp_servers.gitlab_work]
|
|
70
|
+
command = "bash"
|
|
71
|
+
args = ["-lc", """
|
|
72
|
+
export NVM_DIR="$HOME/.nvm";
|
|
73
|
+
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh";
|
|
74
|
+
|
|
75
|
+
export GITLAB_API_URL="https://gitlab.work.local/api/v4";
|
|
76
|
+
export GITLAB_AUTH_MODE="oauth";
|
|
77
|
+
export GITLAB_OAUTH_CLIENT_ID="<WORK_APP_ID>";
|
|
78
|
+
export GITLAB_OAUTH_CLIENT_SECRET="<WORK_APP_SECRET>";
|
|
79
|
+
export GITLAB_OAUTH_REDIRECT_URI="http://127.0.0.1:8788/oauth/callback";
|
|
80
|
+
export GITLAB_OAUTH_AUTO_LOGIN="true";
|
|
81
|
+
export GITLAB_OAUTH_OPEN_BROWSER="true";
|
|
67
82
|
|
|
68
83
|
npx -y gitlab-mcp-agent-server
|
|
69
84
|
"""]
|
|
@@ -71,10 +86,13 @@ npx -y gitlab-mcp-agent-server
|
|
|
71
86
|
|
|
72
87
|
После изменения `config.toml` перезапусти Codex.
|
|
73
88
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
По умолчанию token store instance-aware:
|
|
90
|
+
- `~/.config/gitlab-mcp/gitlab.com/token.json`
|
|
91
|
+
- `~/.config/gitlab-mcp/gitlab.work.local/token.json`
|
|
92
|
+
|
|
93
|
+
Для OAuth используется lock-файл на instance:
|
|
94
|
+
- `<tokenStorePath>.oauth.lock`
|
|
95
|
+
- если OAuth уже идёт в другом процессе, текущий процесс ждёт появления токена.
|
|
78
96
|
|
|
79
97
|
## 4. Что происходит при первом запуске
|
|
80
98
|
|
|
@@ -83,11 +101,10 @@ npx -y gitlab-mcp-agent-server
|
|
|
83
101
|
3. Если `GITLAB_OAUTH_OPEN_BROWSER=true` и окружение GUI доступно, браузер откроется автоматически.
|
|
84
102
|
4. Локальный URL `http://127.0.0.1:8787/` автоматически редиректит на GitLab OAuth.
|
|
85
103
|
5. Если браузер не может быть открыт, сервер печатает URL авторизации в лог.
|
|
86
|
-
6. После подтверждения в GitLab и callback на `http://127.0.0.1:8787/oauth/callback` токены сохраняются в
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
2. если не удалось, tool запросит `project` явно.
|
|
104
|
+
6. После подтверждения в GitLab и callback на `http://127.0.0.1:8787/oauth/callback` токены сохраняются в token store для конкретного instance.
|
|
105
|
+
7. В браузере показывается feedback-страница:
|
|
106
|
+
- `Success`: токен сохранен, можно вернуться в ИИ-агент;
|
|
107
|
+
- `Error`: показана причина и кнопка повторного запуска OAuth.
|
|
91
108
|
|
|
92
109
|
## 5. Автообновление токена
|
|
93
110
|
|
|
@@ -95,17 +112,32 @@ npx -y gitlab-mcp-agent-server
|
|
|
95
112
|
1. Проверяет срок действия `access_token` перед API-вызовами.
|
|
96
113
|
2. Выполняет refresh по `refresh_token`, если токен истекает.
|
|
97
114
|
3. Перезаписывает token store файл новым токеном.
|
|
115
|
+
4. Если refresh token отозван/протух, очищает token store и запускает OAuth заново (при `GITLAB_OAUTH_AUTO_LOGIN=true`).
|
|
98
116
|
|
|
99
117
|
## 6. Рекомендованные настройки для production
|
|
100
118
|
|
|
101
119
|
1. Держи token store вне репозитория:
|
|
102
|
-
- пример: `/home/<user>/.config/gitlab-mcp/token.json`
|
|
120
|
+
- пример: `/home/<user>/.config/gitlab-mcp/gitlab.com/token.json`
|
|
103
121
|
2. Ограничь права файла:
|
|
104
|
-
- `chmod 600 /home/<user>/.config/gitlab-mcp/token.json`
|
|
105
|
-
3.
|
|
106
|
-
|
|
122
|
+
- `chmod 600 /home/<user>/.config/gitlab-mcp/gitlab.com/token.json`
|
|
123
|
+
3. Для headless окружений ставь `GITLAB_OAUTH_OPEN_BROWSER=false`.
|
|
124
|
+
|
|
125
|
+
## 7. Сценарий пользовательского запроса
|
|
126
|
+
|
|
127
|
+
Пример запроса: `Покажи мне все задачи, которые сейчас есть в проекте?`
|
|
107
128
|
|
|
108
|
-
|
|
129
|
+
1. Пользователь добавляет MCP-блок в `~/.codex/config.toml`.
|
|
130
|
+
2. Codex поднимает сервер командой `npx -y gitlab-mcp-agent-server`.
|
|
131
|
+
3. Codex вызывает tool `gitlab_list_issues`.
|
|
132
|
+
4. Сервер резолвит проект в порядке:
|
|
133
|
+
- `project` из входа tool,
|
|
134
|
+
- `git remote origin` текущего `cwd`,
|
|
135
|
+
- `GITLAB_DEFAULT_PROJECT`.
|
|
136
|
+
5. Если токена для этого instance нет, запускается OAuth flow.
|
|
137
|
+
6. После callback токен сохраняется в token store этого instance.
|
|
138
|
+
7. Сервер возвращает список issues.
|
|
139
|
+
|
|
140
|
+
## 8. Быстрая проверка работоспособности
|
|
109
141
|
|
|
110
142
|
1. Вызови `gitlab_list_labels`.
|
|
111
143
|
2. Создай issue: `gitlab_create_issue`.
|
|
@@ -113,7 +145,7 @@ npx -y gitlab-mcp-agent-server
|
|
|
113
145
|
4. Обнови labels: `gitlab_update_issue_labels`.
|
|
114
146
|
5. Закрой issue: `gitlab_close_issue`.
|
|
115
147
|
|
|
116
|
-
##
|
|
148
|
+
## 9. Troubleshooting
|
|
117
149
|
|
|
118
150
|
`The redirect URI included is not valid`:
|
|
119
151
|
1. Проверь, что URI совпадает 1-в-1:
|
|
@@ -125,3 +157,20 @@ npx -y gitlab-mcp-agent-server
|
|
|
125
157
|
1. Проверь scope `api`.
|
|
126
158
|
2. Убедись, что `client_id`/`client_secret` от того же приложения.
|
|
127
159
|
3. Удали token store файл и пройди OAuth заново.
|
|
160
|
+
|
|
161
|
+
`Stored OAuth refresh token is invalid or expired`:
|
|
162
|
+
1. Включи `GITLAB_OAUTH_AUTO_LOGIN=true`.
|
|
163
|
+
2. Повтори запрос: сервер автоматически запустит OAuth и сохранит новый token.
|
|
164
|
+
|
|
165
|
+
`OAuth flow is already running in another process for this instance`:
|
|
166
|
+
1. Заверши OAuth в первом окне.
|
|
167
|
+
2. Второе окно подождет и продолжит после появления токена.
|
|
168
|
+
3. Если lock застрял после краша, удали stale lock:
|
|
169
|
+
- `<tokenStorePath>.oauth.lock`
|
|
170
|
+
|
|
171
|
+
## 10. Advanced (необязательно)
|
|
172
|
+
|
|
173
|
+
Если нужен тонкий контроль, можно использовать:
|
|
174
|
+
1. `GITLAB_DEFAULT_PROJECT` для явного fallback проекта.
|
|
175
|
+
2. `GITLAB_AUTO_RESOLVE_PROJECT_FROM_GIT` для автоопределения проекта из `git remote`.
|
|
176
|
+
3. `GITLAB_OAUTH_TOKEN_STORE_PATH` для ручного override пути токена.
|
package/package.json
CHANGED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.GroupOAuthTokenProvider = void 0;
|
|
4
|
-
exports.resolveOAuthGroupKey = resolveOAuthGroupKey;
|
|
5
|
-
const errors_1 = require("../../shared/errors");
|
|
6
|
-
const gitlab_oauth_manager_1 = require("./gitlab-oauth-manager");
|
|
7
|
-
class GroupOAuthTokenProvider {
|
|
8
|
-
options;
|
|
9
|
-
groupProviders;
|
|
10
|
-
constructor(options) {
|
|
11
|
-
this.options = options;
|
|
12
|
-
this.groupProviders = Object.fromEntries(Object.entries(options.groupConfigs).map(([groupKey, cfg]) => {
|
|
13
|
-
return [
|
|
14
|
-
groupKey,
|
|
15
|
-
new gitlab_oauth_manager_1.GitLabOAuthManager({
|
|
16
|
-
apiUrl: options.apiUrl,
|
|
17
|
-
clientId: cfg.clientId,
|
|
18
|
-
clientSecret: cfg.clientSecret,
|
|
19
|
-
redirectUri: cfg.redirectUri,
|
|
20
|
-
scopes: cfg.scopes,
|
|
21
|
-
bootstrapAccessToken: cfg.bootstrapAccessToken,
|
|
22
|
-
tokenStorePath: cfg.tokenStorePath,
|
|
23
|
-
autoLogin: cfg.autoLogin,
|
|
24
|
-
openBrowser: cfg.openBrowser
|
|
25
|
-
})
|
|
26
|
-
];
|
|
27
|
-
}));
|
|
28
|
-
}
|
|
29
|
-
async getAccessToken(projectRef) {
|
|
30
|
-
const groupKey = resolveOAuthGroupKey(projectRef, Object.keys(this.groupProviders));
|
|
31
|
-
if (groupKey) {
|
|
32
|
-
const provider = this.groupProviders[groupKey];
|
|
33
|
-
if (provider) {
|
|
34
|
-
return provider.getAccessToken(projectRef);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
if (this.options.defaultProvider) {
|
|
38
|
-
return this.options.defaultProvider.getAccessToken(projectRef);
|
|
39
|
-
}
|
|
40
|
-
throw new errors_1.ConfigurationError('No OAuth config matched this project and default OAuth config is not available.');
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
exports.GroupOAuthTokenProvider = GroupOAuthTokenProvider;
|
|
44
|
-
function resolveOAuthGroupKey(projectRef, configuredGroups) {
|
|
45
|
-
if (typeof projectRef !== 'string' || projectRef.trim() === '') {
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
const normalized = projectRef.trim().replace(/^\/+/, '');
|
|
49
|
-
const sorted = [...configuredGroups].sort((a, b) => b.length - a.length);
|
|
50
|
-
return sorted.find((group) => normalized === group || normalized.startsWith(`${group}/`));
|
|
51
|
-
}
|