@zereight/mcp-gitlab 2.2.0 → 2.2.2

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/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_PERMISSION_MODE_RAW, 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_GITLAB_WIKI_RAW, USE_MILESTONE, USE_MILESTONE_RAW, USE_OAUTH, USE_PIPELINE, USE_PIPELINE_RAW, 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, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, } 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_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, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, buildDeprecatedEnvInput, } from "./config.js";
3
3
  import { getDeprecatedEnvWarnings } from "./deprecated-env.js";
4
4
  /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
5
5
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
@@ -97,6 +97,8 @@ import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation
97
97
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
98
98
  import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
99
99
  import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
100
+ import { classifyHealthCheckTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
101
+ import { redactUrlSecretsInText } from "./utils/redact-url.js";
100
102
  import { createMaskingPolicyResolver, getManagedMaskingProjectIds, } from "./masking/index.js";
101
103
  import { checkForNewVersion } from "./utils/version-check.js";
102
104
  import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
@@ -119,11 +121,15 @@ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMerg
119
121
  GitLabDiscussionNoteSchema, // Added
120
122
  GitLabDiscussionSchema,
121
123
  // Draft Notes Schemas
122
- 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
123
- 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";
124
+ GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabProtectedTagSchema, 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
125
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListProjectRunnersSchema, GetRunnerSchema, 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, ListProtectedTagsSchema, GetProtectedTagSchema, ProtectTagSchema, UnprotectTagSchema, 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";
124
126
  import { randomUUID, timingSafeEqual, } from "node:crypto";
125
- import { createLogger } from "./utils/logger.js";
127
+ import { createLogger, flushBufferedLogs, flushStartupLogs } from "./utils/logger.js";
126
128
  const logger = createLogger();
129
+ function exitAfterFlush(code) {
130
+ flushBufferedLogs(logger);
131
+ process.exit(code);
132
+ }
127
133
  // Construct once at startup. When disabled this is undefined and the result
128
134
  // path below returns the original objects without reading a config file.
129
135
  const maskingPolicyResolver = createMaskingPolicyResolver({
@@ -303,6 +309,7 @@ function createServer() {
303
309
  durationMs,
304
310
  error: safeError instanceof Error ? safeError.message : String(safeError),
305
311
  }, `tool_call_error: ${toolName} (${durationMs}ms)`);
312
+ flushBufferedLogs(logger);
306
313
  return safeError;
307
314
  };
308
315
  try {
@@ -502,9 +509,11 @@ function createServer() {
502
509
  return logCompletion(result);
503
510
  }
504
511
  catch (error) {
505
- const isGitLabApiError = error instanceof GitLabApiError;
512
+ const isRecoverableToolError = error instanceof GitLabApiError ||
513
+ error instanceof ToolInputError ||
514
+ error instanceof ProjectAccessDeniedError;
506
515
  const safeError = logError(error);
507
- if (isGitLabApiError) {
516
+ if (isRecoverableToolError) {
508
517
  return {
509
518
  content: [
510
519
  {
@@ -709,7 +718,7 @@ function validateConfiguration() {
709
718
  new URL(url.trim());
710
719
  }
711
720
  catch {
712
- errors.push(`GITLAB_API_URL contains an invalid URL: ${url.trim()}`);
721
+ errors.push(`GITLAB_API_URL contains an invalid URL: ${redactUrlSecretsInText(url.trim())}`);
713
722
  }
714
723
  }
715
724
  }
@@ -795,7 +804,7 @@ function validateConfiguration() {
795
804
  if (errors.length > 0) {
796
805
  logger.error("Configuration validation failed:");
797
806
  errors.forEach(err => logger.error(` - ${err}`));
798
- process.exit(1);
807
+ exitAfterFlush(1);
799
808
  }
800
809
  logger.info("Configuration validation passed");
801
810
  }
@@ -807,6 +816,20 @@ let oauthClient = null;
807
816
  * switch to signed/sealed opaque values instead of per-pod in-memory caches.
808
817
  */
809
818
  let STATELESS_MATERIAL = null;
819
+ let hasEmittedDeprecatedEnvWarnings = false;
820
+ function emitDeprecatedEnvWarnings() {
821
+ if (hasEmittedDeprecatedEnvWarnings) {
822
+ return;
823
+ }
824
+ hasEmittedDeprecatedEnvWarnings = true;
825
+ const warnings = getDeprecatedEnvWarnings(buildDeprecatedEnvInput());
826
+ for (const warning of warnings) {
827
+ logger.warn(warning);
828
+ }
829
+ if (warnings.length > 0) {
830
+ flushStartupLogs(logger);
831
+ }
832
+ }
810
833
  try {
811
834
  // Drive enablement from the already-resolved config flag so the CLI flag
812
835
  // (--oauth-stateless-mode) is honored. Re-reading env.OAUTH_STATELESS_MODE
@@ -821,6 +844,7 @@ try {
821
844
  }
822
845
  }
823
846
  catch (err) {
847
+ emitDeprecatedEnvWarnings();
824
848
  // eslint-disable-next-line no-console -- startup failure must be visible
825
849
  console.error(`[gitlab-mcp] failed to load stateless secret: ${err.message}`);
826
850
  process.exit(1);
@@ -1064,7 +1088,11 @@ const BASE_HEADERS = {
1064
1088
  function buildAuthHeaders() {
1065
1089
  if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
1066
1090
  const ctx = sessionAuthStore.getStore();
1067
- logger.debug({ sessionId: ctx?.sessionId, header: ctx?.header, apiUrl: ctx?.apiUrl }, "buildAuthHeaders: session context");
1091
+ logger.debug({
1092
+ sessionId: ctx?.sessionId,
1093
+ header: ctx?.header,
1094
+ apiUrl: ctx?.apiUrl === undefined ? undefined : redactUrlSecretsInText(ctx.apiUrl),
1095
+ }, "buildAuthHeaders: session context");
1068
1096
  if (ctx?.token) {
1069
1097
  return {
1070
1098
  [ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
@@ -1112,7 +1140,11 @@ function getEffectiveApiUrl() {
1112
1140
  if (ctx?.apiUrl) {
1113
1141
  return ctx.apiUrl;
1114
1142
  }
1115
- logger.warn({ sessionId: ctx?.sessionId, header: ctx?.header, apiUrl: ctx?.apiUrl }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
1143
+ logger.warn({
1144
+ sessionId: ctx?.sessionId,
1145
+ header: ctx?.header,
1146
+ apiUrl: ctx?.apiUrl === undefined ? undefined : redactUrlSecretsInText(ctx.apiUrl),
1147
+ }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
1116
1148
  }
1117
1149
  return GITLAB_API_URL;
1118
1150
  }
@@ -1278,27 +1310,31 @@ const GITLAB_REPO_FILE_ENCODING = getConfig("repo-file-encoding", "GITLAB_REPO_F
1278
1310
  if (REMOTE_AUTHORIZATION) {
1279
1311
  // Remote authorization mode: token comes from HTTP headers
1280
1312
  if (SSE) {
1313
+ emitDeprecatedEnvWarnings();
1281
1314
  logger.error("REMOTE_AUTHORIZATION=true is not compatible with SSE transport mode");
1282
1315
  logger.error("Please use STREAMABLE_HTTP=true instead");
1283
- process.exit(1);
1316
+ exitAfterFlush(1);
1284
1317
  }
1285
1318
  if (!STREAMABLE_HTTP) {
1319
+ emitDeprecatedEnvWarnings();
1286
1320
  logger.error("REMOTE_AUTHORIZATION=true requires STREAMABLE_HTTP=true");
1287
1321
  logger.error("Set STREAMABLE_HTTP=true to enable remote authorization");
1288
- process.exit(1);
1322
+ exitAfterFlush(1);
1289
1323
  }
1290
1324
  logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
1291
1325
  }
1292
1326
  if (GITLAB_MCP_OAUTH) {
1293
1327
  if (SSE) {
1328
+ emitDeprecatedEnvWarnings();
1294
1329
  logger.error("GITLAB_MCP_OAUTH=true is not compatible with SSE transport mode");
1295
1330
  logger.error("Please use STREAMABLE_HTTP=true instead");
1296
- process.exit(1);
1331
+ exitAfterFlush(1);
1297
1332
  }
1298
1333
  if (!STREAMABLE_HTTP) {
1334
+ emitDeprecatedEnvWarnings();
1299
1335
  logger.error("GITLAB_MCP_OAUTH=true requires STREAMABLE_HTTP=true");
1300
1336
  logger.error("Set STREAMABLE_HTTP=true to enable MCP OAuth");
1301
- process.exit(1);
1337
+ exitAfterFlush(1);
1302
1338
  }
1303
1339
  logger.info("MCP OAuth enabled: GitLab OAuth proxy active (Private-Token/JOB-TOKEN headers bypass OAuth)");
1304
1340
  }
@@ -1309,10 +1345,12 @@ if (!isCliInvocation(process.argv) &&
1309
1345
  !GITLAB_PERSONAL_ACCESS_TOKEN &&
1310
1346
  !GITLAB_JOB_TOKEN &&
1311
1347
  !GITLAB_AUTH_COOKIE_PATH) {
1312
- // Standard mode: token must be in environment (unless using OAuth)
1348
+ // Standard mode: token must be in environment (unless using OAuth).
1349
+ // This exit runs before runServer(), so warnings have to be printed here too.
1350
+ emitDeprecatedEnvWarnings();
1313
1351
  logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
1314
1352
  logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
1315
- process.exit(1);
1353
+ exitAfterFlush(1);
1316
1354
  }
1317
1355
  class GitLabApiError extends Error {
1318
1356
  constructor(message) {
@@ -1320,6 +1358,19 @@ class GitLabApiError extends Error {
1320
1358
  this.name = "GitLabApiError";
1321
1359
  }
1322
1360
  }
1361
+ class ToolInputError extends Error {
1362
+ constructor(message) {
1363
+ super(message);
1364
+ this.name = "ToolInputError";
1365
+ }
1366
+ }
1367
+ class ProjectAccessDeniedError extends Error {
1368
+ constructor(message) {
1369
+ super(message);
1370
+ this.name = "ProjectAccessDeniedError";
1371
+ }
1372
+ }
1373
+ const MERGE_REQUEST_IDENTIFIER_REQUIRED = "Either merge_request_iid or source_branch must be provided";
1323
1374
  /**
1324
1375
  * Utility function for handling GitLab API errors
1325
1376
  * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
@@ -1346,10 +1397,44 @@ async function handleGitLabError(response) {
1346
1397
  }
1347
1398
  }
1348
1399
  }
1400
+ const USER_API_INSUFFICIENT_SCOPE_MESSAGE = "GitLab API error: 403 Forbidden on GET /api/v4/user (insufficient scope). The token was accepted, but read_user (classic PAT) or User API read (fine-grained User:Read) is required for whoami, my_issues, and other current-user tools.";
1401
+ const USER_API_HEALTH_WARNING = "GET /api/v4/user returned 403 (insufficient scope). The token was accepted and other tools may still work. Grant read_user (classic) or User:Read (fine-grained) for whoami, my_issues, and version metadata.";
1402
+ function isGitLabUserApiRateLimitBody(errorBody) {
1403
+ return errorBody.includes("User API Key Rate limit exceeded");
1404
+ }
1405
+ function isGitLabInsufficientUserScopeBody(errorBody) {
1406
+ // Fine-grained PATs use insufficient_granular_scope, which does not contain insufficient_scope.
1407
+ return (errorBody.includes("insufficient_granular_scope") || errorBody.includes("insufficient_scope"));
1408
+ }
1409
+ async function isInsufficientUserScopeForbiddenAsync(response) {
1410
+ if (response.status !== 403) {
1411
+ return false;
1412
+ }
1413
+ const errorBody = await response.text();
1414
+ return isGitLabInsufficientUserScopeBody(errorBody);
1415
+ }
1416
+ async function throwUserApiScopeErrorUnlessRateLimitedAsync(response) {
1417
+ const errorBody = await response.clone().text();
1418
+ // Rate-limit and other 403s keep the upstream body via handleGitLabError.
1419
+ if (isGitLabUserApiRateLimitBody(errorBody) || !isGitLabInsufficientUserScopeBody(errorBody)) {
1420
+ return;
1421
+ }
1422
+ throw new GitLabApiError(USER_API_INSUFFICIENT_SCOPE_MESSAGE);
1423
+ }
1424
+ async function authenticateWithJobTokenFallbackAsync(response) {
1425
+ if (response.ok ||
1426
+ (response.status !== 401 && response.status !== 403) ||
1427
+ !(GITLAB_JOB_TOKEN || usesJobTokenHeader())) {
1428
+ return false;
1429
+ }
1430
+ const jobResponse = await fetch(`${getEffectiveApiUrl()}/job`, getFetchConfig());
1431
+ return jobResponse.ok;
1432
+ }
1349
1433
  /**
1350
1434
  * @param {string} projectId - The project ID parameter passed to the function
1351
1435
  * @returns {string} The project ID to use for the API call
1352
- * @throws {Error} If GITLAB_ALLOWED_PROJECT_IDS is set and the requested project is not in the whitelist
1436
+ * @throws {ProjectAccessDeniedError} If the requested project is outside the effective allowlist
1437
+ * @throws {Error} If no project can be selected, or multiple allowed projects require an explicit ID
1353
1438
  */
1354
1439
  function getEffectiveProjectId(projectId) {
1355
1440
  const allowedProjectIds = getEffectiveAllowedProjectIds();
@@ -1360,7 +1445,7 @@ function getEffectiveProjectId(projectId) {
1360
1445
  }
1361
1446
  // If a project ID is provided, check if it's in the whitelist
1362
1447
  if (projectId && !allowedProjectIds.includes(projectId)) {
1363
- throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
1448
+ throw new ProjectAccessDeniedError(`Access denied: Project ${projectId} is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
1364
1449
  }
1365
1450
  // If no project ID provided but we have multiple allowed projects, require an explicit choice
1366
1451
  if (!projectId && allowedProjectIds.length > 1) {
@@ -3793,7 +3878,7 @@ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
3793
3878
  url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/merge_requests?source_branch=${encodeURIComponent(branchName)}`);
3794
3879
  }
3795
3880
  else {
3796
- throw new Error("Either mergeRequestIid or branchName must be provided");
3881
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
3797
3882
  }
3798
3883
  const response = await fetch(url.toString(), {
3799
3884
  ...getFetchConfig(),
@@ -4027,7 +4112,7 @@ async function buildMergeRequestDeploymentSummary(projectId, mergeRequest) {
4027
4112
  async function getMergeRequestDiffs(projectId, mergeRequestIid, branchName, view) {
4028
4113
  projectId = decodeURIComponent(projectId); // Decode project ID
4029
4114
  if (!mergeRequestIid && !branchName) {
4030
- throw new Error("Either mergeRequestIid or branchName must be provided");
4115
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4031
4116
  }
4032
4117
  if (branchName && !mergeRequestIid) {
4033
4118
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4057,7 +4142,7 @@ async function getMergeRequestDiffs(projectId, mergeRequestIid, branchName, view
4057
4142
  async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, page, perPage, unidiff) {
4058
4143
  projectId = decodeURIComponent(projectId); // Decode project ID
4059
4144
  if (!mergeRequestIid && !branchName) {
4060
- throw new Error("Either mergeRequestIid or branchName must be provided");
4145
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4061
4146
  }
4062
4147
  if (branchName && !mergeRequestIid) {
4063
4148
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4093,7 +4178,7 @@ async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, pag
4093
4178
  async function listMergeRequestChangedFiles(projectId, mergeRequestIid, branchName, excludedFilePatterns) {
4094
4179
  projectId = decodeURIComponent(projectId);
4095
4180
  if (!mergeRequestIid && !branchName) {
4096
- throw new Error("Either mergeRequestIid or branchName must be provided");
4181
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4097
4182
  }
4098
4183
  if (branchName && !mergeRequestIid) {
4099
4184
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4127,7 +4212,7 @@ async function listMergeRequestChangedFiles(projectId, mergeRequestIid, branchNa
4127
4212
  async function getMergeRequestFileDiff(projectId, filePaths, mergeRequestIid, branchName, unidiff) {
4128
4213
  projectId = decodeURIComponent(projectId);
4129
4214
  if (!mergeRequestIid && !branchName) {
4130
- throw new Error("Either mergeRequestIid or branchName must be provided");
4215
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4131
4216
  }
4132
4217
  if (branchName && !mergeRequestIid) {
4133
4218
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4212,7 +4297,7 @@ async function getBranchDiffs(projectId, from, to, straight) {
4212
4297
  async function updateMergeRequest(projectId, options, mergeRequestIid, branchName) {
4213
4298
  projectId = decodeURIComponent(projectId); // Decode project ID
4214
4299
  if (!mergeRequestIid && !branchName) {
4215
- throw new Error("Either mergeRequestIid or branchName must be provided");
4300
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4216
4301
  }
4217
4302
  if (branchName && !mergeRequestIid) {
4218
4303
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4603,6 +4688,28 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
4603
4688
  };
4604
4689
  }
4605
4690
  }
4691
+ function healthCheckTransportErrorResult(error) {
4692
+ const failure = classifyHealthCheckTransportError(error);
4693
+ return {
4694
+ content: [
4695
+ {
4696
+ type: "text",
4697
+ text: JSON.stringify({
4698
+ status: "error",
4699
+ authenticated: false,
4700
+ gitlab_url: redactGitLabUrlCredentials(getEffectiveApiUrl()),
4701
+ mcp_server_version: SERVER_VERSION,
4702
+ error: {
4703
+ kind: failure.kind,
4704
+ code: failure.code,
4705
+ message: failure.message,
4706
+ ...(failure.hint !== undefined ? { hint: failure.hint } : {}),
4707
+ },
4708
+ }),
4709
+ },
4710
+ ],
4711
+ };
4712
+ }
4606
4713
  async function fetchGitLabVersionMetadata() {
4607
4714
  try {
4608
4715
  const response = await fetch(`${getEffectiveApiUrl()}/version`, {
@@ -5402,6 +5509,132 @@ async function getEnvironment(projectId, environmentId) {
5402
5509
  const data = await response.json();
5403
5510
  return GitLabEnvironmentSchema.parse(data);
5404
5511
  }
5512
+ async function listProjectRunners(projectId, options) {
5513
+ const decodedProjectId = decodeURIComponent(projectId);
5514
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(decodedProjectId))}/runners`);
5515
+ appendFilterParams(url.searchParams, options);
5516
+ const response = await fetch(url.toString(), {
5517
+ ...getFetchConfig(),
5518
+ });
5519
+ await handleGitLabError(response);
5520
+ return redactSensitiveGitLabFields(await response.json());
5521
+ }
5522
+ const PROJECT_RUNNER_LOOKUP_PAGE_SIZE = 100;
5523
+ const MAX_PROJECT_RUNNER_LOOKUP_PAGES = 100;
5524
+ function isJsonObject(value) {
5525
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5526
+ }
5527
+ function runnerEntryId(entry) {
5528
+ if (!isJsonObject(entry)) {
5529
+ return undefined;
5530
+ }
5531
+ const id = entry.id;
5532
+ if (typeof id === "number" && Number.isFinite(id)) {
5533
+ return String(id);
5534
+ }
5535
+ if (typeof id === "string" && id !== "") {
5536
+ return id;
5537
+ }
5538
+ return undefined;
5539
+ }
5540
+ function projectIsInAllowlist(project, allowedProjectIds) {
5541
+ if (!isJsonObject(project)) {
5542
+ return false;
5543
+ }
5544
+ const id = project.id;
5545
+ const pathWithNamespace = project.path_with_namespace;
5546
+ const idText = typeof id === "number" && Number.isFinite(id)
5547
+ ? String(id)
5548
+ : typeof id === "string"
5549
+ ? id
5550
+ : undefined;
5551
+ return ((idText !== undefined && allowedProjectIds.includes(idText)) ||
5552
+ (typeof pathWithNamespace === "string" && allowedProjectIds.includes(pathWithNamespace)));
5553
+ }
5554
+ /**
5555
+ * GET /runners/:id is not project-scoped. Instance runner details are visible to
5556
+ * any authenticated user and can list projects outside GITLAB_ALLOWED_PROJECT_IDS.
5557
+ * Keep only projects on the effective allowlist, and drop groups because a group
5558
+ * path cannot be checked against a project allowlist.
5559
+ */
5560
+ function restrictRunnerDetailsToAllowlist(runner, allowedProjectIds) {
5561
+ if (!isJsonObject(runner)) {
5562
+ return runner;
5563
+ }
5564
+ const restricted = {};
5565
+ for (const [key, value] of Object.entries(runner)) {
5566
+ if (key === "groups") {
5567
+ continue;
5568
+ }
5569
+ if (key === "projects" && Array.isArray(value)) {
5570
+ restricted.projects = value.filter(project => projectIsInAllowlist(project, allowedProjectIds));
5571
+ continue;
5572
+ }
5573
+ restricted[key] = value;
5574
+ }
5575
+ return restricted;
5576
+ }
5577
+ /**
5578
+ * Confirm the runner is among the runners available to an allowed project.
5579
+ * GitLab has no single-runner project read; membership is the paginated
5580
+ * GET /projects/:id/runners list, which includes ancestor group runners and
5581
+ * allowed instance runners. Fail closed when the list cannot be fully scanned.
5582
+ */
5583
+ async function assertRunnerAvailableToProject(projectId, runnerId) {
5584
+ const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(projectId));
5585
+ let page = 1;
5586
+ while (page <= MAX_PROJECT_RUNNER_LOOKUP_PAGES) {
5587
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/runners`);
5588
+ url.searchParams.set("per_page", String(PROJECT_RUNNER_LOOKUP_PAGE_SIZE));
5589
+ url.searchParams.set("page", String(page));
5590
+ const response = await fetch(url.toString(), {
5591
+ ...getFetchConfig(),
5592
+ });
5593
+ await handleGitLabError(response);
5594
+ const data = await response.json();
5595
+ if (!Array.isArray(data)) {
5596
+ throw new GitLabApiError(`Could not verify runner ${runnerId} against project ${effectiveProjectId}`);
5597
+ }
5598
+ if (data.some(entry => runnerEntryId(entry) === runnerId)) {
5599
+ return;
5600
+ }
5601
+ const nextPageHeader = response.headers.get("x-next-page");
5602
+ if (!nextPageHeader) {
5603
+ throw new GitLabApiError("Runner not found");
5604
+ }
5605
+ const nextPage = Number.parseInt(nextPageHeader, 10);
5606
+ if (!Number.isFinite(nextPage) || nextPage <= page) {
5607
+ throw new ProjectAccessDeniedError(`Access denied: Runner ${runnerId} could not be verified against project ${effectiveProjectId}`);
5608
+ }
5609
+ page = nextPage;
5610
+ }
5611
+ throw new ProjectAccessDeniedError(`Access denied: Runner ${runnerId} could not be verified against project ${effectiveProjectId}`);
5612
+ }
5613
+ async function getRunner(runnerId, projectId) {
5614
+ const allowedProjectIds = getEffectiveAllowedProjectIds();
5615
+ const decodedProjectId = projectId === undefined || projectId.trim() === "" ? undefined : decodeURIComponent(projectId);
5616
+ // GITLAB_PROJECT_ID is the default project, same as getEffectiveProjectId.
5617
+ // It is not an allowlist, so runner details stay unfiltered unless
5618
+ // GITLAB_ALLOWED_PROJECT_IDS is set.
5619
+ if (allowedProjectIds.length > 0 ||
5620
+ decodedProjectId !== undefined ||
5621
+ Boolean(GITLAB_PROJECT_ID)) {
5622
+ await assertRunnerAvailableToProject(getEffectiveProjectId(decodedProjectId ?? ""), runnerId);
5623
+ }
5624
+ const url = new URL(`${getEffectiveApiUrl()}/runners/${encodeGitLabPathSegment(runnerId)}`);
5625
+ const response = await fetch(url.toString(), {
5626
+ ...getFetchConfig(),
5627
+ });
5628
+ if (response.status === 404) {
5629
+ throw new GitLabApiError("Runner not found");
5630
+ }
5631
+ await handleGitLabError(response);
5632
+ const details = redactSensitiveGitLabFields(await response.json());
5633
+ if (allowedProjectIds.length === 0) {
5634
+ return details;
5635
+ }
5636
+ return restrictRunnerDetailsToAllowlist(details, allowedProjectIds);
5637
+ }
5405
5638
  async function environmentRequest(projectId, path, method, body, query) {
5406
5639
  projectId = decodeURIComponent(projectId);
5407
5640
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/environments${path}`);
@@ -6593,6 +6826,9 @@ async function getCurrentUser() {
6593
6826
  }
6594
6827
  }
6595
6828
  }
6829
+ if (response.status === 403) {
6830
+ await throwUserApiScopeErrorUnlessRateLimitedAsync(response);
6831
+ }
6596
6832
  await handleGitLabError(response);
6597
6833
  throw new Error(`GitLab API error: ${response.status} ${response.statusText}`);
6598
6834
  }
@@ -6996,7 +7232,7 @@ function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
6996
7232
  const allowed = (fullPath !== undefined && allowedProjectIds.includes(fullPath)) ||
6997
7233
  (numericId !== undefined && allowedProjectIds.includes(numericId));
6998
7234
  if (!allowed) {
6999
- throw new Error(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
7235
+ throw new ProjectAccessDeniedError(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
7000
7236
  }
7001
7237
  }
7002
7238
  /**
@@ -7820,7 +8056,7 @@ async function handleToolCall(params) {
7820
8056
  const controller = new AbortController();
7821
8057
  const timeoutMs = 45000;
7822
8058
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
7823
- logger.info({ endpoint: graphqlUrl }, "execute_graphql request");
8059
+ logger.info({ endpoint: redactUrlSecretsInText(graphqlUrl) }, "execute_graphql request");
7824
8060
  try {
7825
8061
  const response = await fetch(graphqlUrl, {
7826
8062
  ...getFetchConfig(),
@@ -8512,6 +8748,9 @@ async function handleToolCall(params) {
8512
8748
  const response = await fetch(url.toString(), {
8513
8749
  ...getFetchConfig(),
8514
8750
  });
8751
+ if (response.status === 403) {
8752
+ await throwUserApiScopeErrorUnlessRateLimitedAsync(response);
8753
+ }
8515
8754
  await handleGitLabError(response);
8516
8755
  const data = await response.json();
8517
8756
  const user = GitLabCurrentUserSchema.parse(data);
@@ -9240,6 +9479,20 @@ async function handleToolCall(params) {
9240
9479
  content: [{ type: "text", text: JSON.stringify(environment) }],
9241
9480
  };
9242
9481
  }
9482
+ case "list_project_runners": {
9483
+ const { project_id, ...options } = ListProjectRunnersSchema.parse(params.arguments);
9484
+ const runners = await listProjectRunners(project_id, options);
9485
+ return {
9486
+ content: [{ type: "text", text: JSON.stringify(runners) }],
9487
+ };
9488
+ }
9489
+ case "get_runner": {
9490
+ const { runner_id, project_id } = GetRunnerSchema.parse(params.arguments);
9491
+ const runner = await getRunner(runner_id, project_id);
9492
+ return {
9493
+ content: [{ type: "text", text: JSON.stringify(runner) }],
9494
+ };
9495
+ }
9243
9496
  case "update_environment": {
9244
9497
  const { project_id, environment_id, ...body } = UpdateEnvironmentSchema.parse(params.arguments);
9245
9498
  return {
@@ -10472,6 +10725,77 @@ async function handleToolCall(params) {
10472
10725
  content: [{ type: "text", text: JSON.stringify(signature) }],
10473
10726
  };
10474
10727
  }
10728
+ case "list_protected_tags": {
10729
+ const args = ListProtectedTagsSchema.parse(params.arguments);
10730
+ const projectId = decodeURIComponent(args.project_id);
10731
+ const effectiveProjectId = getEffectiveProjectId(projectId);
10732
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/protected_tags`);
10733
+ if (args.page)
10734
+ url.searchParams.append("page", String(args.page));
10735
+ if (args.per_page)
10736
+ url.searchParams.append("per_page", String(args.per_page));
10737
+ const response = await fetch(url.toString(), {
10738
+ ...getFetchConfig(),
10739
+ });
10740
+ await handleGitLabError(response);
10741
+ const data = z.array(GitLabProtectedTagSchema).parse(await response.json());
10742
+ return {
10743
+ content: [{ type: "text", text: JSON.stringify(data) }],
10744
+ };
10745
+ }
10746
+ case "get_protected_tag": {
10747
+ const args = GetProtectedTagSchema.parse(params.arguments);
10748
+ const projectId = decodeURIComponent(args.project_id);
10749
+ const effectiveProjectId = getEffectiveProjectId(projectId);
10750
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/protected_tags/${encodeGitLabPathSegment(args.tag_name)}`);
10751
+ const response = await fetch(url.toString(), {
10752
+ ...getFetchConfig(),
10753
+ });
10754
+ await handleGitLabError(response);
10755
+ const data = GitLabProtectedTagSchema.parse(await response.json());
10756
+ return {
10757
+ content: [{ type: "text", text: JSON.stringify(data) }],
10758
+ };
10759
+ }
10760
+ case "protect_tag": {
10761
+ const args = ProtectTagSchema.parse(params.arguments);
10762
+ const projectId = decodeURIComponent(args.project_id);
10763
+ const effectiveProjectId = getEffectiveProjectId(projectId);
10764
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/protected_tags`);
10765
+ const body = { name: args.tag_name };
10766
+ if (args.create_access_level !== undefined) {
10767
+ body.create_access_level = args.create_access_level;
10768
+ }
10769
+ const response = await fetch(url.toString(), {
10770
+ ...getFetchConfig(),
10771
+ method: "POST",
10772
+ body: JSON.stringify(body),
10773
+ });
10774
+ await handleGitLabError(response);
10775
+ const data = GitLabProtectedTagSchema.parse(await response.json());
10776
+ return {
10777
+ content: [{ type: "text", text: JSON.stringify(data) }],
10778
+ };
10779
+ }
10780
+ case "unprotect_tag": {
10781
+ const args = UnprotectTagSchema.parse(params.arguments);
10782
+ const projectId = decodeURIComponent(args.project_id);
10783
+ const effectiveProjectId = getEffectiveProjectId(projectId);
10784
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/protected_tags/${encodeGitLabPathSegment(args.tag_name)}`);
10785
+ const response = await fetch(url.toString(), {
10786
+ ...getFetchConfig(),
10787
+ method: "DELETE",
10788
+ });
10789
+ await handleGitLabError(response);
10790
+ return {
10791
+ content: [
10792
+ {
10793
+ type: "text",
10794
+ text: JSON.stringify({ status: "unprotected", tag: args.tag_name }),
10795
+ },
10796
+ ],
10797
+ };
10798
+ }
10475
10799
  case "list_snippets": {
10476
10800
  const args = ListSnippetsSchema.parse(params.arguments);
10477
10801
  const { project_id, ...options } = args;
@@ -10604,16 +10928,27 @@ async function handleToolCall(params) {
10604
10928
  case "health_check": {
10605
10929
  HealthCheckSchema.parse(params.arguments ?? {});
10606
10930
  const url = new URL(`${getEffectiveApiUrl()}/user`);
10607
- const response = await fetch(url.toString(), getFetchConfig());
10608
- let authenticated = response.ok;
10609
- if (!authenticated &&
10610
- (response.status === 401 || response.status === 403) &&
10611
- (GITLAB_JOB_TOKEN || usesJobTokenHeader())) {
10612
- const jobUrl = new URL(`${getEffectiveApiUrl()}/job`);
10613
- const jobResponse = await fetch(jobUrl.toString(), getFetchConfig());
10614
- authenticated = jobResponse.ok;
10931
+ const fetchConfig = getFetchConfig();
10932
+ let response;
10933
+ try {
10934
+ response = await fetch(url.toString(), fetchConfig);
10615
10935
  }
10616
- const versionMetadata = authenticated ? await fetchGitLabVersionMetadata() : null;
10936
+ catch (error) {
10937
+ return healthCheckTransportErrorResult(error);
10938
+ }
10939
+ let jobAuthenticated;
10940
+ try {
10941
+ jobAuthenticated = await authenticateWithJobTokenFallbackAsync(response);
10942
+ }
10943
+ catch (error) {
10944
+ return healthCheckTransportErrorResult(error);
10945
+ }
10946
+ // insufficient_scope and insufficient_granular_scope mean the token was accepted
10947
+ // but User API scope is missing.
10948
+ // Rate-limit and authentication-ban 403s stay unauthenticated.
10949
+ const insufficientUserScope = !jobAuthenticated && (await isInsufficientUserScopeForbiddenAsync(response));
10950
+ const authenticated = response.ok || jobAuthenticated || insufficientUserScope;
10951
+ const versionMetadata = authenticated && !insufficientUserScope ? await fetchGitLabVersionMetadata() : null;
10617
10952
  return {
10618
10953
  content: [
10619
10954
  {
@@ -10621,8 +10956,9 @@ async function handleToolCall(params) {
10621
10956
  text: JSON.stringify({
10622
10957
  status: authenticated ? "ok" : "error",
10623
10958
  authenticated,
10624
- gitlab_url: getEffectiveApiUrl(),
10959
+ gitlab_url: redactGitLabUrlCredentials(getEffectiveApiUrl()),
10625
10960
  mcp_server_version: SERVER_VERSION,
10961
+ ...(insufficientUserScope ? { user_api_warning: USER_API_HEALTH_WARNING } : {}),
10626
10962
  ...(versionMetadata ?? {}),
10627
10963
  }),
10628
10964
  },
@@ -11153,7 +11489,7 @@ async function startStreamableHTTPServer() {
11153
11489
  }
11154
11490
  catch (error) {
11155
11491
  const reason = error instanceof Error ? error.message : "Invalid X-GitLab-API-URL";
11156
- logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. ${reason}`);
11492
+ logger.warn(`Invalid X-GitLab-API-URL provided: ${redactUrlSecretsInText(dynamicApiUrl)}. ${reason}`);
11157
11493
  return null; // Reject if URL is malformed or not allowed
11158
11494
  }
11159
11495
  }
@@ -12205,7 +12541,7 @@ function initOAuthClient() {
12205
12541
  return;
12206
12542
  }
12207
12543
  if (isCleartextRemoteUrl(GITLAB_API_URL)) {
12208
- throw new Error(`Refusing to send OAuth tokens over cleartext HTTP to ${GITLAB_API_URL}. ` +
12544
+ throw new Error(`Refusing to send OAuth tokens over cleartext HTTP to ${redactUrlSecretsInText(GITLAB_API_URL)}. ` +
12209
12545
  "Use an https:// GITLAB_API_URL (http:// is only allowed for localhost).");
12210
12546
  }
12211
12547
  oauthClient = createGitLabOAuthClient(GITLAB_API_URL.replace(/\/api\/v4$/, ""));
@@ -12216,6 +12552,9 @@ function initOAuthClient() {
12216
12552
  */
12217
12553
  async function runServer() {
12218
12554
  try {
12555
+ // Print before validateConfiguration(), which process.exit()s on failure.
12556
+ // logger.warn goes to stderr; stdio transport owns stdout for JSON-RPC.
12557
+ emitDeprecatedEnvWarnings();
12219
12558
  // Validate configuration before starting server
12220
12559
  validateConfiguration();
12221
12560
  // Initialize OAuth token if OAuth is enabled
@@ -12227,7 +12566,7 @@ async function runServer() {
12227
12566
  }
12228
12567
  catch (error) {
12229
12568
  logger.error({ err: error }, "OAuth authentication failed");
12230
- process.exit(1);
12569
+ exitAfterFlush(1);
12231
12570
  }
12232
12571
  }
12233
12572
  const transportMode = determineTransportMode();
@@ -12242,35 +12581,26 @@ async function runServer() {
12242
12581
  }
12243
12582
  });
12244
12583
  }
12245
- logger.info(`Configured GitLab API URLs: ${GITLAB_API_URLS.join(", ")}`);
12246
- logger.info(`Default GitLab API URL: ${GITLAB_API_URL}`);
12247
- for (const warning of getDeprecatedEnvWarnings({
12248
- readOnlyMode: GITLAB_READ_ONLY_MODE,
12249
- permissionModeRaw: GITLAB_PERMISSION_MODE_RAW,
12250
- allowedGroupsRaw: GITLAB_ALLOWED_GROUPS_RAW,
12251
- oauthAllowedGroupsRaw: GITLAB_OAUTH_ALLOWED_GROUPS_RAW,
12252
- useWikiRaw: USE_GITLAB_WIKI_RAW,
12253
- useMilestoneRaw: USE_MILESTONE_RAW,
12254
- usePipelineRaw: USE_PIPELINE_RAW,
12255
- })) {
12256
- logger.warn(warning);
12257
- }
12584
+ logger.info(`Configured GitLab API URLs: ${GITLAB_API_URLS.map(redactUrlSecretsInText).join(", ")}`);
12585
+ logger.info(`Default GitLab API URL: ${redactUrlSecretsInText(GITLAB_API_URL)}`);
12258
12586
  if (GITLAB_OAUTH_ALLOWED_GROUPS) {
12259
12587
  logger.info(`Group access control enabled for: ${GITLAB_OAUTH_ALLOWED_GROUPS.join(", ")}`);
12260
12588
  }
12261
12589
  }
12262
12590
  catch (error) {
12263
12591
  logger.error({ err: error }, "Error initializing server");
12264
- process.exit(1);
12592
+ exitAfterFlush(1);
12265
12593
  }
12266
12594
  }
12267
12595
  async function main() {
12268
12596
  const cli = resolveCli(process.argv);
12269
12597
  if (cli.kind === "help") {
12598
+ emitDeprecatedEnvWarnings();
12270
12599
  process.stdout.write(cli.text);
12271
12600
  process.exit(0);
12272
12601
  }
12273
12602
  if (cli.kind === "usage" || cli.kind === "refused") {
12603
+ emitDeprecatedEnvWarnings();
12274
12604
  process.stderr.write(`${cli.message}\n`);
12275
12605
  process.exit(2);
12276
12606
  }
@@ -12283,10 +12613,11 @@ async function main() {
12283
12613
  const message = error instanceof Error ? error.message : String(error);
12284
12614
  process.stderr.write(`${message}\n`);
12285
12615
  logger.error({ err: error }, "auth command failed");
12286
- process.exit(1);
12616
+ exitAfterFlush(1);
12287
12617
  }
12288
12618
  }
12289
12619
  if (cli.kind === "run") {
12620
+ emitDeprecatedEnvWarnings();
12290
12621
  if (!hasCliCredentials()) {
12291
12622
  process.stderr.write("Missing GitLab credentials. Set --token or GITLAB_PERSONAL_ACCESS_TOKEN, or run `auth` with GITLAB_USE_OAUTH=true.\n");
12292
12623
  process.exit(2);
@@ -12309,7 +12640,7 @@ async function main() {
12309
12640
  catch (error) {
12310
12641
  process.stderr.write(formatCliError(error));
12311
12642
  logger.error({ err: error }, "cli command failed");
12312
- process.exit(1);
12643
+ exitAfterFlush(1);
12313
12644
  }
12314
12645
  }
12315
12646
  await runServer();
@@ -12336,5 +12667,5 @@ function maskCliToolResult(toolName, result) {
12336
12667
  }
12337
12668
  main().catch(error => {
12338
12669
  logger.fatal({ err: error }, "Fatal error in main()");
12339
- process.exit(1);
12670
+ exitAfterFlush(1);
12340
12671
  });