@zereight/mcp-gitlab 2.1.70 → 2.2.1

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