@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.
- package/README.ko.md +9 -5
- package/README.md +15 -11
- package/README.pt-BR.md +9 -5
- package/README.zh-CN.md +9 -5
- package/build/config.js +12 -4
- package/build/deprecated-env.js +63 -0
- package/build/index.js +430 -200
- package/build/oauth-device-flow.js +80 -0
- package/build/oauth.js +135 -18
- package/build/schemas.js +1 -1
- package/build/server/remote-http-stack.js +39 -0
- package/build/tools/discover-activation.js +38 -0
- package/build/tools/exposed-tools.js +35 -0
- package/build/tools/listed-tools.js +76 -0
- package/build/tools/registry.js +232 -63
- package/build/tools/tool-descriptions.js +3 -3
- package/build/tools/tool-profile.js +66 -0
- package/build/utils/compact-tool-allowlist.js +14 -0
- package/build/utils/logger.js +34 -12
- package/package.json +3 -3
- package/build/scripts/check-skill-sync.js +0 -143
- package/build/scripts/generate-server-card.js +0 -39
- package/build/scripts/generate-tool-coverage.js +0 -58
- package/build/scripts/generate-tool-docs.js +0 -430
- package/build/scripts/tool-coverage/coverage.js +0 -341
- package/build/scripts/tool-coverage/coverage.test.js +0 -142
- package/build/test/callback-proxy-tests.js +0 -321
- package/build/test/cli/args.test.js +0 -175
- package/build/test/cli/boolean-flags.test.js +0 -106
- package/build/test/cli/compact-mcp-result.test.js +0 -93
- package/build/test/cli/credentials.test.js +0 -153
- package/build/test/cli/exposure.test.js +0 -104
- package/build/test/cli/load-config-snapshot.js +0 -5
- package/build/test/cli/output.test.js +0 -74
- package/build/test/cli/replay-command.test.js +0 -37
- package/build/test/cli/router.test.js +0 -183
- package/build/test/client-pool-test.js +0 -109
- package/build/test/clients/client.js +0 -34
- package/build/test/clients/custom-header-client.js +0 -122
- package/build/test/clients/sse-client.js +0 -95
- package/build/test/clients/stdio-client.js +0 -113
- package/build/test/clients/streamable-http-client.js +0 -95
- package/build/test/config-allowed-groups.test.js +0 -97
- package/build/test/create-commit-actions.test.js +0 -102
- package/build/test/draft-note-schema.test.js +0 -44
- package/build/test/dynamic-api-url-allowlist.test.js +0 -121
- package/build/test/dynamic-api-url-test.js +0 -304
- package/build/test/dynamic-routing-tests.js +0 -495
- package/build/test/get-merge-request-discussion-schema.test.js +0 -30
- package/build/test/gitlab-api-tool-errors.test.js +0 -112
- package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
- package/build/test/gitlab-diff-schema.test.js +0 -36
- package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
- package/build/test/gitlab-tag-schema.test.js +0 -51
- package/build/test/group-milestone-schema.test.js +0 -45
- package/build/test/mcp-oauth-tests.js +0 -905
- package/build/test/mcp-server-name.test.js +0 -87
- package/build/test/merge-merge-request-schema.test.js +0 -75
- package/build/test/multi-server-test.js +0 -190
- package/build/test/no-proxy-integration-test.js +0 -183
- package/build/test/no-proxy-test.js +0 -149
- package/build/test/nullable-gitlab-response-fields.test.js +0 -37
- package/build/test/nullish-tool-arguments-schema.test.js +0 -118
- package/build/test/oauth-device-flow-tests.js +0 -450
- package/build/test/oauth-download-url.test.js +0 -157
- package/build/test/oauth-startup.test.js +0 -204
- package/build/test/oauth-tests.js +0 -428
- package/build/test/path-segment-encoding.test.js +0 -98
- package/build/test/remote-auth-simple-test.js +0 -277
- package/build/test/remote-auth-tests.js +0 -314
- package/build/test/response-masking.test.js +0 -445
- package/build/test/schema-tests.js +0 -1679
- package/build/test/server/metrics.test.js +0 -47
- package/build/test/server/request-helpers.test.js +0 -48
- package/build/test/server/version.test.js +0 -13
- package/build/test/sse-auth-guard.test.js +0 -96
- package/build/test/sse-session-limits.test.js +0 -273
- package/build/test/stateless/callback-proxy.test.js +0 -510
- package/build/test/stateless/client-id.test.js +0 -177
- package/build/test/stateless/codec.test.js +0 -328
- package/build/test/stateless/config-ttl.test.js +0 -149
- package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
- package/build/test/stateless/session-id-integration.test.js +0 -675
- package/build/test/stateless/session-id.test.js +0 -157
- package/build/test/streamable-http-concurrent-session.test.js +0 -171
- package/build/test/streamable-http-dns-rebinding.test.js +0 -185
- package/build/test/streamable-http-sse-stream.test.js +0 -350
- package/build/test/streamable-http-static-token-auth.test.js +0 -157
- package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
- package/build/test/test-all-transport-server.js +0 -255
- package/build/test/test-auth-retry.js +0 -195
- package/build/test/test-blank-filters.js +0 -156
- package/build/test/test-ci-catalog.js +0 -177
- package/build/test/test-ci-lint.js +0 -191
- package/build/test/test-ci-variables.js +0 -309
- package/build/test/test-create-repository.js +0 -120
- package/build/test/test-dependency-proxy.js +0 -195
- package/build/test/test-deployment-tools.js +0 -463
- package/build/test/test-download-attachment.js +0 -190
- package/build/test/test-dynamic-project-scope.js +0 -524
- package/build/test/test-empty-toolsets.js +0 -535
- package/build/test/test-get-file-blame.js +0 -145
- package/build/test/test-get-file-contents.js +0 -128
- package/build/test/test-geteffectiveprojectid.js +0 -804
- package/build/test/test-health-check.js +0 -115
- package/build/test/test-issue-description-patch.js +0 -256
- package/build/test/test-job-artifacts.js +0 -275
- package/build/test/test-json-schema.js +0 -166
- package/build/test/test-list-group-members.js +0 -150
- package/build/test/test-list-group-merge-requests.js +0 -165
- package/build/test/test-list-issues.js +0 -170
- package/build/test/test-list-merge-requests.js +0 -201
- package/build/test/test-list-milestones.js +0 -145
- package/build/test/test-list-project-members.js +0 -132
- package/build/test/test-merge-request-approval-state-tools.js +0 -213
- package/build/test/test-merge-request-approvals.js +0 -187
- package/build/test/test-merge-request-pipelines.js +0 -106
- package/build/test/test-mr-diffs-filter.js +0 -132
- package/build/test/test-mr-file-diffs.js +0 -251
- package/build/test/test-oauth-proxy-rate-limit.js +0 -133
- package/build/test/test-orbit.js +0 -155
- package/build/test/test-permission-mode.js +0 -424
- package/build/test/test-pipeline-schedules.js +0 -484
- package/build/test/test-protected-branches.js +0 -174
- package/build/test/test-remote-downloads.js +0 -510
- package/build/test/test-search-code.js +0 -275
- package/build/test/test-snippets.js +0 -735
- package/build/test/test-tags.js +0 -220
- package/build/test/test-todos.js +0 -195
- package/build/test/test-token-optimizations.js +0 -691
- package/build/test/test-toolset-filtering.js +0 -499
- package/build/test/test-update-issue-slim.js +0 -141
- package/build/test/test-update-project.js +0 -112
- package/build/test/test-upload-markdown.js +0 -206
- package/build/test/test-vulnerabilities.js +0 -399
- package/build/test/test-webhooks.js +0 -218
- package/build/test/tool-description-quality.test.js +0 -50
- package/build/test/utils/bulk-publish-options.test.js +0 -32
- package/build/test/utils/download-token.test.js +0 -35
- package/build/test/utils/forwarded-public-base-url.test.js +0 -46
- package/build/test/utils/gitlab-instance-version.test.js +0 -32
- package/build/test/utils/gitlab-version-gate.test.js +0 -74
- package/build/test/utils/graphql-query.test.js +0 -168
- package/build/test/utils/helpers-path.test.js +0 -104
- package/build/test/utils/jmespath-tool-result.test.js +0 -91
- package/build/test/utils/masking.test.js +0 -384
- package/build/test/utils/merge-request-position.test.js +0 -57
- package/build/test/utils/mock-gitlab-server.js +0 -614
- package/build/test/utils/proxy-client-ip.test.js +0 -28
- package/build/test/utils/redact-sensitive.test.js +0 -32
- package/build/test/utils/safe-redirect-fetch.test.js +0 -463
- package/build/test/utils/server-launcher.js +0 -231
- package/build/test/utils/tool-args.test.js +0 -210
- package/build/test/utils/url.test.js +0 -141
- package/build/test/utils/version-check.test.js +0 -239
- package/build/test/utils/wiki-title.test.js +0 -21
- package/build/test-note.js +0 -54
- 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 {
|
|
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
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
584
|
+
if (isAuthInvalidError(error)) {
|
|
565
585
|
logger.error({ err: error }, "Refresh token invalid, starting new OAuth flow...");
|
|
566
|
-
|
|
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.
|
|
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:
|
|
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,
|
|
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
|
+
}
|