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 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 node_child_process_2 = require("node:child_process");
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
- const refreshed = await this.refreshToken(stored.refreshToken);
26
- this.tokenStore.write(refreshed);
27
- return refreshed.accessToken;
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.loginInteractively();
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 Error(`Failed to refresh OAuth token: ${response.status} ${body}`);
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.end('Authorization failed. You can close this tab.');
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.end('Invalid callback. You can close this tab.');
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('<html><body><h3>Authorization completed. You can close this tab.</h3></body></html>');
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, node_child_process_2.execSync)('command -v open', { stdio: ['ignore', 'ignore', 'ignore'] });
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, node_child_process_2.execSync)('command -v xdg-open', { stdio: ['ignore', 'ignore', 'ignore'] });
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, '&amp;').replace(/"/g, '&quot;');
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, '&amp;')
479
+ .replace(/</g, '&lt;')
480
+ .replace(/>/g, '&gt;')
481
+ .replace(/"/g, '&quot;')
482
+ .replace(/'/g, '&#39;');
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
- const raw = (0, node_fs_1.readFileSync)(this.filePath, 'utf8');
16
- const parsed = JSON.parse(raw);
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 response = await fetch(`${this.options.apiUrl}${path}`, {
97
- ...init,
98
- headers: {
99
- Authorization: `Bearer ${token}`,
100
- 'Content-Type': 'application/json',
101
- ...(init?.headers ?? {})
102
- }
103
- });
104
- if (!response.ok) {
105
- const body = await safeReadBody(response);
106
- throw new Error(`GitLab API ${response.status}: ${body}`);
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 (await response.json());
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;
@@ -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
  Если нужен тонкий контроль, можно использовать:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab-mcp-agent-server",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "MCP server for GitLab integration via OAuth",
5
5
  "main": "build/src/index.js",
6
6
  "bin": {