@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.
Files changed (39) hide show
  1. package/README.ko.md +8 -4
  2. package/README.md +14 -5
  3. package/README.pt-BR.md +589 -0
  4. package/README.zh-CN.md +8 -4
  5. package/build/cli/args.js +298 -0
  6. package/build/cli/compact-mcp-result.js +87 -0
  7. package/build/cli/curated.js +243 -0
  8. package/build/cli/exposure.js +72 -0
  9. package/build/cli/help.js +108 -0
  10. package/build/cli/output.js +65 -0
  11. package/build/cli/replay-command.js +53 -0
  12. package/build/cli/router.js +158 -0
  13. package/build/cli/tables.js +117 -0
  14. package/build/cli-boolean-flags.js +38 -0
  15. package/build/cli-command.js +14 -4
  16. package/build/config.js +30 -7
  17. package/build/index.js +329 -44
  18. package/build/oauth.js +93 -10
  19. package/build/schemas.js +187 -0
  20. package/build/scripts/generate-tool-docs.js +7 -1
  21. package/build/scripts/tool-coverage/coverage.js +2 -0
  22. package/build/test/cli/args.test.js +175 -0
  23. package/build/test/cli/boolean-flags.test.js +106 -0
  24. package/build/test/cli/compact-mcp-result.test.js +93 -0
  25. package/build/test/cli/exposure.test.js +104 -0
  26. package/build/test/cli/load-config-snapshot.js +5 -0
  27. package/build/test/cli/output.test.js +74 -0
  28. package/build/test/cli/replay-command.test.js +37 -0
  29. package/build/test/cli/router.test.js +183 -0
  30. package/build/test/oauth-device-flow-tests.js +10 -0
  31. package/build/test/oauth-startup.test.js +204 -0
  32. package/build/test/test-dynamic-project-scope.js +162 -0
  33. package/build/test/test-geteffectiveprojectid.js +4 -2
  34. package/build/test/test-permission-mode.js +87 -0
  35. package/build/test/test-snippets.js +735 -0
  36. package/build/test/test-toolset-filtering.js +3 -0
  37. package/build/tools/denied-regex.js +38 -0
  38. package/build/tools/registry.js +42 -1
  39. package/package.json +1 -1
package/build/config.js CHANGED
@@ -1,15 +1,30 @@
1
+ import { BOOLEAN_CLI_FLAG_NAMES, nextBooleanFlagValue } from "./cli-boolean-flags.js";
1
2
  // Parse CLI arguments before anything else
2
3
  const args = process.argv.slice(2);
3
4
  export const cliArgs = {};
4
5
  for (let i = 0; i < args.length; i++) {
5
6
  const arg = args[i];
6
7
  if (arg.startsWith("--")) {
7
- const [key, value] = arg.slice(2).split("=");
8
- if (value) {
9
- cliArgs[key] = value;
8
+ const eqIndex = arg.indexOf("=");
9
+ if (eqIndex !== -1) {
10
+ const key = arg.slice(2, eqIndex);
11
+ const value = arg.slice(eqIndex + 1);
12
+ if (value) {
13
+ cliArgs[key] = value;
14
+ }
10
15
  }
11
- else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
12
- cliArgs[key] = args[++i];
16
+ else {
17
+ const key = arg.slice(2);
18
+ if (BOOLEAN_CLI_FLAG_NAMES.has(key)) {
19
+ const parsed = nextBooleanFlagValue(args[i + 1]);
20
+ cliArgs[key] = parsed.value;
21
+ if (parsed.consumeNext) {
22
+ i += 1;
23
+ }
24
+ }
25
+ else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
26
+ cliArgs[key] = args[++i];
27
+ }
13
28
  }
14
29
  }
15
30
  }
@@ -76,7 +91,9 @@ export const MCP_SERVER_URL = getConfig("mcp-server-url", "MCP_SERVER_URL");
76
91
  export const GITLAB_OAUTH_APP_ID = getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID");
77
92
  export const GITLAB_OAUTH_SCOPES_RAW = getConfig("oauth-scopes", "GITLAB_OAUTH_SCOPES");
78
93
  export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
79
- ? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
94
+ ? GITLAB_OAUTH_SCOPES_RAW.split(",")
95
+ .map(s => s.trim())
96
+ .filter(Boolean)
80
97
  : undefined;
