@zereight/mcp-gitlab 2.1.69 → 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 (155) hide show
  1. package/README.ko.md +10 -4
  2. package/README.md +238 -233
  3. package/README.pt-BR.md +4 -4
  4. package/README.zh-CN.md +6 -4
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +456 -237
  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/build/utils/url.js +15 -0
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  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-artifact-entry-schema.test.js +0 -44
  50. package/build/test/gitlab-diff-schema.test.js +0 -36
  51. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  52. package/build/test/gitlab-tag-schema.test.js +0 -51
  53. package/build/test/group-milestone-schema.test.js +0 -45
  54. package/build/test/mcp-oauth-tests.js +0 -905
  55. package/build/test/mcp-server-name.test.js +0 -87
  56. package/build/test/merge-merge-request-schema.test.js +0 -75
  57. package/build/test/multi-server-test.js +0 -190
  58. package/build/test/no-proxy-integration-test.js +0 -183
  59. package/build/test/no-proxy-test.js +0 -149
  60. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  61. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  62. package/build/test/oauth-device-flow-tests.js +0 -450
  63. package/build/test/oauth-startup.test.js +0 -204
  64. package/build/test/oauth-tests.js +0 -428
  65. package/build/test/path-segment-encoding.test.js +0 -98
  66. package/build/test/remote-auth-simple-test.js +0 -277
  67. package/build/test/remote-auth-tests.js +0 -314
  68. package/build/test/response-masking.test.js +0 -445
  69. package/build/test/schema-tests.js +0 -1679
  70. package/build/test/server/metrics.test.js +0 -47
  71. package/build/test/server/request-helpers.test.js +0 -48
  72. package/build/test/server/version.test.js +0 -13
  73. package/build/test/sse-auth-guard.test.js +0 -96
  74. package/build/test/sse-session-limits.test.js +0 -273
  75. package/build/test/stateless/callback-proxy.test.js +0 -510
  76. package/build/test/stateless/client-id.test.js +0 -177
  77. package/build/test/stateless/codec.test.js +0 -328
  78. package/build/test/stateless/config-ttl.test.js +0 -149
  79. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  80. package/build/test/stateless/session-id-integration.test.js +0 -675
  81. package/build/test/stateless/session-id.test.js +0 -157
  82. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  83. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  84. package/build/test/streamable-http-sse-stream.test.js +0 -350
  85. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  86. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  87. package/build/test/test-all-transport-server.js +0 -255
  88. package/build/test/test-auth-retry.js +0 -195
  89. package/build/test/test-blank-filters.js +0 -156
  90. package/build/test/test-ci-catalog.js +0 -177
  91. package/build/test/test-ci-lint.js +0 -191
  92. package/build/test/test-ci-variables.js +0 -309
  93. package/build/test/test-create-repository.js +0 -120
  94. package/build/test/test-dependency-proxy.js +0 -195
  95. package/build/test/test-deployment-tools.js +0 -463
  96. package/build/test/test-download-attachment.js +0 -190
  97. package/build/test/test-dynamic-project-scope.js +0 -524
  98. package/build/test/test-empty-toolsets.js +0 -535
  99. package/build/test/test-get-file-blame.js +0 -145
  100. package/build/test/test-get-file-contents.js +0 -128
  101. package/build/test/test-geteffectiveprojectid.js +0 -812
  102. package/build/test/test-health-check.js +0 -115
  103. package/build/test/test-issue-description-patch.js +0 -256
  104. package/build/test/test-job-artifacts.js +0 -275
  105. package/build/test/test-json-schema.js +0 -166
  106. package/build/test/test-list-group-members.js +0 -150
  107. package/build/test/test-list-group-merge-requests.js +0 -165
  108. package/build/test/test-list-issues.js +0 -170
  109. package/build/test/test-list-merge-requests.js +0 -201
  110. package/build/test/test-list-milestones.js +0 -145
  111. package/build/test/test-list-project-members.js +0 -132
  112. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  113. package/build/test/test-merge-request-approvals.js +0 -187
  114. package/build/test/test-merge-request-pipelines.js +0 -106
  115. package/build/test/test-mr-diffs-filter.js +0 -132
  116. package/build/test/test-mr-file-diffs.js +0 -251
  117. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  118. package/build/test/test-orbit.js +0 -155
  119. package/build/test/test-permission-mode.js +0 -424
  120. package/build/test/test-pipeline-schedules.js +0 -482
  121. package/build/test/test-protected-branches.js +0 -174
  122. package/build/test/test-remote-downloads.js +0 -510
  123. package/build/test/test-search-code.js +0 -275
  124. package/build/test/test-snippets.js +0 -735
  125. package/build/test/test-tags.js +0 -220
  126. package/build/test/test-todos.js +0 -195
  127. package/build/test/test-token-optimizations.js +0 -691
  128. package/build/test/test-toolset-filtering.js +0 -499
  129. package/build/test/test-update-issue-slim.js +0 -141
  130. package/build/test/test-update-project.js +0 -112
  131. package/build/test/test-upload-markdown.js +0 -206
  132. package/build/test/test-vulnerabilities.js +0 -399
  133. package/build/test/test-webhooks.js +0 -218
  134. package/build/test/tool-description-quality.test.js +0 -50
  135. package/build/test/utils/bulk-publish-options.test.js +0 -32
  136. package/build/test/utils/download-token.test.js +0 -35
  137. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  138. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  139. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  140. package/build/test/utils/graphql-query.test.js +0 -168
  141. package/build/test/utils/helpers-path.test.js +0 -104
  142. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  143. package/build/test/utils/masking.test.js +0 -384
  144. package/build/test/utils/merge-request-position.test.js +0 -57
  145. package/build/test/utils/mock-gitlab-server.js +0 -614
  146. package/build/test/utils/proxy-client-ip.test.js +0 -28
  147. package/build/test/utils/redact-sensitive.test.js +0 -32
  148. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  149. package/build/test/utils/server-launcher.js +0 -231
  150. package/build/test/utils/tool-args.test.js +0 -210
  151. package/build/test/utils/url.test.js +0 -128
  152. package/build/test/utils/version-check.test.js +0 -239
  153. package/build/test/utils/wiki-title.test.js +0 -21
  154. package/build/test-note.js +0 -54
  155. 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
+ }