@zereight/mcp-gitlab 2.1.67 → 2.1.69
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 +8 -4
- package/README.md +14 -5
- package/README.pt-BR.md +589 -0
- package/README.zh-CN.md +8 -4
- package/build/cli/args.js +298 -0
- package/build/cli/compact-mcp-result.js +87 -0
- package/build/cli/curated.js +243 -0
- package/build/cli/exposure.js +72 -0
- package/build/cli/help.js +108 -0
- package/build/cli/output.js +65 -0
- package/build/cli/replay-command.js +53 -0
- package/build/cli/router.js +158 -0
- package/build/cli/tables.js +117 -0
- package/build/cli-boolean-flags.js +38 -0
- package/build/cli-command.js +14 -4
- package/build/config.js +30 -7
- package/build/index.js +329 -44
- package/build/oauth.js +93 -10
- package/build/schemas.js +187 -0
- package/build/scripts/generate-tool-docs.js +7 -1
- package/build/scripts/tool-coverage/coverage.js +2 -0
- package/build/test/cli/args.test.js +175 -0
- package/build/test/cli/boolean-flags.test.js +106 -0
- package/build/test/cli/compact-mcp-result.test.js +93 -0
- package/build/test/cli/exposure.test.js +104 -0
- package/build/test/cli/load-config-snapshot.js +5 -0
- package/build/test/cli/output.test.js +74 -0
- package/build/test/cli/replay-command.test.js +37 -0
- package/build/test/cli/router.test.js +183 -0
- package/build/test/oauth-device-flow-tests.js +10 -0
- package/build/test/oauth-startup.test.js +204 -0
- package/build/test/test-dynamic-project-scope.js +162 -0
- package/build/test/test-geteffectiveprojectid.js +4 -2
- package/build/test/test-permission-mode.js +87 -0
- package/build/test/test-snippets.js +735 -0
- package/build/test/test-toolset-filtering.js +3 -0
- package/build/tools/denied-regex.js +38 -0
- package/build/tools/registry.js +42 -1
- package/package.json +1 -1
package/build/oauth.js
CHANGED
|
@@ -19,6 +19,28 @@ function escapeHtml(str) {
|
|
|
19
19
|
}
|
|
20
20
|
// Track pending auth requests across multiple MCP instances
|
|
21
21
|
const pendingAuthRequests = new Map();
|
|
22
|
+
const OAUTH_REFRESH_TIMEOUT_MS = 10000;
|
|
23
|
+
/**
|
|
24
|
+
* Decide whether a failed token-refresh response means the refresh token is
|
|
25
|
+
* genuinely dead (interactive re-login would fix it) versus a transient or
|
|
26
|
+
* configuration problem (where opening a browser is wrong).
|
|
27
|
+
*
|
|
28
|
+
* Returns true ONLY for a 400/401 whose JSON body has error "invalid_grant"
|
|
29
|
+
* or "invalid_token". Everything else — config errors (invalid_client,
|
|
30
|
+
* invalid_request), 5xx, 429, and non-JSON bodies — returns false.
|
|
31
|
+
*/
|
|
32
|
+
export function isAuthInvalidTokenResponse(status, bodyText) {
|
|
33
|
+
if (status !== 400 && status !== 401) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const body = JSON.parse(bodyText);
|
|
38
|
+
return body.error === "invalid_grant" || body.error === "invalid_token";
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
22
44
|
/**
|
|
23
45
|
* Check if a port is already in use
|
|
24
46
|
*/
|
|
@@ -170,10 +192,14 @@ export class GitLabOAuth {
|
|
|
170
192
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
171
193
|
},
|
|
172
194
|
body: params.toString(),
|
|
195
|
+
signal: AbortSignal.timeout(OAUTH_REFRESH_TIMEOUT_MS),
|
|
173
196
|
});
|
|
174
197
|
if (!response.ok) {
|
|
175
|
-
const
|
|
176
|
-
|
|
198
|
+
const bodyText = await response.text();
|
|
199
|
+
const authInvalid = isAuthInvalidTokenResponse(response.status, bodyText);
|
|
200
|
+
throw Object.assign(new Error(`Token refresh failed: ${response.status} ${bodyText}`), {
|
|
201
|
+
authInvalid,
|
|
202
|
+
});
|
|
177
203
|
}
|
|
178
204
|
const data = (await response.json());
|
|
179
205
|
return {
|
|
@@ -230,7 +256,13 @@ export class GitLabOAuth {
|
|
|
230
256
|
* Uses a shared server if port is already in use
|
|
231
257
|
*/
|
|
232
258
|
async startOAuthFlow() {
|
|
233
|
-
|
|
259
|
+
let callbackPort = 8888;
|
|
260
|
+
try {
|
|
261
|
+
callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888", 10);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Fallback to default port on malformed redirectUri
|
|
265
|
+
}
|
|
234
266
|
const requestId = crypto.randomUUID();
|
|
235
267
|
// Check if port is already in use
|
|
236
268
|
const portInUse = await isPortInUse(callbackPort);
|
|
@@ -529,8 +561,14 @@ export class GitLabOAuth {
|
|
|
529
561
|
this.saveToken(tokenData);
|
|
530
562
|
}
|
|
531
563
|
catch (error) {
|
|
532
|
-
|
|
533
|
-
|
|
564
|
+
if (error.authInvalid === true) {
|
|
565
|
+
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;
|
|
571
|
+
}
|
|
534
572
|
}
|
|
535
573
|
}
|
|
536
574
|
else {
|
|
@@ -588,11 +626,11 @@ export class GitLabOAuth {
|
|
|
588
626
|
}
|
|
589
627
|
}
|
|
590
628
|
/**
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
629
|
+
* Construct a GitLabOAuth client from environment configuration.
|
|
630
|
+
* Does NOT perform any network access — no token is acquired here.
|
|
631
|
+
* Throws if neither GITLAB_OAUTH_CLIENT_ID nor GITLAB_OAUTH_TOKEN_SCRIPT is set.
|
|
594
632
|
*/
|
|
595
|
-
export
|
|
633
|
+
export function createGitLabOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
596
634
|
const clientId = process.env.GITLAB_OAUTH_CLIENT_ID;
|
|
597
635
|
const clientSecret = process.env.GITLAB_OAUTH_CLIENT_SECRET;
|
|
598
636
|
const redirectUri = process.env.GITLAB_OAUTH_REDIRECT_URI || "http://127.0.0.1:8888/callback";
|
|
@@ -601,7 +639,7 @@ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
|
601
639
|
if (!clientId && !tokenScript) {
|
|
602
640
|
throw new Error("GITLAB_OAUTH_CLIENT_ID or GITLAB_OAUTH_TOKEN_SCRIPT environment variable is required for OAuth authentication");
|
|
603
641
|
}
|
|
604
|
-
|
|
642
|
+
return new GitLabOAuth({
|
|
605
643
|
clientId: clientId || "external-token-script",
|
|
606
644
|
clientSecret,
|
|
607
645
|
redirectUri,
|
|
@@ -610,6 +648,51 @@ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
|
610
648
|
tokenStoragePath,
|
|
611
649
|
tokenScript,
|
|
612
650
|
});
|
|
651
|
+
}
|
|
652
|
+
const inFlightTokenPromises = new WeakMap();
|
|
653
|
+
/**
|
|
654
|
+
* Ensure the caller holds a usable OAuth access token, delivering it via onToken.
|
|
655
|
+
* Returns cached token without network when still valid.
|
|
656
|
+
*
|
|
657
|
+
* NOTE: the fast path requires BOTH a held token and a valid stored token.
|
|
658
|
+
* Returning early on hasValidToken() alone would skip onToken after lazy
|
|
659
|
+
* startup (caller holds nothing yet) and leave requests unauthenticated.
|
|
660
|
+
* Concurrent callers coalesce on an in-flight promise to avoid racing
|
|
661
|
+
* duplicate refresh requests (which would fail on token rotation).
|
|
662
|
+
*/
|
|
663
|
+
export async function ensureOAuthToken(client, currentToken, onToken) {
|
|
664
|
+
if (!client)
|
|
665
|
+
return;
|
|
666
|
+
// Fast path: token already resolved for this process and still valid.
|
|
667
|
+
if (currentToken && client.hasValidToken())
|
|
668
|
+
return;
|
|
669
|
+
let promise = inFlightTokenPromises.get(client);
|
|
670
|
+
if (!promise) {
|
|
671
|
+
const needsRefresh = !client.hasValidToken();
|
|
672
|
+
if (needsRefresh) {
|
|
673
|
+
logger.info("OAuth token expired or missing, refreshing...");
|
|
674
|
+
}
|
|
675
|
+
promise = (async () => {
|
|
676
|
+
const token = await client.getAccessToken();
|
|
677
|
+
if (needsRefresh) {
|
|
678
|
+
logger.info("OAuth token refreshed successfully");
|
|
679
|
+
}
|
|
680
|
+
return token;
|
|
681
|
+
})().finally(() => {
|
|
682
|
+
inFlightTokenPromises.delete(client);
|
|
683
|
+
});
|
|
684
|
+
inFlightTokenPromises.set(client, promise);
|
|
685
|
+
}
|
|
686
|
+
const token = await promise;
|
|
687
|
+
onToken(token);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Create and initialize a GitLabOAuth client.
|
|
691
|
+
* Performs initial authentication (triggers browser flow if needed).
|
|
692
|
+
* Returns the client instance and the initial access token.
|
|
693
|
+
*/
|
|
694
|
+
export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
695
|
+
const oauth = createGitLabOAuthClient(gitlabUrl);
|
|
613
696
|
// Single call: triggers browser flow if needed, or reads cached token
|
|
614
697
|
const accessToken = await oauth.getAccessToken();
|
|
615
698
|
return { client: oauth, accessToken };
|
package/build/schemas.js
CHANGED
|
@@ -3847,6 +3847,193 @@ export const GitLabTagSchema = z.object({
|
|
|
3847
3847
|
protected: z.boolean(),
|
|
3848
3848
|
created_at: z.string().nullable(),
|
|
3849
3849
|
});
|
|
3850
|
+
// --- Snippet schemas ---
|
|
3851
|
+
export const ListSnippetsSchema = z
|
|
3852
|
+
.object({
|
|
3853
|
+
project_id: z
|
|
3854
|
+
.coerce.string()
|
|
3855
|
+
.optional()
|
|
3856
|
+
.describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
|
|
3857
|
+
})
|
|
3858
|
+
.merge(PaginationOptionsSchema);
|
|
3859
|
+
export const GetSnippetSchema = z.object({
|
|
3860
|
+
project_id: z
|
|
3861
|
+
.coerce.string()
|
|
3862
|
+
.optional()
|
|
3863
|
+
.describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
|
|
3864
|
+
snippet_id: z.coerce.number().describe("The snippet ID"),
|
|
3865
|
+
include_content: coerceBooleanString
|
|
3866
|
+
.optional()
|
|
3867
|
+
.default(false)
|
|
3868
|
+
.describe("Whether to fetch the raw file content (default: false)"),
|
|
3869
|
+
ref: z
|
|
3870
|
+
.string()
|
|
3871
|
+
.optional()
|
|
3872
|
+
.describe("Branch, tag, or commit to fetch content from. Inferred from the snippet's raw_url when omitted."),
|
|
3873
|
+
});
|
|
3874
|
+
export const SnippetFileSchema = z.object({
|
|
3875
|
+
file_path: z.string().describe("File path within the snippet (e.g., 'src/script.py')"),
|
|
3876
|
+
content: z.string().describe("File content"),
|
|
3877
|
+
});
|
|
3878
|
+
export const SnippetFileUpdateActionSchema = z
|
|
3879
|
+
.object({
|
|
3880
|
+
action: z
|
|
3881
|
+
.enum(["create", "update", "delete", "move"])
|
|
3882
|
+
.describe("Type of action to perform on the file"),
|
|
3883
|
+
file_path: z
|
|
3884
|
+
.string()
|
|
3885
|
+
.optional()
|
|
3886
|
+
.describe("File path of the snippet file (required for all actions). For 'move', this is the new path; for 'create'/'update'/'delete', the target path."),
|
|
3887
|
+
previous_path: z
|
|
3888
|
+
.string()
|
|
3889
|
+
.optional()
|
|
3890
|
+
.describe("Previous path of the snippet file (required for 'move')."),
|
|
3891
|
+
content: z
|
|
3892
|
+
.string()
|
|
3893
|
+
.optional()
|
|
3894
|
+
.describe("Content of the snippet file (required for 'create' and 'update')."),
|
|
3895
|
+
})
|
|
3896
|
+
.superRefine((data, ctx) => {
|
|
3897
|
+
if (data.file_path === undefined) {
|
|
3898
|
+
ctx.addIssue({
|
|
3899
|
+
code: z.ZodIssueCode.custom,
|
|
3900
|
+
message: "file_path is required for all snippet file actions",
|
|
3901
|
+
path: ["file_path"],
|
|
3902
|
+
});
|
|
3903
|
+
}
|
|
3904
|
+
if ((data.action === "create" || data.action === "update") && data.content === undefined) {
|
|
3905
|
+
ctx.addIssue({
|
|
3906
|
+
code: z.ZodIssueCode.custom,
|
|
3907
|
+
message: `content is required for action: '${data.action}'`,
|
|
3908
|
+
path: ["content"],
|
|
3909
|
+
});
|
|
3910
|
+
}
|
|
3911
|
+
if (data.action === "move" && data.previous_path === undefined) {
|
|
3912
|
+
ctx.addIssue({
|
|
3913
|
+
code: z.ZodIssueCode.custom,
|
|
3914
|
+
message: "previous_path is required for action: 'move'",
|
|
3915
|
+
path: ["previous_path"],
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3918
|
+
})
|
|
3919
|
+
.describe("Multi-file snippet update action. Mirrors GitLab's PUT /snippets/:id files[] payload.");
|
|
3920
|
+
export const CreateSnippetSchema = z
|
|
3921
|
+
.object({
|
|
3922
|
+
project_id: z
|
|
3923
|
+
.coerce.string()
|
|
3924
|
+
.optional()
|
|
3925
|
+
.describe("Project ID or URL-encoded path. Omit for a personal snippet, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
|
|
3926
|
+
title: z.string().describe("Snippet title"),
|
|
3927
|
+
file_name: z
|
|
3928
|
+
.string()
|
|
3929
|
+
.optional()
|
|
3930
|
+
.describe("File name for a single-file snippet (e.g., 'script.py'). Use together with content. Mutually exclusive with files[]."),
|
|
3931
|
+
content: z
|
|
3932
|
+
.string()
|
|
3933
|
+
.optional()
|
|
3934
|
+
.describe("File content for a single-file snippet. Use together with file_name."),
|
|
3935
|
+
files: z
|
|
3936
|
+
.array(SnippetFileSchema)
|
|
3937
|
+
.optional()
|
|
3938
|
+
.describe("Array of files for a multi-file snippet. Each item needs file_path and content. Mutually exclusive with file_name/content."),
|
|
3939
|
+
description: z.string().optional().describe("Optional snippet description"),
|
|
3940
|
+
visibility: z
|
|
3941
|
+
.enum(["private", "internal", "public"])
|
|
3942
|
+
.optional()
|
|
3943
|
+
.describe("Snippet visibility (default: private)"),
|
|
3944
|
+
})
|
|
3945
|
+
.refine(data => (data.files !== undefined && data.files.length > 0) ||
|
|
3946
|
+
(data.file_name !== undefined && data.content !== undefined), {
|
|
3947
|
+
message: "Provide either files[] (multi-file) or both file_name and content (single-file)",
|
|
3948
|
+
path: ["files"],
|
|
3949
|
+
})
|
|
3950
|
+
.refine(data => !(data.files !== undefined &&
|
|
3951
|
+
(data.file_name !== undefined || data.content !== undefined)), {
|
|
3952
|
+
message: "Cannot mix files[] with file_name/content — pick one shape",
|
|
3953
|
+
path: ["files"],
|
|
3954
|
+
});
|
|
3955
|
+
export const UpdateSnippetSchema = z
|
|
3956
|
+
.object({
|
|
3957
|
+
project_id: z
|
|
3958
|
+
.coerce.string()
|
|
3959
|
+
.optional()
|
|
3960
|
+
.describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
|
|
3961
|
+
snippet_id: z.coerce.number().describe("The snippet ID to update"),
|
|
3962
|
+
title: z.string().optional().describe("New title"),
|
|
3963
|
+
file_name: z
|
|
3964
|
+
.string()
|
|
3965
|
+
.optional()
|
|
3966
|
+
.describe("File name to update. Provide together with content to replace a single-file snippet's contents."),
|
|
3967
|
+
content: z.string().optional().describe("New file content (requires file_name)"),
|
|
3968
|
+
files: z
|
|
3969
|
+
.array(SnippetFileUpdateActionSchema)
|
|
3970
|
+
.min(1, "files[] must be non-empty when provided")
|
|
3971
|
+
.optional()
|
|
3972
|
+
.describe("Multi-file update actions. Each item has 'action' (create/update/delete/move) plus the relevant path/content fields. Use this for renames (action: 'move' with previous_path), deletions, and additions. Mutually exclusive with file_name/content."),
|
|
3973
|
+
description: z.string().optional().describe("New description"),
|
|
3974
|
+
visibility: z
|
|
3975
|
+
.enum(["private", "internal", "public"])
|
|
3976
|
+
.optional()
|
|
3977
|
+
.describe("New visibility"),
|
|
3978
|
+
})
|
|
3979
|
+
.refine(data => data.title !== undefined ||
|
|
3980
|
+
data.content !== undefined ||
|
|
3981
|
+
data.file_name !== undefined ||
|
|
3982
|
+
data.files !== undefined ||
|
|
3983
|
+
data.description !== undefined ||
|
|
3984
|
+
data.visibility !== undefined, { message: "At least one field must be provided to update" })
|
|
3985
|
+
.refine(data => !(data.files !== undefined &&
|
|
3986
|
+
(data.file_name !== undefined || data.content !== undefined)), {
|
|
3987
|
+
message: "Cannot mix files[] with file_name/content — pick one shape",
|
|
3988
|
+
path: ["files"],
|
|
3989
|
+
})
|
|
3990
|
+
.refine(data => !(data.content !== undefined && data.file_name === undefined), {
|
|
3991
|
+
message: "content requires file_name",
|
|
3992
|
+
path: ["content"],
|
|
3993
|
+
})
|
|
3994
|
+
.refine(data => !(data.file_name !== undefined && data.content === undefined), {
|
|
3995
|
+
message: "file_name requires content — renaming a file alone is not supported (the modern GitLab snippet API requires a files[] action with previous_path)",
|
|
3996
|
+
path: ["file_name"],
|
|
3997
|
+
});
|
|
3998
|
+
export const DeleteSnippetSchema = z.object({
|
|
3999
|
+
project_id: z
|
|
4000
|
+
.coerce.string()
|
|
4001
|
+
.optional()
|
|
4002
|
+
.describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
|
|
4003
|
+
snippet_id: z.coerce.number().describe("The snippet ID to delete"),
|
|
4004
|
+
});
|
|
4005
|
+
export const GitLabSnippetAuthorSchema = z
|
|
4006
|
+
.object({
|
|
4007
|
+
id: z.number().optional(),
|
|
4008
|
+
username: z.string().optional(),
|
|
4009
|
+
name: z.string().optional(),
|
|
4010
|
+
state: z.string().optional(),
|
|
4011
|
+
avatar_url: z.string().nullable().optional(),
|
|
4012
|
+
web_url: z.string().optional(),
|
|
4013
|
+
})
|
|
4014
|
+
.passthrough();
|
|
4015
|
+
export const GitLabSnippetFileSchema = z
|
|
4016
|
+
.object({
|
|
4017
|
+
path: z.string(),
|
|
4018
|
+
raw_url: z.string().optional(),
|
|
4019
|
+
})
|
|
4020
|
+
.passthrough();
|
|
4021
|
+
export const GitLabSnippetSchema = z
|
|
4022
|
+
.object({
|
|
4023
|
+
id: z.number(),
|
|
4024
|
+
title: z.string(),
|
|
4025
|
+
description: z.string().nullable().optional(),
|
|
4026
|
+
visibility: z.string().optional(),
|
|
4027
|
+
author: GitLabSnippetAuthorSchema.optional(),
|
|
4028
|
+
updated_at: z.string().optional(),
|
|
4029
|
+
created_at: z.string().optional(),
|
|
4030
|
+
web_url: z.string().optional(),
|
|
4031
|
+
raw_url: z.string().optional(),
|
|
4032
|
+
file_name: z.string().nullable().optional(),
|
|
4033
|
+
files: z.array(GitLabSnippetFileSchema).optional(),
|
|
4034
|
+
project_id: z.number().nullable().optional(),
|
|
4035
|
+
})
|
|
4036
|
+
.passthrough();
|
|
3850
4037
|
export const GitLabTagSignatureSchema = z.object({
|
|
3851
4038
|
signature_type: z.literal("X509"),
|
|
3852
4039
|
verification_status: z.string(),
|
|
@@ -24,13 +24,14 @@ const LEGACY_TOGGLE_ENV = {
|
|
|
24
24
|
function isDefaultToolset(id) {
|
|
25
25
|
return TOOLSET_DEFINITIONS.find(d => d.id === id)?.isDefault ?? false;
|
|
26
26
|
}
|
|
27
|
+
/** Returns the availability note for a toolset group, or undefined when it is on by default. */
|
|
27
28
|
function computeToggleNote(id) {
|
|
28
29
|
if (isDefaultToolset(id))
|
|
29
30
|
return undefined;
|
|
30
31
|
// Synthetic group for tools not in any TOOLSET_DEFINITIONS entry.
|
|
31
32
|
// discover_tools is always exposed; execute_graphql is opt-in via GITLAB_TOOLS.
|
|
32
33
|
if (id === "meta") {
|
|
33
|
-
return "Mixed availability. `discover_tools` is always exposed (the server re-adds it after every toolset filter). `execute_graphql` is not part of any toolset — enable it explicitly with `GITLAB_TOOLS=execute_graphql`.";
|
|
34
|
+
return "Mixed availability. `discover_tools` is always exposed (the server re-adds it after every toolset filter). `execute_graphql` is not part of any toolset — enable it explicitly with `GITLAB_TOOLS=execute_graphql`. Tools excluded from the list (toolset / `GITLAB_TOOLS` / `GITLAB_DENIED_TOOLS_REGEX`) are also rejected on direct calls; `GITLAB_TOOL_POLICY_HIDDEN` tools stay callable unless they match `GITLAB_DENIED_TOOLS_REGEX`.";
|
|
34
35
|
}
|
|
35
36
|
const legacy = LEGACY_TOGGLE_ENV[id];
|
|
36
37
|
if (legacy) {
|
|
@@ -123,6 +124,10 @@ const GROUP_META = {
|
|
|
123
124
|
title: "GitLab Orbit",
|
|
124
125
|
blurb: "Query the Orbit SDLC knowledge graph (Beta; Premium/Ultimate). Graph queries consume GitLab credits; schema, status, and tool listing are free.",
|
|
125
126
|
},
|
|
127
|
+
snippets: {
|
|
128
|
+
title: "Snippets",
|
|
129
|
+
blurb: "Snippet CRUD — list, get (with optional file content), create, update, and delete personal or project snippets.",
|
|
130
|
+
},
|
|
126
131
|
};
|
|
127
132
|
const GROUP_ORDER = [
|
|
128
133
|
"projects",
|
|
@@ -146,6 +151,7 @@ const GROUP_ORDER = [
|
|
|
146
151
|
"dependency_proxy",
|
|
147
152
|
"vulnerabilities",
|
|
148
153
|
"orbit",
|
|
154
|
+
"snippets",
|
|
149
155
|
];
|
|
150
156
|
// Authoritative classification — uses the `readOnlyTools` set from
|
|
151
157
|
// tools/registry.ts. That set is what the server itself consults to decide
|
|
@@ -50,6 +50,7 @@ export const GROUP_ORDER = [
|
|
|
50
50
|
"dependency_proxy",
|
|
51
51
|
"vulnerabilities",
|
|
52
52
|
"orbit",
|
|
53
|
+
"snippets",
|
|
53
54
|
];
|
|
54
55
|
export const GROUP_TITLE = {
|
|
55
56
|
merge_requests: "Merge Requests",
|
|
@@ -73,6 +74,7 @@ export const GROUP_TITLE = {
|
|
|
73
74
|
dependency_proxy: "Dependency Proxy",
|
|
74
75
|
vulnerabilities: "Vulnerabilities",
|
|
75
76
|
orbit: "GitLab Orbit",
|
|
77
|
+
snippets: "Snippets",
|
|
76
78
|
meta: "Meta & GraphQL",
|
|
77
79
|
};
|
|
78
80
|
export const CANARY_INVOKED = [
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { allTools } from "../../tools/registry.js";
|
|
4
|
+
import { CreateWebhookSchema, GetPipelineJobOutputSchema, ProtectBranchSchema, } from "../../schemas.js";
|
|
5
|
+
import { applyFlagAliases, CliUsageError, parseArgv, parseToolArgs } from "../../cli/args.js";
|
|
6
|
+
function argv(...args) {
|
|
7
|
+
return ["node", "index.js", ...args];
|
|
8
|
+
}
|
|
9
|
+
function schemaFor(toolName) {
|
|
10
|
+
const tool = allTools.find(entry => entry.name === toolName);
|
|
11
|
+
assert.ok(tool, `missing tool ${toolName}`);
|
|
12
|
+
return tool.inputSchema;
|
|
13
|
+
}
|
|
14
|
+
describe("When parseArgv reads a command line", () => {
|
|
15
|
+
describe("with a generic tool invocation", () => {
|
|
16
|
+
it("should keep positionals and kebab flags as snake_case tool args", () => {
|
|
17
|
+
const parsed = parseArgv(argv("tool", "list_issues", "--project-id", "123", "--state", "opened"));
|
|
18
|
+
assert.deepEqual(parsed.positionals, ["tool", "list_issues"]);
|
|
19
|
+
assert.equal(parsed.toolFlags.project_id, "123");
|
|
20
|
+
assert.equal(parsed.toolFlags.state, "opened");
|
|
21
|
+
assert.equal(parsed.output, undefined);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("with global flags mixed in", () => {
|
|
25
|
+
it("should not treat --token --output --yes as tool flags", () => {
|
|
26
|
+
const parsed = parseArgv(argv("tool", "whoami", "--token", "glpat-xxxxxxxxxxxxxxxxxxxx", "--output=json", "--yes"));
|
|
27
|
+
assert.equal(parsed.output, "json");
|
|
28
|
+
assert.equal(parsed.yes, true);
|
|
29
|
+
assert.deepEqual(parsed.toolFlags, {});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("with --args-json", () => {
|
|
33
|
+
it("should parse the object and ignore prototype keys", () => {
|
|
34
|
+
const parsed = parseArgv(argv("tool", "create_issue", "--args-json", '{"title":"bug","__proto__":{"x":1}}'));
|
|
35
|
+
assert.equal(parsed.argsJson?.title, "bug");
|
|
36
|
+
assert.equal(Object.prototype.hasOwnProperty.call(parsed.argsJson, "__proto__"), false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("with --use-oauth before a curated command", () => {
|
|
40
|
+
it("should keep mr list as positionals instead of consuming mr as the flag value", () => {
|
|
41
|
+
const parsed = parseArgv(argv("--use-oauth", "mr", "list"));
|
|
42
|
+
assert.deepEqual(parsed.positionals, ["mr", "list"]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("with --read-only false before a curated command", () => {
|
|
46
|
+
it("should keep false as the flag value and mr list as positionals", () => {
|
|
47
|
+
const parsed = parseArgv(argv("--read-only", "false", "mr", "list"));
|
|
48
|
+
assert.deepEqual(parsed.positionals, ["mr", "list"]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe("When parseToolArgs validates a real schema", () => {
|
|
53
|
+
describe("with whoami", () => {
|
|
54
|
+
it("should accept an empty flag set", () => {
|
|
55
|
+
const args = parseToolArgs({
|
|
56
|
+
schema: schemaFor("whoami"),
|
|
57
|
+
flags: {},
|
|
58
|
+
argsJson: undefined,
|
|
59
|
+
extraArgs: undefined,
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(args, {});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("with create_issue missing title", () => {
|
|
65
|
+
it("should throw a usage error for the required flag", () => {
|
|
66
|
+
assert.throws(() => parseToolArgs({
|
|
67
|
+
schema: schemaFor("create_issue"),
|
|
68
|
+
flags: { project_id: "123" },
|
|
69
|
+
argsJson: undefined,
|
|
70
|
+
extraArgs: undefined,
|
|
71
|
+
}), (error) => error instanceof CliUsageError && error.message.includes("--title"));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe("with flags overlaying --args-json", () => {
|
|
75
|
+
it("should let flags win", () => {
|
|
76
|
+
const args = parseToolArgs({
|
|
77
|
+
schema: schemaFor("create_issue"),
|
|
78
|
+
flags: { title: "from-flag" },
|
|
79
|
+
argsJson: { project_id: "123", title: "from-json" },
|
|
80
|
+
extraArgs: undefined,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(args.project_id, "123");
|
|
83
|
+
assert.equal(args.title, "from-flag");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe("with an unknown flag", () => {
|
|
87
|
+
it("should throw a usage error", () => {
|
|
88
|
+
assert.throws(() => parseToolArgs({
|
|
89
|
+
schema: schemaFor("whoami"),
|
|
90
|
+
flags: { nope: "1" },
|
|
91
|
+
argsJson: undefined,
|
|
92
|
+
extraArgs: undefined,
|
|
93
|
+
}), (error) => error instanceof CliUsageError && error.message === "Unknown flag: --nope");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("When parseToolArgs coerces scalar flags from the schema", () => {
|
|
98
|
+
describe("with a number flag", () => {
|
|
99
|
+
it("should pass a number that the tool schema accepts", () => {
|
|
100
|
+
const args = parseToolArgs({
|
|
101
|
+
schema: schemaFor("get_pipeline_job_output"),
|
|
102
|
+
flags: { project_id: "1", job_id: "9", limit: "10" },
|
|
103
|
+
argsJson: undefined,
|
|
104
|
+
extraArgs: undefined,
|
|
105
|
+
});
|
|
106
|
+
assert.equal(GetPipelineJobOutputSchema.safeParse(args).success, true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("with a boolean flag", () => {
|
|
110
|
+
it("should pass a boolean that the tool schema accepts", () => {
|
|
111
|
+
const args = parseToolArgs({
|
|
112
|
+
schema: schemaFor("create_webhook"),
|
|
113
|
+
flags: { project_id: "1", url: "https://example.com/hook", push_events: "false" },
|
|
114
|
+
argsJson: undefined,
|
|
115
|
+
extraArgs: undefined,
|
|
116
|
+
});
|
|
117
|
+
assert.equal(CreateWebhookSchema.safeParse(args).success, true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe("with an integer flag", () => {
|
|
121
|
+
it("should pass an integer that the tool schema accepts", () => {
|
|
122
|
+
const args = parseToolArgs({
|
|
123
|
+
schema: schemaFor("protect_branch"),
|
|
124
|
+
flags: { project_id: "1", branch_name: "main", push_access_level: "40" },
|
|
125
|
+
argsJson: undefined,
|
|
126
|
+
extraArgs: undefined,
|
|
127
|
+
});
|
|
128
|
+
assert.strictEqual(args.push_access_level, 40);
|
|
129
|
+
assert.equal(ProtectBranchSchema.safeParse(args).success, true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe("with a field that also accepts strings", () => {
|
|
133
|
+
it("should keep the raw string", () => {
|
|
134
|
+
const args = parseToolArgs({
|
|
135
|
+
schema: schemaFor("list_webhook_events"),
|
|
136
|
+
flags: { project_id: "1", hook_id: "2", status: "500" },
|
|
137
|
+
argsJson: undefined,
|
|
138
|
+
extraArgs: undefined,
|
|
139
|
+
});
|
|
140
|
+
assert.equal(args.status, "500");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe("with a non-boolean value for a boolean flag", () => {
|
|
144
|
+
it("should throw a usage error", () => {
|
|
145
|
+
assert.throws(() => parseToolArgs({
|
|
146
|
+
schema: schemaFor("create_webhook"),
|
|
147
|
+
flags: { project_id: "1", url: "https://example.com/hook", push_events: "yes" },
|
|
148
|
+
argsJson: undefined,
|
|
149
|
+
extraArgs: undefined,
|
|
150
|
+
}), (error) => error instanceof CliUsageError && error.message === "--push-events must be true or false");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe("with a fraction for an integer flag", () => {
|
|
154
|
+
it("should throw a usage error", () => {
|
|
155
|
+
assert.throws(() => parseToolArgs({
|
|
156
|
+
schema: schemaFor("protect_branch"),
|
|
157
|
+
flags: { project_id: "1", branch_name: "main", push_access_level: "40.5" },
|
|
158
|
+
argsJson: undefined,
|
|
159
|
+
extraArgs: undefined,
|
|
160
|
+
}), (error) => error instanceof CliUsageError &&
|
|
161
|
+
error.message === "--push-access-level must be an integer");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe("When applyFlagAliases rewrites curated shorts", () => {
|
|
166
|
+
describe("with --mr-iid", () => {
|
|
167
|
+
it("should map onto merge_request_iid", () => {
|
|
168
|
+
const mapped = applyFlagAliases({ mr_iid: "45", project_id: "123" }, {
|
|
169
|
+
mr_iid: "merge_request_iid",
|
|
170
|
+
});
|
|
171
|
+
assert.equal(mapped.merge_request_iid, "45");
|
|
172
|
+
assert.equal(mapped.project_id, "123");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as url from "node:url";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { isBooleanFlagLiteral, nextBooleanFlagValue } from "../../cli-boolean-flags.js";
|
|
7
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
8
|
+
const SNAPSHOT_PATH = path.resolve(__dirname, "load-config-snapshot.ts");
|
|
9
|
+
function loadConfig(argv) {
|
|
10
|
+
const childEnv = {};
|
|
11
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
12
|
+
if (value !== undefined) {
|
|
13
|
+
childEnv[key] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
delete childEnv.GITLAB_READ_ONLY_MODE;
|
|
17
|
+
delete childEnv.GITLAB_USE_OAUTH;
|
|
18
|
+
const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", SNAPSHOT_PATH, ...argv], {
|
|
19
|
+
env: childEnv,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
});
|
|
23
|
+
return readBooleanConfigSnapshot(JSON.parse(stdout));
|
|
24
|
+
}
|
|
25
|
+
function readBooleanConfigSnapshot(value) {
|
|
26
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
27
|
+
throw new Error("config snapshot was not an object");
|
|
28
|
+
}
|
|
29
|
+
if (!("GITLAB_READ_ONLY_MODE" in value) || typeof value.GITLAB_READ_ONLY_MODE !== "boolean") {
|
|
30
|
+
throw new Error("GITLAB_READ_ONLY_MODE missing");
|
|
31
|
+
}
|
|
32
|
+
if (!("USE_OAUTH" in value) || typeof value.USE_OAUTH !== "boolean") {
|
|
33
|
+
throw new Error("USE_OAUTH missing");
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
GITLAB_READ_ONLY_MODE: value.GITLAB_READ_ONLY_MODE,
|
|
37
|
+
USE_OAUTH: value.USE_OAUTH,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
describe("When nextBooleanFlagValue reads the following token", () => {
|
|
41
|
+
describe("with a positional command", () => {
|
|
42
|
+
it("should treat the flag as true and leave the command", () => {
|
|
43
|
+
assert.deepEqual(nextBooleanFlagValue("mr"), { value: "true", consumeNext: false });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe("with an explicit false", () => {
|
|
47
|
+
it("should consume the literal", () => {
|
|
48
|
+
assert.deepEqual(nextBooleanFlagValue("false"), { value: "false", consumeNext: true });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("with a missing token", () => {
|
|
52
|
+
it("should default to true", () => {
|
|
53
|
+
assert.deepEqual(nextBooleanFlagValue(undefined), { value: "true", consumeNext: false });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("When isBooleanFlagLiteral inspects a token", () => {
|
|
58
|
+
describe("with true", () => {
|
|
59
|
+
it("should accept it", () => {
|
|
60
|
+
assert.equal(isBooleanFlagLiteral("true"), true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe("with a command name", () => {
|
|
64
|
+
it("should reject it", () => {
|
|
65
|
+
assert.equal(isBooleanFlagLiteral("mr"), false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("When config.ts parses space-separated boolean flags", () => {
|
|
70
|
+
describe("with --read-only as the last argument", () => {
|
|
71
|
+
it("should turn read-only mode on", () => {
|
|
72
|
+
const cfg = loadConfig(["--read-only"]);
|
|
73
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("with --read-only followed by another flag", () => {
|
|
77
|
+
it("should turn read-only mode on", () => {
|
|
78
|
+
const cfg = loadConfig(["--read-only", "--use-pipeline"]);
|
|
79
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("with an empty --read-only= assignment", () => {
|
|
83
|
+
it("should leave read-only mode off", () => {
|
|
84
|
+
const cfg = loadConfig(["--read-only="]);
|
|
85
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("with --read-only false", () => {
|
|
89
|
+
it("should keep read-only off", () => {
|
|
90
|
+
const cfg = loadConfig(["--read-only", "false"]);
|
|
91
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("with --read-only before a command", () => {
|
|
95
|
+
it("should turn read-only on without consuming mr", () => {
|
|
96
|
+
const cfg = loadConfig(["--read-only", "mr", "list"]);
|
|
97
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("with --use-oauth false", () => {
|
|
101
|
+
it("should keep oauth off", () => {
|
|
102
|
+
const cfg = loadConfig(["--use-oauth", "false"]);
|
|
103
|
+
assert.equal(cfg.USE_OAUTH, false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|