81
98
  export const GITLAB_OAUTH_CALLBACK_PROXY = getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true";
82
99
  /** @deprecated Use GITLAB_OAUTH_ALLOWED_GROUPS_RAW instead. Will be removed in the next major version. */
@@ -88,7 +105,10 @@ export const GITLAB_OAUTH_ALLOWED_GROUPS = (() => {
88
105
  const raw = newVar ?? oldVar;
89
106
  if (!raw)
90
107
  return undefined;
91
- const groups = raw.split(",").map((g) => g.trim()).filter(Boolean);
108
+ const groups = raw
109
+ .split(",")
110
+ .map(g => g.trim())
111
+ .filter(Boolean);
92
112
  return groups.length > 0 ? groups : undefined;
93
113
  })();
94
114
  export const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
@@ -163,6 +183,9 @@ export const HOST = getConfig("host", "HOST") || "127.0.0.1";
163
183
  /** Default HTTP port for the MCP server. */
164
184
  const _PORT_DEFAULT = 3002;
165
185
  export const PORT = _intEnv("PORT", "port", _PORT_DEFAULT);
186
+ /** When true, oversized MCP tool replies are replaced with a preview plus a CLI replay command. */
187
+ export const GITLAB_MCP_COMPACT_RESULTS = getConfig("compact-results", "GITLAB_MCP_COMPACT_RESULTS") === "true";
188
+ export const GITLAB_MCP_COMPACT_RESULT_CHARS = _intEnv("GITLAB_MCP_COMPACT_RESULT_CHARS", "compact-result-chars", 4000);
166
189
  // ---------------------------------------------------------------------------
167
190
  // Proxy configuration
168
191
  // ---------------------------------------------------------------------------
package/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
2
+ import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, } from "./config.js";
3
3
  /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
4
4
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
5
5
  const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STREAMABLE_HTTP_AUTH_TOKEN");
@@ -74,9 +74,13 @@ import path from "node:path";
74
74
  import { CookieJar, parse as parseCookie } from "tough-cookie";
75
75
  import { URL } from "node:url";
76
76
  import { z } from "zod";
77
- import { initializeOAuthClient } from "./oauth.js";
77
+ import { createGitLabOAuthClient, ensureOAuthToken } from "./oauth.js";
78
78
  import { getPositionalCliCommand } from "./cli-command.js";
79
79
  import { runAuthCommandAsync } from "./auth-cli.js";
80
+ import { isCliInvocation, resolveCli } from "./cli/router.js";
81
+ import { formatCliError, formatToolOutput } from "./cli/output.js";
82
+ import { compactMcpToolResult, readOwnRecord } from "./cli/compact-mcp-result.js";
83
+ import { compileDeniedToolsRegex } from "./tools/denied-regex.js";
80
84
  import { createGitLabOAuthProvider } from "./oauth-proxy.js";
81
85
  import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
82
86
  import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -114,7 +118,7 @@ GitLabDiscussionNoteSchema, // Added
114
118
  GitLabDiscussionSchema,
115
119
  // Draft Notes Schemas
116
120
  GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineScheduleSchema, GitLabPipelineScheduleVariableSchema, GitLabPipelineSchedulePlayResultSchema, ListPipelineSchedulesSchema, GetPipelineScheduleSchema, ListPipelineSchedulePipelinesSchema, CreatePipelineScheduleSchema, UpdatePipelineScheduleSchema, DeletePipelineScheduleSchema, PlayPipelineScheduleSchema, TakeOwnershipPipelineScheduleSchema, GetPipelineScheduleVariableSchema, CreatePipelineScheduleVariableSchema, UpdatePipelineScheduleVariableSchema, DeletePipelineScheduleVariableSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
117
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, GetMergeRequestDiscussionSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, CreateWebhookSchema, UpdateWebhookSchema, DeleteWebhookSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
121
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, ListSnippetsSchema, GetSnippetSchema, CreateSnippetSchema, UpdateSnippetSchema, DeleteSnippetSchema, GitLabSnippetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, GetMergeRequestDiscussionSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, CreateWebhookSchema, UpdateWebhookSchema, DeleteWebhookSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
118
122
  import { randomUUID, timingSafeEqual, } from "node:crypto";
119
123
  import { createLogger } from "./utils/logger.js";
120
124
  const logger = createLogger();
