gitlab-mcp-agent-server 0.2.3 → 0.2.5
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 +1 -0
- package/build/src/infrastructure/auth/gitlab-oauth-manager.js +246 -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/shared/errors.js +19 -1
- package/docs/USER_GUIDE.md +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ MCP server for GitLab integration (TypeScript + Node.js).
|
|
|
17
17
|
Остальное работает по дефолту:
|
|
18
18
|
- OAuth auto-login при отсутствии токена.
|
|
19
19
|
- instance-aware token store в `~/.config/gitlab-mcp/<gitlab-host>/token.json`.
|
|
20
|
+
- OAuth-flow lock на instance (`<tokenStorePath>.oauth.lock`) для исключения гонки callback-порта.
|
|
20
21
|
- auto-refresh access token.
|
|
21
22
|
- поддержка явного `project` в tool input и fallback-резолва проекта.
|
|
22
23
|
|
|
@@ -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 = 15 * 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. Complete OAuth in the first window or remove stale lock: ${lockFilePath}`);
|
|
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);
|
|
@@ -72,6 +124,7 @@ class GitLabOAuthManager {
|
|
|
72
124
|
authorizeUrl.searchParams.set('state', state);
|
|
73
125
|
const localEntryUrl = `${redirect.protocol}//${redirect.host}/`;
|
|
74
126
|
const code = await new Promise((resolve, reject) => {
|
|
127
|
+
let settled = false;
|
|
75
128
|
const server = (0, node_http_1.createServer)((req, res) => {
|
|
76
129
|
if (!req.url) {
|
|
77
130
|
return;
|
|
@@ -93,25 +146,50 @@ class GitLabOAuthManager {
|
|
|
93
146
|
const authCode = url.searchParams.get('code');
|
|
94
147
|
if (error) {
|
|
95
148
|
res.statusCode = 400;
|
|
96
|
-
res.
|
|
149
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
150
|
+
res.end(renderOAuthResultPage({
|
|
151
|
+
status: 'error',
|
|
152
|
+
title: 'Authorization Failed',
|
|
153
|
+
message: `GitLab returned error: ${escapeHtml(error)}.`,
|
|
154
|
+
hint: 'Return to your AI agent and retry the request.',
|
|
155
|
+
actionHref: localEntryUrl,
|
|
156
|
+
actionLabel: 'Start OAuth Again'
|
|
157
|
+
}));
|
|
97
158
|
server.close();
|
|
159
|
+
settled = true;
|
|
98
160
|
reject(new Error(`OAuth authorization failed: ${error}`));
|
|
99
161
|
return;
|
|
100
162
|
}
|
|
101
163
|
if (!authCode || responseState !== state) {
|
|
102
164
|
res.statusCode = 400;
|
|
103
|
-
res.
|
|
165
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
166
|
+
res.end(renderOAuthResultPage({
|
|
167
|
+
status: 'error',
|
|
168
|
+
title: 'Invalid OAuth Callback',
|
|
169
|
+
message: 'Authorization code is missing or state verification failed.',
|
|
170
|
+
hint: 'Return to your AI agent and retry the request.',
|
|
171
|
+
actionHref: localEntryUrl,
|
|
172
|
+
actionLabel: 'Start OAuth Again'
|
|
173
|
+
}));
|
|
104
174
|
server.close();
|
|
175
|
+
settled = true;
|
|
105
176
|
reject(new Error('Invalid OAuth callback: code/state mismatch.'));
|
|
106
177
|
return;
|
|
107
178
|
}
|
|
108
179
|
res.statusCode = 200;
|
|
109
180
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
110
|
-
res.end(
|
|
181
|
+
res.end(renderOAuthResultPage({
|
|
182
|
+
status: 'success',
|
|
183
|
+
title: 'Authorization Completed',
|
|
184
|
+
message: 'GitLab token is saved. You can return to your AI agent.',
|
|
185
|
+
hint: 'This tab can be closed now.'
|
|
186
|
+
}));
|
|
111
187
|
server.close();
|
|
188
|
+
settled = true;
|
|
112
189
|
resolve(authCode);
|
|
113
190
|
});
|
|
114
191
|
server.on('error', (error) => {
|
|
192
|
+
settled = true;
|
|
115
193
|
reject(new Error(`OAuth callback server failed on ${redirect.hostname}:${resolvePort(redirect)}: ${error.message}`));
|
|
116
194
|
});
|
|
117
195
|
server.listen(resolvePort(redirect), redirect.hostname, () => {
|
|
@@ -123,6 +201,14 @@ class GitLabOAuthManager {
|
|
|
123
201
|
console.error(authorizeUrl.toString());
|
|
124
202
|
}
|
|
125
203
|
});
|
|
204
|
+
const timeoutMs = 20_000;
|
|
205
|
+
setTimeout(() => {
|
|
206
|
+
if (settled) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
server.close();
|
|
210
|
+
reject(new Error(`OAuth was not completed in time. Open ${localEntryUrl} to continue, or use ${authorizeUrl.toString()}`));
|
|
211
|
+
}, timeoutMs);
|
|
126
212
|
});
|
|
127
213
|
const tokenResponse = await fetch(`${this.oauthBaseUrl}/oauth/token`, {
|
|
128
214
|
method: 'POST',
|
|
@@ -206,19 +292,114 @@ function resolvePort(url) {
|
|
|
206
292
|
function hasOpenCommand(platform) {
|
|
207
293
|
try {
|
|
208
294
|
if (platform === 'darwin') {
|
|
209
|
-
(0,
|
|
295
|
+
(0, node_child_process_1.execSync)('command -v open', { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
210
296
|
return true;
|
|
211
297
|
}
|
|
212
298
|
if (platform === 'win32') {
|
|
213
299
|
return true;
|
|
214
300
|
}
|
|
215
|
-
(0,
|
|
301
|
+
(0, node_child_process_1.execSync)('command -v xdg-open', { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
216
302
|
return true;
|
|
217
303
|
}
|
|
218
304
|
catch {
|
|
219
305
|
return false;
|
|
220
306
|
}
|
|
221
307
|
}
|
|
308
|
+
class OAuthRefreshError extends Error {
|
|
309
|
+
status;
|
|
310
|
+
body;
|
|
311
|
+
constructor(status, body) {
|
|
312
|
+
super(`Failed to refresh OAuth token: ${status} ${body}`);
|
|
313
|
+
this.status = status;
|
|
314
|
+
this.body = body;
|
|
315
|
+
this.name = 'OAuthRefreshError';
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function shouldReloginOnRefreshFailure(error) {
|
|
319
|
+
if (!(error instanceof OAuthRefreshError)) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (error.status === 400 || error.status === 401) {
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
function acquireOauthLock(lockFilePath) {
|
|
328
|
+
let fd;
|
|
329
|
+
try {
|
|
330
|
+
fd = (0, node_fs_1.openSync)(lockFilePath, 'wx');
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
const e = error;
|
|
334
|
+
if (e.code !== 'EEXIST') {
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
if (isStaleLock(lockFilePath)) {
|
|
338
|
+
try {
|
|
339
|
+
(0, node_fs_1.unlinkSync)(lockFilePath);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// ignore race
|
|
343
|
+
}
|
|
344
|
+
return acquireOauthLock(lockFilePath);
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
acquired: false,
|
|
348
|
+
release: () => { }
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const payload = JSON.stringify({
|
|
352
|
+
pid: process.pid,
|
|
353
|
+
startedAt: new Date().toISOString()
|
|
354
|
+
}, null, 2);
|
|
355
|
+
(0, node_fs_1.writeFileSync)(fd, payload, 'utf8');
|
|
356
|
+
(0, node_fs_1.closeSync)(fd);
|
|
357
|
+
let released = false;
|
|
358
|
+
return {
|
|
359
|
+
acquired: true,
|
|
360
|
+
release: () => {
|
|
361
|
+
if (released) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
released = true;
|
|
365
|
+
try {
|
|
366
|
+
(0, node_fs_1.unlinkSync)(lockFilePath);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// ignore
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function isStaleLock(lockFilePath) {
|
|
375
|
+
try {
|
|
376
|
+
const raw = (0, node_fs_1.readFileSync)(lockFilePath, 'utf8');
|
|
377
|
+
const parsed = JSON.parse(raw);
|
|
378
|
+
if (!parsed.pid || !Number.isInteger(parsed.pid)) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return !isProcessAlive(parsed.pid);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function isProcessAlive(pid) {
|
|
388
|
+
try {
|
|
389
|
+
process.kill(pid, 0);
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
const e = error;
|
|
394
|
+
if (e.code === 'EPERM') {
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function sleep(ms) {
|
|
401
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
402
|
+
}
|
|
222
403
|
function renderOAuthEntryPage(authorizeUrl) {
|
|
223
404
|
const escapedUrl = authorizeUrl.replace(/&/g, '&').replace(/"/g, '"');
|
|
224
405
|
return `<!doctype html>
|
|
@@ -251,3 +432,55 @@ function renderOAuthEntryPage(authorizeUrl) {
|
|
|
251
432
|
</body>
|
|
252
433
|
</html>`;
|
|
253
434
|
}
|
|
435
|
+
function renderOAuthResultPage(input) {
|
|
436
|
+
const title = escapeHtml(input.title);
|
|
437
|
+
const message = escapeHtml(input.message);
|
|
438
|
+
const hint = input.hint ? escapeHtml(input.hint) : '';
|
|
439
|
+
const isSuccess = input.status === 'success';
|
|
440
|
+
const borderColor = isSuccess ? '#14532d' : '#7f1d1d';
|
|
441
|
+
const badgeColor = isSuccess ? '#22c55e' : '#ef4444';
|
|
442
|
+
const bgTop = isSuccess ? '#052e16' : '#450a0a';
|
|
443
|
+
const button = input.actionHref && input.actionLabel
|
|
444
|
+
? `<a class="btn" href="${escapeHtmlAttr(input.actionHref)}">${escapeHtml(input.actionLabel)}</a>`
|
|
445
|
+
: '';
|
|
446
|
+
return `<!doctype html>
|
|
447
|
+
<html lang="en">
|
|
448
|
+
<head>
|
|
449
|
+
<meta charset="utf-8" />
|
|
450
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
451
|
+
<title>${title}</title>
|
|
452
|
+
<style>
|
|
453
|
+
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; }
|
|
454
|
+
.wrap { max-width: 680px; margin: 40px auto; }
|
|
455
|
+
.card { background: #121a2b; border: 1px solid ${borderColor}; border-radius: 14px; padding: 28px; box-shadow: 0 10px 30px rgba(0, 0, 0, .35); }
|
|
456
|
+
.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; }
|
|
457
|
+
h1 { margin: 12px 0 8px; font-size: 24px; }
|
|
458
|
+
p { margin: 0 0 12px; color: #cbd5e1; line-height: 1.55; }
|
|
459
|
+
.hint { font-size: 13px; color: #94a3b8; }
|
|
460
|
+
.btn { display: inline-block; margin-top: 10px; background: #2563eb; color: #fff; text-decoration: none; padding: 10px 14px; border-radius: 8px; font-weight: 600; }
|
|
461
|
+
</style>
|
|
462
|
+
</head>
|
|
463
|
+
<body>
|
|
464
|
+
<div class="wrap">
|
|
465
|
+
<div class="card">
|
|
466
|
+
<span class="badge">${isSuccess ? 'Success' : 'Error'}</span>
|
|
467
|
+
<h1>${title}</h1>
|
|
468
|
+
<p>${message}</p>
|
|
469
|
+
${hint ? `<p class="hint">${hint}</p>` : ''}
|
|
470
|
+
${button}
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
</body>
|
|
474
|
+
</html>`;
|
|
475
|
+
}
|
|
476
|
+
function escapeHtml(value) {
|
|
477
|
+
return value
|
|
478
|
+
.replace(/&/g, '&')
|
|
479
|
+
.replace(/</g, '<')
|
|
480
|
+
.replace(/>/g, '>')
|
|
481
|
+
.replace(/"/g, '"')
|
|
482
|
+
.replace(/'/g, ''');
|
|
483
|
+
}
|
|
484
|
+
function escapeHtmlAttr(value) {
|
|
485
|
+
return escapeHtml(value);
|
|
486
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -90,6 +90,10 @@ npx -y gitlab-mcp-agent-server
|
|
|
90
90
|
- `~/.config/gitlab-mcp/gitlab.com/token.json`
|
|
91
91
|
- `~/.config/gitlab-mcp/gitlab.work.local/token.json`
|
|
92
92
|
|
|
93
|
+
Для OAuth используется lock-файл на instance:
|
|
94
|
+
- `<tokenStorePath>.oauth.lock`
|
|
95
|
+
- если OAuth уже идёт в другом процессе, текущий процесс ждёт появления токена.
|
|
96
|
+
|
|
93
97
|
## 4. Что происходит при первом запуске
|
|
94
98
|
|
|
95
99
|
1. Агент вызывает любой GitLab tool (например `gitlab_list_labels`).
|
|
@@ -98,6 +102,9 @@ npx -y gitlab-mcp-agent-server
|
|
|
98
102
|
4. Локальный URL `http://127.0.0.1:8787/` автоматически редиректит на GitLab OAuth.
|
|
99
103
|
5. Если браузер не может быть открыт, сервер печатает URL авторизации в лог.
|
|
100
104
|
6. После подтверждения в GitLab и callback на `http://127.0.0.1:8787/oauth/callback` токены сохраняются в token store для конкретного instance.
|
|
105
|
+
7. В браузере показывается feedback-страница:
|
|
106
|
+
- `Success`: токен сохранен, можно вернуться в ИИ-агент;
|
|
107
|
+
- `Error`: показана причина и кнопка повторного запуска OAuth.
|
|
101
108
|
|
|
102
109
|
## 5. Автообновление токена
|
|
103
110
|
|
|
@@ -105,6 +112,7 @@ npx -y gitlab-mcp-agent-server
|
|
|
105
112
|
1. Проверяет срок действия `access_token` перед API-вызовами.
|
|
106
113
|
2. Выполняет refresh по `refresh_token`, если токен истекает.
|
|
107
114
|
3. Перезаписывает token store файл новым токеном.
|
|
115
|
+
4. Если refresh token отозван/протух, очищает token store и запускает OAuth заново (при `GITLAB_OAUTH_AUTO_LOGIN=true`).
|
|
108
116
|
|
|
109
117
|
## 6. Рекомендованные настройки для production
|
|
110
118
|
|
|
@@ -150,6 +158,21 @@ npx -y gitlab-mcp-agent-server
|
|
|
150
158
|
2. Убедись, что `client_id`/`client_secret` от того же приложения.
|
|
151
159
|
3. Удали token store файл и пройди OAuth заново.
|
|
152
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
|
+
`gitlab_list_issues`/другой tool уходит в timeout после удаления token:
|
|
172
|
+
1. Проверь stale lock и удали его:
|
|
173
|
+
- `rm -f ~/.config/gitlab-mcp/<gitlab-host>/token.json.oauth.lock`
|
|
174
|
+
2. Повтори запрос и заверши OAuth в браузере в течение окна авторизации.
|
|
175
|
+
|
|
153
176
|
## 10. Advanced (необязательно)
|
|
154
177
|
|
|
155
178
|
Если нужен тонкий контроль, можно использовать:
|