gitlab-mcp-agent-server 0.2.3 → 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 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 = 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 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);
@@ -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.end('Authorization failed. You can close this tab.');
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.end('Invalid callback. You can close this tab.');
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('<html><body><h3>Authorization completed. You can close this tab.</h3></body></html>');
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, node_child_process_2.execSync)('command -v open', { stdio: ['ignore', 'ignore', 'ignore'] });
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, node_child_process_2.execSync)('command -v xdg-open', { stdio: ['ignore', 'ignore', 'ignore'] });
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, '&amp;').replace(/"/g, '&quot;');
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, '&amp;')
466
+ .replace(/</g, '&lt;')
467
+ .replace(/>/g, '&gt;')
468
+ .replace(/"/g, '&quot;')
469
+ .replace(/'/g, '&#39;');
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
- 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,16 @@ 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
+
153
171
  ## 10. Advanced (необязательно)
154
172
 
155
173
  Если нужен тонкий контроль, можно использовать:
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.4",
4
4
  "description": "MCP server for GitLab integration via OAuth",
5
5
  "main": "build/src/index.js",
6
6
  "bin": {