@@ -179,6 +183,7 @@ const MCP_PROMPTS = [
179
183
  * Each transport connection gets its own Server instance to prevent
180
184
  * cross-client data leakage (GHSA-345p-7cg4-v4c7).
181
185
  */
186
+ /** Builds an MCP server with the tool list filtered by toolsets, permission mode, and policy. */
182
187
  function createServer() {
183
188
  // Precompute filtered tool list once at server creation (Steps 1–5 are static)
184
189
  // Step 1: Toolset filter — keep tools in enabled toolsets
@@ -329,11 +334,18 @@ function createServer() {
329
334
  ? (value) => maskingEngine.maskValue(value)
330
335
  : undefined,
331
336
  });
332
- return maskingEngine
337
+ const masked = maskingEngine
333
338
  ? maskingEngine.maskToolResult(filtered, {
334
339
  textFormat: isPlainTextResult ? "plain" : "json",
335
340
  })
336
341
  : filtered;
342
+ return compactMcpToolResult({
343
+ result: masked,
344
+ enabled: GITLAB_MCP_COMPACT_RESULTS,
345
+ maxChars: GITLAB_MCP_COMPACT_RESULT_CHARS,
346
+ toolName,
347
+ args: readOwnRecord(request.params.arguments),
348
+ });
337
349
  };
