@zereight/mcp-gitlab 2.1.70 → 2.2.0

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.
Files changed (157) hide show
  1. package/README.ko.md +3 -1
  2. package/README.md +6 -2
  3. package/README.pt-BR.md +1 -1
  4. package/README.zh-CN.md +3 -1
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +369 -184
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/package.json +3 -3
  20. package/build/scripts/check-skill-sync.js +0 -143
  21. package/build/scripts/generate-server-card.js +0 -39
  22. package/build/scripts/generate-tool-coverage.js +0 -58
  23. package/build/scripts/generate-tool-docs.js +0 -430
  24. package/build/scripts/tool-coverage/coverage.js +0 -341
  25. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  26. package/build/test/callback-proxy-tests.js +0 -321
  27. package/build/test/cli/args.test.js +0 -175
  28. package/build/test/cli/boolean-flags.test.js +0 -106
  29. package/build/test/cli/compact-mcp-result.test.js +0 -93
  30. package/build/test/cli/credentials.test.js +0 -153
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-api-tool-errors.test.js +0 -112
  50. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  51. package/build/test/gitlab-diff-schema.test.js +0 -36
  52. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  53. package/build/test/gitlab-tag-schema.test.js +0 -51
  54. package/build/test/group-milestone-schema.test.js +0 -45
  55. package/build/test/mcp-oauth-tests.js +0 -905
  56. package/build/test/mcp-server-name.test.js +0 -87
  57. package/build/test/merge-merge-request-schema.test.js +0 -75
  58. package/build/test/multi-server-test.js +0 -190
  59. package/build/test/no-proxy-integration-test.js +0 -183
  60. package/build/test/no-proxy-test.js +0 -149
  61. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  62. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  63. package/build/test/oauth-device-flow-tests.js +0 -450
  64. package/build/test/oauth-download-url.test.js +0 -157
  65. package/build/test/oauth-startup.test.js +0 -204
  66. package/build/test/oauth-tests.js +0 -428
  67. package/build/test/path-segment-encoding.test.js +0 -98
  68. package/build/test/remote-auth-simple-test.js +0 -277
  69. package/build/test/remote-auth-tests.js +0 -314
  70. package/build/test/response-masking.test.js +0 -445
  71. package/build/test/schema-tests.js +0 -1679
  72. package/build/test/server/metrics.test.js +0 -47
  73. package/build/test/server/request-helpers.test.js +0 -48
  74. package/build/test/server/version.test.js +0 -13
  75. package/build/test/sse-auth-guard.test.js +0 -96
  76. package/build/test/sse-session-limits.test.js +0 -273
  77. package/build/test/stateless/callback-proxy.test.js +0 -510
  78. package/build/test/stateless/client-id.test.js +0 -177
  79. package/build/test/stateless/codec.test.js +0 -328
  80. package/build/test/stateless/config-ttl.test.js +0 -149
  81. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  82. package/build/test/stateless/session-id-integration.test.js +0 -675
  83. package/build/test/stateless/session-id.test.js +0 -157
  84. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  85. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  86. package/build/test/streamable-http-sse-stream.test.js +0 -350
  87. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  88. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  89. package/build/test/test-all-transport-server.js +0 -255
  90. package/build/test/test-auth-retry.js +0 -195
  91. package/build/test/test-blank-filters.js +0 -156
  92. package/build/test/test-ci-catalog.js +0 -177
  93. package/build/test/test-ci-lint.js +0 -191
  94. package/build/test/test-ci-variables.js +0 -309
  95. package/build/test/test-create-repository.js +0 -120
  96. package/build/test/test-dependency-proxy.js +0 -195
  97. package/build/test/test-deployment-tools.js +0 -463
  98. package/build/test/test-download-attachment.js +0 -190
  99. package/build/test/test-dynamic-project-scope.js +0 -524
  100. package/build/test/test-empty-toolsets.js +0 -535
  101. package/build/test/test-get-file-blame.js +0 -145
  102. package/build/test/test-get-file-contents.js +0 -128
  103. package/build/test/test-geteffectiveprojectid.js +0 -804
  104. package/build/test/test-health-check.js +0 -115
  105. package/build/test/test-issue-description-patch.js +0 -256
  106. package/build/test/test-job-artifacts.js +0 -275
  107. package/build/test/test-json-schema.js +0 -166
  108. package/build/test/test-list-group-members.js +0 -150
  109. package/build/test/test-list-group-merge-requests.js +0 -165
  110. package/build/test/test-list-issues.js +0 -170
  111. package/build/test/test-list-merge-requests.js +0 -201
  112. package/build/test/test-list-milestones.js +0 -145
  113. package/build/test/test-list-project-members.js +0 -132
  114. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  115. package/build/test/test-merge-request-approvals.js +0 -187
  116. package/build/test/test-merge-request-pipelines.js +0 -106
  117. package/build/test/test-mr-diffs-filter.js +0 -132
  118. package/build/test/test-mr-file-diffs.js +0 -251
  119. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  120. package/build/test/test-orbit.js +0 -155
  121. package/build/test/test-permission-mode.js +0 -424
  122. package/build/test/test-pipeline-schedules.js +0 -484
  123. package/build/test/test-protected-branches.js +0 -174
  124. package/build/test/test-remote-downloads.js +0 -510
  125. package/build/test/test-search-code.js +0 -275
  126. package/build/test/test-snippets.js +0 -735
  127. package/build/test/test-tags.js +0 -220
  128. package/build/test/test-todos.js +0 -195
  129. package/build/test/test-token-optimizations.js +0 -691
  130. package/build/test/test-toolset-filtering.js +0 -499
  131. package/build/test/test-update-issue-slim.js +0 -141
  132. package/build/test/test-update-project.js +0 -112
  133. package/build/test/test-upload-markdown.js +0 -206
  134. package/build/test/test-vulnerabilities.js +0 -399
  135. package/build/test/test-webhooks.js +0 -218
  136. package/build/test/tool-description-quality.test.js +0 -50
  137. package/build/test/utils/bulk-publish-options.test.js +0 -32
  138. package/build/test/utils/download-token.test.js +0 -35
  139. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  140. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  141. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  142. package/build/test/utils/graphql-query.test.js +0 -168
  143. package/build/test/utils/helpers-path.test.js +0 -104
  144. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  145. package/build/test/utils/masking.test.js +0 -384
  146. package/build/test/utils/merge-request-position.test.js +0 -57
  147. package/build/test/utils/mock-gitlab-server.js +0 -614
  148. package/build/test/utils/proxy-client-ip.test.js +0 -28
  149. package/build/test/utils/redact-sensitive.test.js +0 -32
  150. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  151. package/build/test/utils/server-launcher.js +0 -231
  152. package/build/test/utils/tool-args.test.js +0 -210
  153. package/build/test/utils/url.test.js +0 -141
  154. package/build/test/utils/version-check.test.js +0 -239
  155. package/build/test/utils/wiki-title.test.js +0 -21
  156. package/build/test-note.js +0 -54
  157. package/build/test-resolve-issue-note.js +0 -127
