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 CHANGED
@@ -5,7 +5,10 @@ MCP server for GitLab integration (TypeScript + Node.js).
5
5
  Полный пользовательский сценарий подключения к ИИ-агенту:
6
6
  - `docs/USER_GUIDE.md`
7
7
 
8
- Основной сценарий: добавить сервер в `~/.codex/config.toml` (готовый блок есть в `docs/USER_GUIDE.md`).
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/token.json`.
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
- - автоопределение проекта из git remote текущего `cwd`.
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 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
+ }
@@ -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 group_oauth_token_provider_1.GroupOAuthTokenProvider({
23
+ ? new gitlab_oauth_manager_1.GitLabOAuthManager({
25
24
  apiUrl: config.gitlab.apiUrl,
26
- defaultProvider: new gitlab_oauth_manager_1.GitLabOAuthManager({
27
- apiUrl: config.gitlab.apiUrl,
28
- clientId: config.gitlab.oauth.clientId,
29
- clientSecret: config.gitlab.oauth.clientSecret,
30
- redirectUri: config.gitlab.oauth.redirectUri,
31
- scopes: config.gitlab.oauth.scopes,
32
- bootstrapAccessToken: config.gitlab.accessToken,
33
- tokenStorePath: config.gitlab.oauth.tokenStorePath,
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().default(DEFAULT_TOKEN_STORE_PATH),
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: env.GITLAB_OAUTH_TOKEN_STORE_PATH,
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 parseGroupOAuthConfigJson(raw, defaults) {
95
- if (!raw) {
96
- return {};
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;
@@ -1,6 +1,10 @@
1
1
  # User Guide
2
2
 
3
- Этот гайд для конечного пользователя: как подключить `gitlab-mcp-agent-server` в **Codex** и работать через OAuth с авто-рефрешем токена.
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.gitlab]
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.gitlab]
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="<APPLICATION_ID>";
56
- export GITLAB_OAUTH_CLIENT_SECRET="<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="false";
62
- export GITLAB_GROUP_OAUTH_CONFIG_JSON='{"konoha7":{"clientId":"<ID_1>","clientSecret":"<SECRET_1>","redirectUri":"http://127.0.0.1:8787/oauth/callback"},"othergroup":{"clientId":"<ID_2>","clientSecret":"<SECRET_2>","redirectUri":"http://127.0.0.1:8788/oauth/callback"}}';
64
+ export GITLAB_OAUTH_OPEN_BROWSER="true";
65
+
66
+ npx -y gitlab-mcp-agent-server
67
+ """]
63
68
 
64
- # optional fallback if auto-detect from git remote is unavailable
65
- export GITLAB_DEFAULT_PROJECT="group/repo";
66
- export GITLAB_AUTO_RESOLVE_PROJECT_FROM_GIT="true";
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
- Как выбирается OAuth-конфиг при `GITLAB_GROUP_OAUTH_CONFIG_JSON`:
75
- 1. Сервер берёт `resolved_project` (например `konoha7/subgroup/repo`).
76
- 2. Находит самый длинный совпадающий group key.
77
- 3. Использует соответствующие `clientId/clientSecret/redirectUri/tokenStorePath`.
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` токены сохраняются в `GITLAB_OAUTH_TOKEN_STORE_PATH`.
87
-
88
- Если `GITLAB_DEFAULT_PROJECT` не указан:
89
- 1. сервер пытается автоматически определить проект из `git remote origin` в `cwd`;
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. Оставь `GITLAB_OAUTH_OPEN_BROWSER=false` для headless окружений.
106
- 4. Для multi-repo режима лучше не задавать `GITLAB_DEFAULT_PROJECT`, чтобы проект брался из `git remote` текущего `cwd`.
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
- ## 7. Быстрая проверка работоспособности
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
- ## 8. Troubleshooting
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,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab-mcp-agent-server",
3
- "version": "0.2.2",
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": {
@@ -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
- }