338
350
  const logError = (error) => {
339
351
  const durationMs = Date.now() - start;
@@ -398,6 +410,16 @@ function createServer() {
398
410
  if (jmespathSyntaxError) {
399
411
  return logCompletion({ content: [{ type: "text", text: jmespathSyntaxError }], isError: true }, { skipJmespath: true });
400
412
  }
413
+ // Enforce list-time filtering on direct calls: clients can call names that tools/list omits.
414
+ // filteredTools is extended by discover_tools; hidden-policy tools stay callable by design,
415
+ // but GITLAB_DENIED_TOOLS_REGEX always wins over the hidden exemption.
416
+ // Permission-mode denials fall through so handleToolCall reports its mode-specific error.
417
+ const hiddenAndNotDenied = hiddenToolSet.has(toolName) && !GITLAB_DENIED_TOOLS_REGEX?.test(toolName);
418
+ if (isToolAllowedByPermissionMode(toolName) &&
419
+ !hiddenAndNotDenied &&
420
+ !filteredTools.some(t => t.name === toolName)) {
421
+ throw new Error(`Tool "${toolName}" is not available on this server`);
422
+ }
401
423
  // Handle discover_tools meta-tool directly (needs access to mcpServer and filteredTools)
402
424
  if (toolName === "discover_tools") {
403
425
  const category = request.params.arguments?.category?.trim()?.toLowerCase();
@@ -526,6 +548,7 @@ function createServer() {
526
548
  }
527
549
  catch (error) {
528
550
  logError(error);
551
+ throw error;
529
552
  }
530
553
  });
531
554
  return mcpServer;
@@ -846,15 +869,10 @@ export function hasStatelessSessionId(req) {
846
869
  * This avoids background timers that cause issues with multiple instances.
847
870
  */
848
871
  async function ensureValidOAuthToken() {
849
- if (!oauthClient)
850
- return;
851
- if (oauthClient.hasValidToken())
852
- return;
853
872
  try {
854
- logger.info("OAuth token expired or missing, refreshing...");
855
- const freshToken = await oauthClient.getAccessToken();
856
- OAUTH_ACCESS_TOKEN = freshToken;
857
- logger.info("OAuth token refreshed successfully");
873
+ await ensureOAuthToken(oauthClient, OAUTH_ACCESS_TOKEN, token => {
874
+ OAUTH_ACCESS_TOKEN = token;
875
+ });
858
876
  }
859
877
  catch (error) {
860
878
  logger.error({ err: error }, "Failed to refresh OAuth token");
@@ -873,33 +891,11 @@ function isToolAllowedByPermissionMode(toolName) {
873
891
  }
874
892
  }
875
893
  const GITLAB_DENIED_TOOLS_REGEX = (() => {
876
- const pattern = getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX");
877
- if (!pattern)
878
- return undefined;
879
- // Reject patterns that are too long (potential ReDoS vector)
880
- const MAX_PATTERN_LENGTH = 200;
881
- if (pattern.length > MAX_PATTERN_LENGTH) {
882
- logger.error(`GITLAB_DENIED_TOOLS_REGEX pattern exceeds ${MAX_PATTERN_LENGTH} chars. Ignoring.`);
883
- return undefined;
884
- }
885
- // Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
886
- // e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
887
- // Note: lookahead (?!), (?=), lookbehind (?<), and named groups (?<name>) are safe and allowed
888
- const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]/;
889
- if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
890
- logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
891
- return undefined;
892
- }
893
- try {
894
- const regex = new RegExp(pattern);
895
- // Dry-run against a sample string to catch immediate issues
896
- regex.test("sample_tool_name");
897
- return regex;
898
- }
899
- catch {
900
- logger.error(`Invalid GITLAB_DENIED_TOOLS_REGEX pattern: "${pattern}". Ignoring.`);
901
- return undefined;
894
+ const compiled = compileDeniedToolsRegex(getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX"));
895
+ if (compiled.error) {
896
+ logger.error(compiled.error);
902
897
  }
898
+ return compiled.regex;
903
899
  })();
904
900
  // ---------------------------------------------------------------------------
905
901
  // Tool policy: approve / hidden sets
@@ -1317,7 +1313,7 @@ if (GITLAB_MCP_OAUTH) {
1317
1313
  }
1318
1314
  logger.info("MCP OAuth enabled: GitLab OAuth proxy active (Private-Token/JOB-TOKEN headers bypass OAuth)");
1319
1315
  }
1320
- if (getPositionalCliCommand(process.argv) !== "auth" &&
1316
+ if (!isCliInvocation(process.argv) &&
1321
1317
  !REMOTE_AUTHORIZATION &&
1322
1318
  !GITLAB_MCP_OAUTH &&
1323
1319
  !USE_OAUTH &&
@@ -7605,6 +7601,163 @@ async function executeGitLabGraphQL(query, variables = {}) {
7605
7601
  clearTimeout(timeout);
7606
7602
  }
7607
7603
  }
7604
+ /**
7605
+ * Build the snippets endpoint URL.
7606
+ *
7607
+ * When projectId is provided, or when a project allowlist is in effect (env
7608
+ * GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS or a session scope narrowed via
7609
+ * X-GitLab-Allowed-Project-Ids), returns the project snippets endpoint — routing
7610
+ * through getEffectiveProjectId so the effective scope is enforced. Only falls
7611
+ * back to the personal /snippets endpoint when no scope is active at all.
7612
+ */
7613
+ function getSnippetsEndpoint(projectId) {
7614
+ const scopeActive = getEffectiveAllowedProjectIds().length > 0 || !!GITLAB_PROJECT_ID;
7615
+ if (projectId || scopeActive) {
7616
+ const decoded = projectId ? decodeURIComponent(projectId) : "";
7617
+ const effectiveProjectId = getEffectiveProjectId(decoded);
7618
+ return `${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/snippets`;
7619
+ }
7620
+ return `${getEffectiveApiUrl()}/snippets`;
7621
+ }
7622
+ /**
7623
+ * List snippets — project snippets if projectId is given, otherwise personal snippets.
7624
+ */
7625
+ async function listSnippets(projectId, options = {}) {
7626
+ const url = new URL(getSnippetsEndpoint(projectId));
7627
+ Object.entries(options).forEach(([key, value]) => {
7628
+ if (value !== undefined) {
7629
+ url.searchParams.append(key, String(value));
7630
+ }
7631
+ });
7632
+ const response = await fetch(url.toString(), {
7633
+ ...getFetchConfig(),
7634
+ });
7635
+ await handleGitLabError(response);
7636
+ const data = await response.json();
7637
+ return GitLabSnippetSchema.array().parse(data);
7638
+ }
7639
+ /**
7640
+ * Get a snippet's metadata. Use getSnippetRawContent to fetch the raw file content.
7641
+ */
7642
+ async function getSnippet(projectId, snippetId) {
7643
+ const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}`, {
7644
+ ...getFetchConfig(),
7645
+ });
7646
+ await handleGitLabError(response);
7647
+ const data = await response.json();
7648
+ return GitLabSnippetSchema.parse(data);
7649
+ }
7650
+ /**
7651
+ * Get the raw content of a single-file snippet via the `/raw` endpoint.
7652
+ * For multi-file snippets, use getSnippetFileRawContent instead.
7653
+ */
7654
+ async function getSnippetRawContent(projectId, snippetId) {
7655
+ const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}/raw`, {
7656
+ ...getFetchConfig(),
7657
+ });
7658
+ await handleGitLabError(response);
7659
+ return await response.text();
7660
+ }
7661
+ /**
7662
+ * Extract the ref (branch/tag/commit) from a snippet file's raw_url.
7663
+ * Anchors on /snippets/{id}/raw/ so branch names or file paths containing
7664
+ * the word "raw" don't produce a false match.
7665
+ */
7666
+ function extractSnippetRef(rawUrl, snippetId, filePath) {
7667
+ const rawMarker = `/snippets/${snippetId}/raw/`;
7668
+ const decoded = decodeURIComponent(new URL(rawUrl).pathname);
7669
+ const markerIdx = decoded.indexOf(rawMarker);
7670
+ if (markerIdx === -1) {
7671
+ throw new Error(`Cannot extract ref from snippet file raw_url: ${rawUrl}`);
7672
+ }
7673
+ const afterRaw = decoded.slice(markerIdx + rawMarker.length); // "{ref}/{filePath}"
7674
+ const fileStart = afterRaw.lastIndexOf("/" + filePath);
7675
+ if (fileStart === -1) {
7676
+ throw new Error(`Cannot locate file path "${filePath}" in snippet file raw_url: ${rawUrl}`);
7677
+ }
7678
+ return afterRaw.slice(0, fileStart);
7679
+ }
7680
+ /**
7681
+ * Fetch the raw content of one file inside a multi-file snippet.
7682
+ * Accepts an explicit ref (branch/tag/commit) — callers resolve it via
7683
+ * extractSnippetRef or a user-supplied parameter before calling this.
7684
+ */
7685
+ async function getSnippetFileRawContent(projectId, snippetId, ref, filePath) {
7686
+ const encodedRef = encodeGitLabPathSegment(ref);
7687
+ const encodedPath = encodeGitLabPathSegment(filePath);
7688
+ const url = `${getSnippetsEndpoint(projectId)}/${snippetId}/files/${encodedRef}/${encodedPath}/raw`;
7689
+ const response = await fetch(url, { ...getFetchConfig() });
7690
+ await handleGitLabError(response);
7691
+ return await response.text();
7692
+ }
7693
+ function snippetCreateFilesPayload(options) {
7694
+ if (options.files !== undefined && options.files.length > 0) {
7695
+ return options.files;
7696
+ }
7697
+ if (options.file_name !== undefined && options.content !== undefined) {
7698
+ return [{ file_path: options.file_name, content: options.content }];
7699
+ }
7700
+ throw new Error("Provide either files[] (multi-file) or both file_name and content (single-file)");
7701
+ }
7702
+ /**
7703
+ * Create a snippet — project-scoped if projectId is given, otherwise personal.
7704
+ */
7705
+ async function createSnippet(projectId, options) {
7706
+ const { title, description, visibility } = options;
7707
+ const body = {
7708
+ title,
7709
+ visibility: visibility ?? "private",
7710
+ files: snippetCreateFilesPayload(options),
7711
+ };
7712
+ if (description !== undefined) {
7713
+ body.description = description;
7714
+ }
7715
+ const response = await fetch(getSnippetsEndpoint(projectId), {
7716
+ ...getFetchConfig(),
7717
+ method: "POST",
7718
+ body: JSON.stringify(body),
7719
+ });
7720
+ await handleGitLabError(response);
7721
+ const data = await response.json();
7722
+ return GitLabSnippetSchema.parse(data);
7723
+ }
7724
+ /**
7725
+ * Update an existing snippet — project-scoped if projectId is given, otherwise personal.
7726
+ */
7727
+ async function updateSnippet(projectId, options) {
7728
+ const { snippet_id, title, file_name, content, files, description, visibility } = options;
7729
+ const body = {};
7730
+ if (title !== undefined)
7731
+ body.title = title;
7732
+ if (description !== undefined)
7733
+ body.description = description;
7734
+ if (visibility !== undefined)
7735
+ body.visibility = visibility;
7736
+ if (files !== undefined) {
7737
+ body.files = files;
7738
+ }
7739
+ else if (file_name !== undefined && content !== undefined) {
7740
+ body.files = [{ action: "update", file_path: file_name, content }];
7741
+ }
7742
+ const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippet_id}`, {
7743
+ ...getFetchConfig(),
7744
+ method: "PUT",
7745
+ body: JSON.stringify(body),
7746
+ });
7747
+ await handleGitLabError(response);
7748
+ const data = await response.json();
7749
+ return GitLabSnippetSchema.parse(data);
7750
+ }
7751
+ /**
7752
+ * Delete a snippet — project-scoped if projectId is given, otherwise personal.
7753
+ */
7754
+ async function deleteSnippet(projectId, snippetId) {
7755
+ const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}`, {
7756
+ ...getFetchConfig(),
7757
+ method: "DELETE",
7758
+ });
7759
+ await handleGitLabError(response);
7760
+ }
7608
7761
  // Request handlers are now registered inside createServer() factory function