@@ -19,7 +19,84 @@ const TokenResponseSchema = z.object({
19
19
  refresh_token: z.string().min(1).optional(),
20
20
  expires_in: z.number().positive().optional(),
21
21
  token_type: z.string().min(1).optional(),
22
+ scope: z.string().nullish(),
22
23
  });
24
+ /**
25
+ * Extra scopes that may accompany a matching grant without forcing a new login.
26
+ * Each one is read-only in GitLab's OAuth scope table. Write-capable scopes
27
+ * (`api`, `write_repository`, `sudo`, runner/admin scopes) and anything not
28
+ * listed here are unapproved and must be authorized again.
29
+ */
30
+ const APPROVED_EXTRA_READ_SCOPES = new Set([
31
+ "read_api",
32
+ "read_user",
33
+ "read_repository",
34
+ "read_registry",
35
+ "read_virtual_registry",
36
+ "read_observability",
37
+ "openid",
38
+ "profile",
39
+ "email",
40
+ ]);
41
+ /**
42
+ * Scopes GitLab actually returned.
43
+ * A missing or blank `scope` field is not an observation. Keep a grant this
44
+ * process already recorded; otherwise leave the grant unknown instead of
45
+ * copying the scopes we requested.
46
+ */
47
+ export function grantedOAuthScopes(scopeField, recordedScopes) {
48
+ if (typeof scopeField === "string") {
49
+ const scopes = scopeField.split(/\s+/).filter(scope => scope.length > 0);
50
+ if (scopes.length > 0) {
51
+ return scopes;
52
+ }
53
+ }
54
+ if (recordedScopes !== undefined && recordedScopes.length > 0) {
55
+ return [...recordedScopes];
56
+ }
57
+ return undefined;
58
+ }
59
+ /**
60
+ * True when a stored grant cannot be reused for the scopes this process requires.
61
+ * A missing or empty scope list is a pre-upgrade file. Those tokens keep working:
62
+ * a missing field is not a known mismatch, and forcing a browser login hangs
63
+ * headless and device-flow users. The next refresh records the scopes GitLab returns.
64
+ * Extra scopes stay valid only when they are on the approved read-only list, so a
65
+ * GitLab response that adds `read_user` does not force a login loop.
66
+ */
67
+ export function oauthTokenNeedsReauthorization(storedScopes, expectedScopes) {
68
+ if (storedScopes === undefined || storedScopes.length === 0) {
69
+ return false;
70
+ }
71
+ const stored = new Set(storedScopes);
72
+ const expected = new Set(expectedScopes);
73
+ for (const scope of expected) {
74
+ if (!stored.has(scope)) {
75
+ return true;
76
+ }
77
+ }
78
+ for (const scope of stored) {
79
+ if (!expected.has(scope) && !APPROVED_EXTRA_READ_SCOPES.has(scope)) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+ export function formatOAuthScopes(scopes) {
86
+ if (scopes === undefined || scopes.length === 0) {
87
+ return "missing";
88
+ }
89
+ return scopes.map(scope => scope.replace(/[\r\n]/g, "")).join(" ");
90
+ }
91
+ export function oauthScopeMismatchError(grantedScopes, expectedScopes) {
92
+ return new Error(`OAuth authorization granted scopes (${formatOAuthScopes(grantedScopes)}), ` +
93
+ `which do not match required scopes (${formatOAuthScopes(expectedScopes)}).`);
94
+ }
95
+ function assertGrantedScopesMatchRequest(grantedScopes, expectedScopes) {
96
+ if (oauthTokenNeedsReauthorization(grantedScopes, expectedScopes)) {
97
+ throw oauthScopeMismatchError(grantedScopes, expectedScopes);
98
+ }
99
+ }
23
100
  function delayAsync(ms) {
24
101
  return new Promise(resolve => {
25
102
  setTimeout(resolve, ms);
@@ -124,12 +201,15 @@ export async function runDeviceAuthorizationGrantAsync(input) {
124
201
  if (!token.success) {
125
202
  throw new Error("Token endpoint returned an invalid response.");
126
203
  }
204
+ const scopes = grantedOAuthScopes(token.data.scope);
205
+ assertGrantedScopesMatchRequest(scopes, input.scopes);
127
206
  return {
128
207
  access_token: token.data.access_token,
129
208
  refresh_token: token.data.refresh_token,
130
209
  expires_in: token.data.expires_in,
131
210
  created_at: now(),
132
211
  token_type: token.data.token_type ?? "Bearer",
212
+ ...(scopes === undefined ? {} : { scopes }),
133
213
  };
134
214
  }
135
215
  const tokenError = DeviceTokenErrorSchema.safeParse(tokenBody);
package/build/oauth.js CHANGED
@@ -10,7 +10,9 @@ import { promisify } from "util";
10
10
  import open from "open";
11
11
  import pkceChallenge from "pkce-challenge";
12
12
  import { createLogger } from "./utils/logger.js";
13
- import { runDeviceAuthorizationGrantAsync } from "./oauth-device-flow.js";
13
+ import { GITLAB_PERMISSION_MODE } from "./config.js";
14
+ import { formatOAuthScopes, grantedOAuthScopes, oauthScopeMismatchError, oauthTokenNeedsReauthorization, runDeviceAuthorizationGrantAsync, } from "./oauth-device-flow.js";
15
+ export { oauthTokenNeedsReauthorization };
14
16
  const logger = createLogger("gitlab-mcp-oauth");
15
17
  const execFileAsync = promisify(execFile);
16
18
  function escapeHtml(str) {
@@ -162,19 +164,27 @@ export class GitLabOAuth {
162
164
  const errorText = await response.text();
163
165
  throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
164
166
  }
165
- const data = (await response.json());
166
- return {
167
+ const body = await response.json();
168
+ const data = readOAuthTokenBody(body);
169
+ const tokenData = {
167
170
  access_token: data.access_token,
168
171
  refresh_token: data.refresh_token,
169
172
  expires_in: data.expires_in,
170
173
  created_at: Date.now(),
171
174
  token_type: data.token_type,
175
+ scopes: data.scopes,
172
176
  };
177
+ // Throw before the callback writes the file. A stored token with no scopes
178
+ // stays valid; only a grant that records a mismatch is rejected.
179
+ if (oauthTokenNeedsReauthorization(tokenData.scopes, this.config.scopes)) {
180
+ throw oauthScopeMismatchError(tokenData.scopes, this.config.scopes);
181
+ }
182
+ return tokenData;
173
183
  }
174
184
  /**
175
185
  * Refresh the access token using the refresh token
176
186
  */
177
- async refreshAccessToken(refreshToken) {
187
+ async refreshAccessToken(refreshToken, recordedScopes) {
178
188
  const tokenUrl = `${this.config.gitlabUrl}/oauth/token`;
179
189
  const params = new URLSearchParams({
180
190
  client_id: this.config.clientId,
@@ -201,13 +211,15 @@ export class GitLabOAuth {
201
211
  authInvalid,
202
212
  });
203
213
  }
204
- const data = (await response.json());
214
+ const body = await response.json();
215
+ const data = readOAuthTokenBody(body, recordedScopes);
205
216
  return {
206
217
  access_token: data.access_token,
207
218
  refresh_token: data.refresh_token || refreshToken,
208
219
  expires_in: data.expires_in,
209
220
  created_at: Date.now(),
210
221
  token_type: data.token_type,
222
+ scopes: data.scopes,
211
223
  };
212
224
  }
213
225
  /**
@@ -233,7 +245,8 @@ export class GitLabOAuth {
233
245
  return null;
234
246
  }
235
247
  const data = fs.readFileSync(this.tokenStoragePath, "utf8");
236
- return JSON.parse(data);
248
+ const parsed = JSON.parse(data);
249
+ return parseStoredToken(parsed);
237
250
  }
238
251
  catch (error) {
239
252
  logger.error({ err: error }, "Failed to load token");
@@ -548,36 +561,62 @@ export class GitLabOAuth {
548
561
  return (await this.getScriptToken()).access_token;
549
562
  }
550
563
  let tokenData = this.loadToken();
551
- // If no token or expired (or forced), start OAuth flow or refresh
564
+ const expectedScopes = this.config.scopes;
565
+ // Refresh cannot change GitLab scopes. A stored grant that records a different
566
+ // scope has to be authorized again, or a readonly switch keeps an `api` token.
567
+ // Files written before scope tracking omit `scopes` and stay usable.
552
568
  if (!tokenData) {
553
569
  logger.info("No stored token found. Starting OAuth flow...");
554
- tokenData = await this.startOAuthFlow();
570
+ tokenData = await this.authorizeMatchingScopes();
571
+ }
572
+ else if (oauthTokenNeedsReauthorization(tokenData.scopes, expectedScopes)) {
573
+ logger.warn(`Stored OAuth token scopes (${formatOAuthScopes(tokenData.scopes)}) do not match ` +
574
+ `${formatOAuthScopes(expectedScopes)} for the current permission mode. Starting a new authorization flow...`);
575
+ tokenData = await this.authorizeMatchingScopes();
555
576
  }
556
577
  else if (force || this.isTokenExpired(tokenData)) {
557
578
  logger.info(force && !this.isTokenExpired(tokenData) ? "Force-refreshing OAuth token..." : "Token expired. Refreshing...");
558
579
  if (tokenData.refresh_token) {
559
580
  try {
560
- tokenData = await this.refreshAccessToken(tokenData.refresh_token);
561
- this.saveToken(tokenData);
581
+ tokenData = await this.refreshAccessToken(tokenData.refresh_token, tokenData.scopes);
562
582
  }
563
583
  catch (error) {
564
- if (error.authInvalid === true) {
584
+ if (isAuthInvalidError(error)) {
565
585
  logger.error({ err: error }, "Refresh token invalid, starting new OAuth flow...");
566
- tokenData = await this.startOAuthFlow();
567
- }
568
- else {
569
- logger.error({ err: error }, "Token refresh failed (transient/network), not opening browser.");
570
- throw error;
586
+ return (await this.authorizeMatchingScopes()).access_token;
571
587
  }
588
+ logger.error({ err: error }, "Token refresh failed (transient/network), not opening browser.");
589
+ throw error;
590
+ }
591
+ // GitLab revokes the previous refresh token when this call succeeds.
592
+ // Persist the rotated grant before a browser retry so a failed or busy
593
+ // callback does not leave only the revoked token on disk. Callers still
594
+ // do not receive a mismatched access token: a recorded mismatch is
595
+ // rejected before refresh on the next start, and a later callback overwrites this file.
596
+ this.saveToken(tokenData);
597
+ if (oauthTokenNeedsReauthorization(tokenData.scopes, expectedScopes)) {
598
+ logger.warn(`Refreshed OAuth token scopes (${formatOAuthScopes(tokenData.scopes)}) do not match ` +
599
+ `${formatOAuthScopes(expectedScopes)}. Starting a new authorization flow...`);
600
+ tokenData = await this.authorizeMatchingScopes();
572
601
  }
573
602
  }
574
603
  else {
575
604
  logger.info("No refresh token available. Starting new OAuth flow...");
576
- tokenData = await this.startOAuthFlow();
605
+ tokenData = await this.authorizeMatchingScopes();
577
606
  }
578
607
  }
579
608
  return tokenData.access_token;
580
609
  }
610
+ /**
611
+ * Browser or device-callback login, rejected when GitLab grants a different scope.
612
+ */
613
+ async authorizeMatchingScopes() {
614
+ const tokenData = await this.startOAuthFlow();
615
+ if (oauthTokenNeedsReauthorization(tokenData.scopes, this.config.scopes)) {
616
+ throw oauthScopeMismatchError(tokenData.scopes, this.config.scopes);
617
+ }
618
+ return tokenData;
619
+ }
581
620
  /**
582
621
  * Clear stored token
583
622
  */
@@ -603,6 +642,9 @@ export class GitLabOAuth {
603
642
  if (!tokenData) {
604
643
  return false;
605
644
  }
645
+ if (oauthTokenNeedsReauthorization(tokenData.scopes, this.config.scopes)) {
646
+ return false;
647
+ }
606
648
  return !this.isTokenExpired(tokenData);
607
649
  }
608
650
  /**
@@ -625,6 +667,81 @@ export class GitLabOAuth {
625
667
  return tokenData;
626
668
  }
627
669
  }
670
+ /** Read-only permission mode only needs the `read_api` scope; every other mode needs `api`. */
671
+ export function getOAuthScopes(permissionMode) {
672
+ return [permissionMode === "readonly" ? "read_api" : "api"];
673
+ }
674
+ function isAuthInvalidError(error) {
675
+ if (typeof error !== "object" || error === null) {
676
+ return false;
677
+ }
678
+ return Reflect.get(error, "authInvalid") === true;
679
+ }
680
+ function readOAuthTokenBody(value, recordedScopes) {
681
+ if (typeof value !== "object" || value === null) {
682
+ throw new Error("Token endpoint returned an invalid response.");
683
+ }
684
+ const accessToken = Reflect.get(value, "access_token");
685
+ const refreshToken = Reflect.get(value, "refresh_token");
686
+ const expiresIn = Reflect.get(value, "expires_in");
687
+ const tokenType = Reflect.get(value, "token_type");
688
+ const scope = Reflect.get(value, "scope");
689
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
690
+ throw new Error("Token endpoint returned an invalid response.");
691
+ }
692
+ return {
693
+ access_token: accessToken,
694
+ refresh_token: typeof refreshToken === "string" ? refreshToken : undefined,
695
+ expires_in: typeof expiresIn === "number" ? expiresIn : undefined,
696
+ token_type: typeof tokenType === "string" && tokenType.length > 0 ? tokenType : "Bearer",
697
+ scopes: grantedOAuthScopes(scope, recordedScopes),
698
+ };
699
+ }
700
+ function parseStoredToken(value) {
701
+ if (typeof value !== "object" || value === null) {
702
+ return null;
703
+ }
704
+ const accessToken = Reflect.get(value, "access_token");
705
+ const createdAt = Reflect.get(value, "created_at");
706
+ const tokenType = Reflect.get(value, "token_type");
707
+ const refreshToken = Reflect.get(value, "refresh_token");
708
+ const expiresIn = Reflect.get(value, "expires_in");
709
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
710
+ return null;
711
+ }
712
+ if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) {
713
+ return null;
714
+ }
715
+ const token = {
716
+ access_token: accessToken,
717
+ created_at: createdAt,
718
+ token_type: typeof tokenType === "string" && tokenType.length > 0 ? tokenType : "Bearer",
719
+ };
720
+ if (typeof refreshToken === "string") {
721
+ token.refresh_token = refreshToken;
722
+ }
723
+ if (typeof expiresIn === "number") {
724
+ token.expires_in = expiresIn;
725
+ }
726
+ const scopes = parseStoredScopes(Reflect.get(value, "scopes"));
727
+ if (scopes !== undefined) {
728
+ token.scopes = scopes;
729
+ }
730
+ return token;
731
+ }
732
+ function parseStoredScopes(value) {
733
+ if (!Array.isArray(value)) {
734
+ return undefined;
735
+ }
736
+ const scopes = [];
737
+ for (const item of value) {
738
+ if (typeof item !== "string" || item.trim() === "") {
739
+ return undefined;
740
+ }
741
+ scopes.push(item.trim());
742
+ }
743
+ return scopes.length > 0 ? scopes : undefined;
744
+ }
628
745
  /**
629
746
  * Construct a GitLabOAuth client from environment configuration.
630
747
  * Does NOT perform any network access — no token is acquired here.
@@ -644,7 +761,7 @@ export function createGitLabOAuthClient(gitlabUrl = "https://gitlab.com") {
644
761
  clientSecret,
645
762
  redirectUri,
646
763
  gitlabUrl,
647
- scopes: [process.env.GITLAB_READ_ONLY_MODE === "true" ? "read_api" : "api"],
764
+ scopes: getOAuthScopes(GITLAB_PERMISSION_MODE),
648
765
  tokenStoragePath,
649
766
  tokenScript,
650
767
  });
package/build/schemas.js CHANGED
@@ -2513,7 +2513,7 @@ export const ListGroupMergeRequestsSchema = ListMergeRequestsSchema.omit({
2513
2513
  export const GetIssueSchema = z.object({
2514
2514
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2515
2515
  issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
2516
- full_response: flexibleBooleanOptional.describe("If true, return the complete issue object including the full milestone description. Default returns a slim milestone (id, iid, title, state, web_url) to reduce token usage."),
2516
+ full_response: flexibleBooleanOptional.describe("If true, include the complete milestone object, including its description, on the returned issue. Default returns the issue with a slim milestone (id, iid, title, state, web_url) to reduce token usage."),
2517
2517
  });
2518
2518
  export const UpdateIssueSchema = z.object({
2519
2519
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -0,0 +1,39 @@
1
+ function isExpressFactory(value) {
2
+ return typeof value === "function" && "json" in value;
3
+ }
4
+ function loadExpressFactory(moduleNamespace) {
5
+ if (isExpressFactory(moduleNamespace)) {
6
+ return moduleNamespace;
7
+ }
8
+ if (typeof moduleNamespace === "object" &&
9
+ moduleNamespace !== null &&
10
+ "default" in moduleNamespace &&
11
+ isExpressFactory(moduleNamespace.default)) {
12
+ return moduleNamespace.default;
13
+ }
14
+ throw new Error("express did not export an application factory");
15
+ }
16
+ const remoteHttpStackCache = {};
17
+ export async function loadRemoteHttpStack() {
18
+ if (remoteHttpStackCache.current) {
19
+ return remoteHttpStackCache.current;
20
+ }
21
+ const [expressModule, rateLimitModule, sseModule, streamableModule, authRouterModule, bearerModule,] = await Promise.all([
22
+ import("express"),
23
+ import("express-rate-limit"),
24
+ import("@modelcontextprotocol/sdk/server/sse.js"),
25
+ import("@modelcontextprotocol/sdk/server/streamableHttp.js"),
26
+ import("@modelcontextprotocol/sdk/server/auth/router.js"),
27
+ import("@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"),
28
+ ]);
29
+ remoteHttpStackCache.current = {
30
+ express: loadExpressFactory(expressModule),
31
+ rateLimit: rateLimitModule.default,
32
+ ipKeyGenerator: rateLimitModule.ipKeyGenerator,
33
+ SSEServerTransport: sseModule.SSEServerTransport,
34
+ StreamableHTTPServerTransport: streamableModule.StreamableHTTPServerTransport,
35
+ mcpAuthRouter: authRouterModule.mcpAuthRouter,
36
+ requireBearerAuth: bearerModule.requireBearerAuth,
37
+ };
38
+ return remoteHttpStackCache.current;
39
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Same skips `discover_tools` uses when it adds tools: permission mode, regex
3
+ * denial, hidden policy, and the slim profile. Those tools never join the list.
4
+ */
5
+ export function isSkippedByDiscoverActivation(input) {
6
+ if (!input.allowedByPermissionMode) {
7
+ return true;
8
+ }
9
+ if (input.deniedByRegex) {
10
+ return true;
11
+ }
12
+ if (input.hidden) {
13
+ return true;
14
+ }
15
+ return input.excludedBySlimProfile;
16
+ }
17
+ /**
18
+ * Hint for a direct call to a tool `tools/list` omitted.
19
+ * `discover_tools` is mentioned only when activation would add the tool.
20
+ */
21
+ export function buildUnavailableToolMessage(input) {
22
+ const base = `Tool "${input.toolName}" is not available on this server`;
23
+ const toolset = input.toolset;
24
+ if (toolset === undefined || input.deniedByRegex) {
25
+ return base;
26
+ }
27
+ if (input.excludedBySlimProfile) {
28
+ return `${base}. ${slimExclusionGuidance(input.toolName, toolset, input.enabledByCurrentToolsets)}`;
29
+ }
30
+ return `${base}. It belongs to the "${toolset}" toolset, which is not enabled: call discover_tools with category "${toolset}" or add "${toolset}" to GITLAB_TOOLSETS.`;
31
+ }
32
+ function slimExclusionGuidance(toolName, toolset, enabledByCurrentToolsets) {
33
+ const viaToolsets = `add "${toolset}" to GITLAB_TOOLSETS (setting GITLAB_TOOLSETS disables the slim profile)`;
34
+ if (enabledByCurrentToolsets) {
35
+ return `The slim tool profile excludes it. Add "${toolName}" to GITLAB_TOOLS, set GITLAB_TOOL_PROFILE=full, or ${viaToolsets}.`;
36
+ }
37
+ return `The slim tool profile excludes it. Add "${toolName}" to GITLAB_TOOLS, or ${viaToolsets}.`;
38
+ }
@@ -0,0 +1,35 @@
1
+ import { isExcludedBySlimProfile } from "./tool-profile.js";
2
+ export function selectExposedTools(input) {
3
+ const toolsAfterToolsets = input.tools.filter(tool => input.isInEnabledToolset(tool.name));
4
+ const toolsetToolNames = new Set(toolsAfterToolsets.map(tool => tool.name));
5
+ const toolsAfterIndividual = [
6
+ ...toolsAfterToolsets,
7
+ ...input.tools.filter(tool => input.individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name)),
8
+ ];
9
+ const afterIndividualNames = new Set(toolsAfterIndividual.map(tool => tool.name));
10
+ const toolsAfterLegacy = [
11
+ ...toolsAfterIndividual,
12
+ ...input.tools.filter(tool => input.featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name)),
13
+ ];
14
+ const toolsAfterPermission = toolsAfterLegacy.filter(tool => input.isAllowedByPermissionMode(tool.name));
15
+ const deniedToolsRegex = input.deniedToolsRegex;
16
+ const toolsAfterRegex = deniedToolsRegex
17
+ ? toolsAfterPermission.filter(tool => !deniedToolsRegex.test(tool.name))
18
+ : [...toolsAfterPermission];
19
+ const filteredToolNames = new Set(toolsAfterRegex.map(tool => tool.name));
20
+ const discoverTool = input.tools.find(tool => tool.name === "discover_tools");
21
+ if (discoverTool && !filteredToolNames.has("discover_tools")) {
22
+ const passesPermissionMode = input.isAllowedByPermissionMode("discover_tools");
23
+ const passesRegex = deniedToolsRegex ? !deniedToolsRegex.test("discover_tools") : true;
24
+ if (passesPermissionMode && passesRegex) {
25
+ toolsAfterRegex.push(discoverTool);
26
+ }
27
+ }
28
+ const withoutHidden = input.hiddenToolNames.size > 0
29
+ ? toolsAfterRegex.filter(tool => !input.hiddenToolNames.has(tool.name))
30
+ : toolsAfterRegex;
31
+ if (!input.applySlimProfile) {
32
+ return withoutHidden;
33
+ }
34
+ return withoutHidden.filter(tool => !isExcludedBySlimProfile(tool.name, true, input.individuallyEnabledTools));
35
+ }
@@ -0,0 +1,76 @@
1
+ const CONFIRMED_ARGUMENT = "_confirmed";
2
+ const CONFIRMED_ARGUMENT_DESCRIPTION = "Set to true to confirm execution of this approval-required tool.";
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function cloneSchema(schema) {
7
+ const clone = { ...schema };
8
+ if ("$schema" in clone) {
9
+ delete clone.$schema;
10
+ }
11
+ return clone;
12
+ }
13
+ function withConfirmedArgument(schema, toolName, context) {
14
+ if (!context.approveToolNames.has(toolName) || !isRecord(schema.properties)) {
15
+ return schema;
16
+ }
17
+ return {
18
+ ...schema,
19
+ properties: {
20
+ ...schema.properties,
21
+ [CONFIRMED_ARGUMENT]: {
22
+ type: "boolean",
23
+ description: CONFIRMED_ARGUMENT_DESCRIPTION,
24
+ },
25
+ },
26
+ };
27
+ }
28
+ function withJmespathArgument(schema, context) {
29
+ const properties = isRecord(schema.properties) ? { ...schema.properties } : {};
30
+ if (context.jmespathArgument in properties) {
31
+ return schema;
32
+ }
33
+ properties[context.jmespathArgument] = {
34
+ type: "string",
35
+ description: context.jmespathArgumentDescription,
36
+ };
37
+ return { ...schema, properties };
38
+ }
39
+ function decorateInputSchema(schema, toolName, context) {
40
+ if (!isRecord(schema)) {
41
+ return undefined;
42
+ }
43
+ return withJmespathArgument(withConfirmedArgument(cloneSchema(schema), toolName, context), context);
44
+ }
45
+ export function decorateToolForList(tool, context) {
46
+ const inputSchema = decorateInputSchema(tool.inputSchema, tool.name, context);
47
+ const annotations = { openWorldHint: true };
48
+ if (context.readOnlyToolNames.has(tool.name)) {
49
+ annotations.readOnlyHint = true;
50
+ }
51
+ if (context.destructiveToolNames.has(tool.name)) {
52
+ annotations.destructiveHint = true;
53
+ }
54
+ if (context.approveToolNames.has(tool.name)) {
55
+ annotations.confirmationHint = true;
56
+ }
57
+ if (inputSchema) {
58
+ return { ...tool, inputSchema, annotations };
59
+ }
60
+ return { ...tool, annotations };
61
+ }
62
+ export function decorateToolsForList(tools, context) {
63
+ return tools.map(tool => decorateToolForList(tool, context));
64
+ }
65
+ export function createListedToolsCache(decorate) {
66
+ const cache = { revision: -1 };
67
+ return function getListedTools(tools, revision) {
68
+ if (cache.tools !== undefined && cache.revision === revision) {
69
+ return cache.tools;
70
+ }
71
+ const decorated = decorate(tools);
72
+ cache.tools = decorated;
73
+ cache.revision = revision;
74
+ return decorated;
75
+ };
76
+ }