7609
7762
  // to ensure each transport connection gets its own Server instance (GHSA-345p-7cg4-v4c7).
7610
7763
  async function handleToolCall(params) {
@@ -10129,6 +10282,82 @@ async function handleToolCall(params) {
10129
10282
  content: [{ type: "text", text: JSON.stringify(signature) }],
10130
10283
  };
10131
10284
  }
10285
+ case "list_snippets": {
10286
+ const args = ListSnippetsSchema.parse(params.arguments);
10287
+ const { project_id, ...options } = args;
10288
+ const snippets = await listSnippets(project_id, options);
10289
+ return {
10290
+ content: [{ type: "text", text: JSON.stringify(snippets, null, 2) }],
10291
+ };
10292
+ }
10293
+ case "get_snippet": {
10294
+ const args = GetSnippetSchema.parse(params.arguments);
10295
+ const snippet = await getSnippet(args.project_id, args.snippet_id);
10296
+ const result = { ...snippet };
10297
+ if (args.include_content) {
10298
+ const files = snippet.files ?? [];
10299
+ if (files.length > 1) {
10300
+ const firstFile = files[0];
10301
+ let ref;
10302
+ if (args.ref !== undefined) {
10303
+ ref = args.ref;
10304
+ }
10305
+ else {
10306
+ if (!firstFile.raw_url)
10307
+ throw new Error(`Snippet file "${firstFile.path}" has no raw_url`);
10308
+ ref = extractSnippetRef(firstFile.raw_url, args.snippet_id, firstFile.path);
10309
+ }
10310
+ result.files = await Promise.all(files.map(async (f) => ({
10311
+ ...f,
10312
+ content: await getSnippetFileRawContent(args.project_id, args.snippet_id, ref, f.path),
10313
+ })));
10314
+ }
10315
+ else {
10316
+ const file = files[0];
10317
+ const filePath = file?.path ?? snippet.file_name ?? undefined;
10318
+ if (args.ref !== undefined && filePath) {
10319
+ result.content = await getSnippetFileRawContent(args.project_id, args.snippet_id, args.ref, filePath);
10320
+ }
10321
+ else {
10322
+ result.content = await getSnippetRawContent(args.project_id, args.snippet_id);
10323
+ }
10324
+ }
10325
+ }
10326
+ return {
10327
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
10328
+ };
10329
+ }
10330
+ case "create_snippet": {
10331
+ const args = CreateSnippetSchema.parse(params.arguments);
10332
+ const { project_id, ...options } = args;
10333
+ const snippet = await createSnippet(project_id, options);
10334
+ return {
10335
+ content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }],
10336
+ };
10337
+ }
10338
+ case "update_snippet": {
10339
+ const args = UpdateSnippetSchema.parse(params.arguments);
10340
+ const { project_id, ...options } = args;
10341
+ const snippet = await updateSnippet(project_id, options);
10342
+ return {
10343
+ content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }],
10344
+ };
10345
+ }
10346
+ case "delete_snippet": {
10347
+ const args = DeleteSnippetSchema.parse(params.arguments);
10348
+ await deleteSnippet(args.project_id, args.snippet_id);
10349
+ return {
10350
+ content: [
10351
+ {
10352
+ type: "text",
10353
+ text: JSON.stringify({
10354
+ status: "success",
10355
+ message: `Snippet ${args.snippet_id} deleted successfully`,
10356
+ }, null, 2),
10357
+ },
10358
+ ],
10359
+ };
10360
+ }
10132
10361
  case "list_webhooks": {
10133
10362
  const args = ListWebhooksSchema.parse(params.arguments);
10134
10363
  const webhooks = await listWebhooks(args);
@@ -11771,10 +12000,12 @@ async function runServer() {
11771
12000
  logger.info("Using OAuth authentication...");
11772
12001
  try {
11773
12002
  const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
11774
- const oauthResult = await initializeOAuthClient(gitlabBaseUrl);
11775
- oauthClient = oauthResult.client;
11776
- OAUTH_ACCESS_TOKEN = oauthResult.accessToken;
11777
- logger.info("OAuth authentication successful");
12003
+ // Construct the client synchronously (no network). The token is
12004
+ // acquired lazily on the first tool call via ensureValidOAuthToken,
12005
+ // by which point the network is ready. This avoids blocking startup
12006
+ // and avoids opening a browser on transient boot-time network errors.
12007
+ oauthClient = createGitLabOAuthClient(gitlabBaseUrl);
12008
+ logger.info("OAuth enabled; token acquired lazily on first use.");
11778
12009
  }
11779
12010
  catch (error) {
11780
12011
  logger.error({ err: error }, "OAuth authentication failed");
@@ -11816,7 +12047,16 @@ async function runServer() {
11816
12047
  }
11817
12048
  }
11818
12049
  async function main() {
11819
- if (getPositionalCliCommand(process.argv) === "auth") {
12050
+ const cli = resolveCli(process.argv);
12051
+ if (cli.kind === "help") {
12052
+ process.stdout.write(cli.text);
12053
+ process.exit(0);
12054
+ }
12055
+ if (cli.kind === "usage" || cli.kind === "refused") {
12056
+ process.stderr.write(`${cli.message}\n`);
12057
+ process.exit(2);
12058
+ }
12059
+ if (cli.kind === "auth" || getPositionalCliCommand(process.argv) === "auth") {
11820
12060
  try {
11821
12061
  await runAuthCommandAsync();
11822
12062
  process.exit(0);
@@ -11828,8 +12068,53 @@ async function main() {
11828
12068
  process.exit(1);
11829
12069
  }
11830
12070
  }
12071
+ if (cli.kind === "run") {
12072
+ if (!hasCliCredentials()) {
12073
+ process.stderr.write("Missing GitLab credentials. Set --token or GITLAB_PERSONAL_ACCESS_TOKEN, or run `auth` with GITLAB_USE_OAUTH=true.\n");
12074
+ process.exit(2);
12075
+ }
12076
+ try {
12077
+ const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
12078
+ const masked = maskCliToolResult(cli.toolName, result);
12079
+ const formatted = formatToolOutput({
12080
+ result: masked,
12081
+ mode: cli.output,
12082
+ tableSpec: cli.tableSpec,
12083
+ });
12084
+ if (formatted.stderr) {
12085
+ process.stderr.write(formatted.stderr);
12086
+ }
12087
+ process.stdout.write(formatted.stdout);
12088
+ process.exit(formatted.isError ? 1 : 0);
12089
+ }
12090
+ catch (error) {
12091
+ process.stderr.write(formatCliError(error));
12092
+ logger.error({ err: error }, "cli command failed");
12093
+ process.exit(1);
12094
+ }
12095
+ }
11831
12096
  await runServer();
11832
12097
  }
12098
+ function hasCliCredentials() {
12099
+ return Boolean(GITLAB_PERSONAL_ACCESS_TOKEN || GITLAB_JOB_TOKEN || GITLAB_AUTH_COOKIE_PATH || USE_OAUTH);
12100
+ }
12101
+ function isRecord(value) {
12102
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12103
+ }
12104
+ function maskCliToolResult(toolName, result) {
12105
+ const gitlabInstance = getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com";
12106
+ const engine = maskingPolicyResolver?.select({
12107
+ gitlabInstance,
12108
+ allowBuiltinForUnscoped: true,
12109
+ });
12110
+ if (!engine || !isRecord(result)) {
12111
+ return result;
12112
+ }
12113
+ const isPlainText = toolName === "get_pipeline_job_output" ||
12114
+ toolName === "get_job_artifact_file" ||
12115
+ (toolName === "download_release_asset" && !IS_REMOTE);
12116
+ return engine.maskToolResult(result, { textFormat: isPlainText ? "plain" : "json" });
12117
+ }
11833
12118
  main().catch(error => {
11834
12119
  logger.fatal({ err: error }, "Fatal error in main()");
11835
12120
  process.exit(1);