@zereight/mcp-gitlab 2.2.6 → 2.3.0

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_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";
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_OAUTH_ALLOWED_REDIRECT_URIS_RAW, 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, GITLAB_DOWNLOAD_DIR, 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;
@@ -81,6 +81,7 @@ import { formatCliError, formatToolOutput } from "./cli/output.js";
81
81
  import { compactMcpToolResult, readOwnRecord } from "./cli/compact-mcp-result.js";
82
82
  import { compileDeniedToolsRegex } from "./tools/denied-regex.js";
83
83
  import { createGitLabOAuthProvider } from "./oauth-proxy.js";
84
+ import { parseAllowedRedirectUris, RedirectUriPolicy } from "./oauth-consent.js";
84
85
  import { normalizeProxyClientIpForRateLimit } from "./utils/proxy-client-ip.js";
85
86
  import { getForwardedPublicBaseUrl, getForwardedRequestHost, } from "./utils/forwarded-public-base-url.js";
86
87
  import { registerDownloadProxy } from "./downloads/proxy.js";
@@ -92,7 +93,7 @@ import { SERVER_VERSION } from "./server/version.js";
92
93
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
93
94
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
94
95
  import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
95
- import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
96
+ import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, resolveDownloadRoot, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
96
97
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
97
98
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
98
99
  import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
@@ -112,7 +113,7 @@ import { GitLabClientPool } from "./gitlab-client-pool.js";
112
113
  import { advertisesReadOnly, allTools, readOnlyTools, destructiveTools, deleteTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, findSmallestToolsetForTool, isExplicitOnlyToolsetId, isExplicitToolsetOnlyTool, isToolsetFullyActive, listDiscoverableCategories, TOOLSET_DEFINITIONS, DISCOVERABLE_TOOLSET_IDS, } from "./tools/registry.js";
113
114
  import { executeGitLabApiRequestAsync, GitLabApiRequestHttpError, GitLabApiRequestInputError, } from "./tools/gitlab-api-request.js";
114
115
  import { buildUnavailableToolMessage, isSkippedByDiscoverActivation, } from "./tools/discover-activation.js";
115
- import { selectExposedTools } from "./tools/exposed-tools.js";
116
+ import { selectCallableHiddenToolNames, selectExposedTools } from "./tools/exposed-tools.js";
116
117
  import { createListedToolsCache, decorateToolsForList, } from "./tools/listed-tools.js";
117
118
  import { isExcludedBySlimProfile, shouldApplySlimToolProfile } from "./tools/tool-profile.js";
118
119
  import { shouldCompactToolResult } from "./utils/compact-tool-allowlist.js";
@@ -123,8 +124,8 @@ GitLabDiscussionNoteSchema, // Added
123
124
  GitLabDiscussionSchema,
124
125
  // Draft Notes Schemas
125
126
  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
126
- 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";
127
- import { randomUUID, timingSafeEqual, } from "node:crypto";
127
+ 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, RebaseMergeRequestSchema, 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";
128
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
128
129
  import { createLogger, flushBufferedLogs, flushStartupLogs } from "./utils/logger.js";
129
130
  const logger = createLogger();
130
131
  function exitAfterFlush(code) {
@@ -210,17 +211,20 @@ function buildToolUnavailableMessage(toolName) {
210
211
  */
211
212
  /** Builds an MCP server with the tool list filtered by toolsets, permission mode, and policy. */
212
213
  function createServer() {
213
- const filteredTools = selectExposedTools({
214
+ const exposedToolSelection = {
214
215
  tools: allTools,
215
- isInEnabledToolset: toolName => isToolInEnabledToolset(toolName, enabledToolsets),
216
+ isInEnabledToolset: (toolName) => isToolInEnabledToolset(toolName, enabledToolsets),
216
217
  individuallyEnabledTools,
217
218
  featureFlagOverrides,
218
219
  isAllowedByPermissionMode: isToolAllowedByPermissionMode,
219
220
  deniedToolsRegex: GITLAB_DENIED_TOOLS_REGEX,
220
221
  hiddenToolNames: hiddenToolSet,
221
222
  applySlimProfile,
222
- isAdditiveEnablementAllowed: toolName => !isExplicitToolsetOnlyTool(toolName),
223
- });
223
+ isAdditiveEnablementAllowed: (toolName) => !isExplicitToolsetOnlyTool(toolName),
224
+ };
225
+ const filteredTools = selectExposedTools(exposedToolSelection);
226
+ // Unlisted but callable; extended by discover_tools alongside filteredTools.
227
+ const callableHiddenToolNames = selectCallableHiddenToolNames(exposedToolSelection);
224
228
  const listedToolDecoration = {
225
229
  readOnlyToolNames: new Set([...readOnlyTools].filter(toolName => advertisesReadOnly(toolName))),
226
230
  destructiveToolNames: destructiveTools,
@@ -369,12 +373,11 @@ function createServer() {
369
373
  return logCompletion({ content: [{ type: "text", text: jmespathSyntaxError }], isError: true }, { skipJmespath: true });
370
374
  }
371
375
  // Enforce list-time filtering on direct calls: clients can call names that tools/list omits.
372
- // filteredTools is extended by discover_tools; hidden-policy tools stay callable by design,
373
- // but GITLAB_DENIED_TOOLS_REGEX always wins over the hidden exemption.
376
+ // filteredTools is extended by discover_tools; hidden-policy tools stay callable only while
377
+ // the other list filters still expose them, so hiding a tool never enables it.
374
378
  // Permission-mode denials fall through so handleToolCall reports its mode-specific error.
375
- const hiddenAndNotDenied = hiddenToolSet.has(toolName) && !GITLAB_DENIED_TOOLS_REGEX?.test(toolName);
376
379
  if (isToolAllowedByPermissionMode(toolName) &&
377
- !hiddenAndNotDenied &&
380
+ !callableHiddenToolNames.has(toolName) &&
378
381
  !filteredTools.some(t => t.name === toolName)) {
379
382
  throw new Error(buildToolUnavailableMessage(toolName));
380
383
  }
@@ -382,12 +385,13 @@ function createServer() {
382
385
  if (toolName === "discover_tools") {
383
386
  const category = request.params.arguments?.category?.trim()?.toLowerCase();
384
387
  const currentToolNames = new Set(filteredTools.map(t => t.name));
385
- const isNotActivatable = (name) => isSkippedByDiscoverActivation({
388
+ const isActivationSkipped = (name, hidden) => isSkippedByDiscoverActivation({
386
389
  allowedByPermissionMode: isToolAllowedByPermissionMode(name),
387
390
  deniedByRegex: GITLAB_DENIED_TOOLS_REGEX?.test(name) === true,
388
- hidden: hiddenToolSet.has(name),
391
+ hidden,
389
392
  excludedBySlimProfile: isExcludedFromToolsetListing(name),
390
393
  });
394
+ const isNotActivatable = (name) => isActivationSkipped(name, hiddenToolSet.has(name));
391
395
  if (!category) {
392
396
  // Fully listed only. Partial overlap with `core` must stay inactive so
393
397
  // clients still call discover_tools to add the missing tools. Tools this
@@ -441,6 +445,13 @@ function createServer() {
441
445
  isError: true,
442
446
  });
443
447
  }
448
+ // Hidden tools in this toolset stay unlisted but become callable like the rest of it.
449
+ // Runs before the `active` check because hidden tools never keep a toolset inactive.
450
+ for (const name of toolsetDef.tools) {
451
+ if (hiddenToolSet.has(name) && !isActivationSkipped(name, false)) {
452
+ callableHiddenToolNames.add(name);
453
+ }
454
+ }
444
455
  // Same predicate as the category listing `active` flag.
445
456
  const alreadyActive = isToolsetFullyActive(toolsetDef.tools, currentToolNames, isNotActivatable);
446
457
  if (alreadyActive) {
@@ -550,20 +561,6 @@ function createServer() {
550
561
  });
551
562
  return mcpServer;
552
563
  }
553
- /**
554
- * Validate configuration at startup
555
- */
556
- function isLoopbackBindHost(host) {
557
- const normalized = host
558
- .trim()
559
- .toLowerCase()
560
- .replace(/^\[|\]$/g, "");
561
- const isIpv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized);
562
- return (normalized === "localhost" ||
563
- isIpv4Loopback ||
564
- normalized === "::1" ||
565
- normalized === "0:0:0:0:0:0:0:1");
566
- }
567
564
  function formatHostWithPort(host, port) {
568
565
  const normalized = host
569
566
  .trim()
@@ -814,6 +811,14 @@ function validateConfiguration() {
814
811
  errors.push("GITLAB_OAUTH_APP_ID is required when GITLAB_MCP_OAUTH=true " +
815
812
  "(create an OAuth application in GitLab Admin with the required scopes)");
816
813
  }
814
+ if (getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true") {
815
+ try {
816
+ new RedirectUriPolicy(parseAllowedRedirectUris(getConfig("oauth-allowed-redirect-uris", "GITLAB_OAUTH_ALLOWED_REDIRECT_URIS")));
817
+ }
818
+ catch (error) {
819
+ errors.push(error instanceof Error ? error.message : String(error));
820
+ }
821
+ }
817
822
  }
818
823
  const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
819
824
  if (enableDynamicApiUrl && !remoteAuth) {
@@ -1111,7 +1116,7 @@ function buildAuthHeaders() {
1111
1116
  if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
1112
1117
  const ctx = sessionAuthStore.getStore();
1113
1118
  logger.debug({
1114
- sessionId: ctx?.sessionId,
1119
+ sidPrefix: redactSessionIdForLog(ctx?.sessionId),
1115
1120
  header: ctx?.header,
1116
1121
  apiUrl: ctx?.apiUrl === undefined ? undefined : redactUrlSecretsInText(ctx.apiUrl),
1117
1122
  }, "buildAuthHeaders: session context");
@@ -1163,7 +1168,7 @@ function getEffectiveApiUrl() {
1163
1168
  return ctx.apiUrl;
1164
1169
  }
1165
1170
  logger.warn({
1166
- sessionId: ctx?.sessionId,
1171
+ sidPrefix: redactSessionIdForLog(ctx?.sessionId),
1167
1172
  header: ctx?.header,
1168
1173
  apiUrl: ctx?.apiUrl === undefined ? undefined : redactUrlSecretsInText(ctx.apiUrl),
1169
1174
  }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
@@ -2276,6 +2281,43 @@ async function createWorkItemNote(projectId, iid, body, options = {}) {
2276
2281
  return data.createNote.note;
2277
2282
  }
2278
2283
  // --- Emoji Reactions (GraphQL) ---
2284
+ const NOTE_GID_PATTERN = /^gid:\/\/gitlab\/\w*Note\/\d+$/;
2285
+ /**
2286
+ * Check that a note's noteable is the given work item. Work item notes may report
2287
+ * the noteable as an Issue, which shares the work item's numeric ID. GitLab.com
2288
+ * returns noteableId as a bare number; a global ID string is accepted too.
2289
+ */
2290
+ function isNoteOfWorkItem(note, workItemGID) {
2291
+ const workItemId = workItemGID.match(/\/(\d+)$/)?.[1];
2292
+ const noteable = String(note.noteableId ?? "").match(/^(?:gid:\/\/gitlab\/(\w+)\/)?(\d+)$/);
2293
+ if (workItemId === undefined || noteable?.[2] !== workItemId) {
2294
+ return false;
2295
+ }
2296
+ const types = [noteable[1], note.noteableType]
2297
+ .filter((type) => Boolean(type))
2298
+ .map(type => type.replace(/[^a-z]/gi, "").toLowerCase());
2299
+ return types.length > 0 && types.every(type => type === "issue" || type === "workitem");
2300
+ }
2301
+ /**
2302
+ * Resolve the work item and return the note GID only if the note belongs to it.
2303
+ */
2304
+ async function resolveWorkItemNoteGID(projectId, iid, noteId) {
2305
+ if (!NOTE_GID_PATTERN.test(noteId)) {
2306
+ throw new ToolInputError(`note_id must be a note global ID such as gid://gitlab/Note/123 (got ${noteId})`);
2307
+ }
2308
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, iid);
2309
+ const data = await executeGraphQL(`query($id: NoteID!) {
2310
+ note(id: $id) {
2311
+ id
2312
+ noteableId
2313
+ noteableType
2314
+ }
2315
+ }`, { id: noteId });
2316
+ if (!data.note || !isNoteOfWorkItem(data.note, workItemGID)) {
2317
+ throw new ProjectAccessDeniedError(`Access denied: Note ${noteId} was not found on work item #${iid} in ${projectPath}`);
2318
+ }
2319
+ return data.note.id;
2320
+ }
2279
2321
  async function addGraphQLAwardEmoji(awardableId, name) {
2280
2322
  const data = await executeGraphQL(`mutation($awardableId: AwardableID!, $name: String!) {
2281
2323
  awardEmojiAdd(input: { awardableId: $awardableId, name: $name }) {
@@ -3272,11 +3314,13 @@ async function createIssueLink(projectId, issueIid, targetProjectId, targetIssue
3272
3314
  projectId = decodeURIComponent(projectId); // Decode project ID
3273
3315
  targetProjectId = decodeURIComponent(targetProjectId); // Decode target project ID as well
3274
3316
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/issues/${encodeGitLabPathSegment(issueIid)}/links`);
3317
+ // The target project is subject to the same allowlist as the source project.
3318
+ const effectiveTargetProjectId = getEffectiveProjectId(targetProjectId);
3275
3319
  const response = await fetch(url.toString(), {
3276
3320
  ...getFetchConfig(),
3277
3321
  method: "POST",
3278
3322
  body: JSON.stringify({
3279
- target_project_id: targetProjectId,
3323
+ target_project_id: effectiveTargetProjectId,
3280
3324
  target_issue_iid: targetIssueIid,
3281
3325
  link_type: linkType,
3282
3326
  }),
@@ -3314,6 +3358,10 @@ async function deleteIssueLink(projectId, issueIid, issueLinkId) {
3314
3358
  async function createMergeRequest(projectId, options) {
3315
3359
  projectId = decodeURIComponent(projectId); // Decode project ID
3316
3360
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/merge_requests`);
3361
+ // A cross-project target is subject to the same allowlist as the source project.
3362
+ const targetProjectId = options.target_project_id
3363
+ ? getEffectiveProjectId(decodeURIComponent(options.target_project_id))
3364
+ : undefined;
3317
3365
  const response = await fetch(url.toString(), {
3318
3366
  ...getFetchConfig(),
3319
3367
  method: "POST",
@@ -3322,7 +3370,7 @@ async function createMergeRequest(projectId, options) {
3322
3370
  description: options.description,
3323
3371
  source_branch: options.source_branch,
3324
3372
  target_branch: options.target_branch,
3325
- target_project_id: options.target_project_id,
3373
+ target_project_id: targetProjectId,
3326
3374
  assignee_ids: options.assignee_ids,
3327
3375
  reviewer_ids: options.reviewer_ids,
3328
3376
  labels: options.labels?.join(","),
@@ -4429,6 +4477,51 @@ async function unapproveMergeRequest(projectId, mergeRequestIid) {
4429
4477
  await handleGitLabError(response);
4430
4478
  return parseApprovalsResponse(await response.json());
4431
4479
  }
4480
+ /**
4481
+ * Enqueue a rebase of the merge request source branch onto its target branch
4482
+ *
4483
+ * @param {string} projectId - The ID or URL-encoded path of the project
4484
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
4485
+ * @param {boolean} skipCi - If true, GitLab does not create a pipeline for the rebased branch
4486
+ * @returns {Promise<unknown>} GitLab's enqueue response, `{"rebase_in_progress": true}`
4487
+ */
4488
+ async function rebaseMergeRequest(projectId, mergeRequestIid, skipCi) {
4489
+ projectId = decodeURIComponent(projectId);
4490
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}/rebase`);
4491
+ const body = {};
4492
+ if (skipCi !== undefined) {
4493
+ body.skip_ci = skipCi;
4494
+ }
4495
+ const response = await fetch(url.toString(), {
4496
+ ...getFetchConfig(),
4497
+ method: "PUT",
4498
+ body: JSON.stringify(body),
4499
+ });
4500
+ await handleGitLabError(response);
4501
+ return response.json();
4502
+ }
4503
+ /**
4504
+ * Get a merge request with its rebase state, shaped for waitForStatus
4505
+ *
4506
+ * @param {string} projectId - The ID or URL-encoded path of the project
4507
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
4508
+ * @returns {Promise<{status: string, mergeRequest: GitLabMergeRequest}>} `in_progress` or `finished`
4509
+ */
4510
+ async function getMergeRequestRebaseStatus(projectId, mergeRequestIid, signal) {
4511
+ projectId = decodeURIComponent(projectId);
4512
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}`);
4513
+ url.searchParams.append("include_rebase_in_progress", "true");
4514
+ const response = await fetch(url.toString(), {
4515
+ ...getFetchConfig(),
4516
+ signal,
4517
+ });
4518
+ await handleGitLabError(response);
4519
+ const mergeRequest = GitLabMergeRequestSchema.parse(await response.json());
4520
+ return {
4521
+ status: mergeRequest.rebase_in_progress === false ? "finished" : "in_progress",
4522
+ mergeRequest,
4523
+ };
4524
+ }
4432
4525
  /**
4433
4526
  * Get the approval state of a merge request
4434
4527
  *
@@ -5896,7 +5989,8 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
5896
5989
  if (!response.body) {
5897
5990
  throw new Error("No response body from GitLab");
5898
5991
  }
5899
- const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(filename, localPath, "local_path");
5992
+ // The archive name is fixed, but local_path still comes from the model.
5993
+ const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(filename, localPath, "local_path", undefined, { rejectDotNames: true });
5900
5994
  await streamPipeline(response.body, saveStream);
5901
5995
  return savePath;
5902
5996
  }
@@ -6267,6 +6361,7 @@ async function erasePipelineJob(projectId, jobId) {
6267
6361
  }
6268
6362
  const TERMINAL_PIPELINE_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
6269
6363
  const TERMINAL_JOB_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
6364
+ const TERMINAL_REBASE_STATUSES = new Set(["finished"]);
6270
6365
  async function waitForStatus(fetchStatus, terminalStatuses, timeoutSeconds = 300, pollIntervalSeconds = 5) {
6271
6366
  const deadline = Date.now() + timeoutSeconds * 1000;
6272
6367
  const fetchBeforeDeadline = async () => {
@@ -6764,7 +6859,7 @@ async function getCommit(projectId, sha, stats) {
6764
6859
  async function getCommitDiff(projectId, sha, full_diff) {
6765
6860
  projectId = decodeURIComponent(projectId);
6766
6861
  const baseUrl = `${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/repository/commits/${encodeGitLabPathSegment(sha)}/diff`;
6767
- let allDiffs = [];
6862
+ const allDiffs = [];
6768
6863
  let page = 1;
6769
6864
  while (true) {
6770
6865
  const url = new URL(baseUrl);
@@ -6908,7 +7003,7 @@ async function myIssues(options = {}) {
6908
7003
  }
6909
7004
  }
6910
7005
  // Use listIssues with assignee_username filter
6911
- let listIssuesOptions = {
7006
+ const listIssuesOptions = {
6912
7007
  state: options.state || "opened", // Default to "opened" if not specified
6913
7008
  labels: options.labels,
6914
7009
  milestone: options.milestone,
@@ -7616,15 +7711,23 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
7616
7711
  await handleGitLabError(response);
7617
7712
  }
7618
7713
  const mimeType = getImageMimeType(safeFilename);
7619
- // For non-image files, always save to disk.
7620
- // For image files, only save to disk if local_path is explicitly provided.
7714
+ // Non-image files, and images with local_path, are saved as new files under the
7715
+ // download directory. Upload names and local_path come from untrusted content, so
7716
+ // dot names (.git, .vscode, .envrc) are refused and existing files are never replaced.
7621
7717
  if (!mimeType || localPath) {
7622
7718
  if (!response.body) {
7623
7719
  throw new Error("No response body from GitLab");
7624
7720
  }
7625
- const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(safeFilename, localPath, "local_path");
7721
+ const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(safeFilename, localPath, "local_path", resolveDownloadRoot(GITLAB_DOWNLOAD_DIR), { exclusive: true, rejectDotNames: true });
7626
7722
  // Stream directly to disk instead of buffering in memory
7627
- await streamPipeline(response.body, saveStream);
7723
+ try {
7724
+ await streamPipeline(response.body, saveStream);
7725
+ }
7726
+ catch (error) {
7727
+ // The file was created by this call; drop the partial copy so a retry can succeed.
7728
+ fs.rmSync(savePath, { force: true });
7729
+ throw error;
7730
+ }
7628
7731
  return { buffer: Buffer.alloc(0), filename: safeFilename, mimeType, savedPath: savePath };
7629
7732
  }
7630
7733
  // Images returned inline — buffer into memory for base64 encoding
@@ -8659,6 +8762,15 @@ async function handleToolCall(params) {
8659
8762
  content: [{ type: "text", text: JSON.stringify(approvalState) }],
8660
8763
  };
8661
8764
  }
8765
+ case "rebase_merge_request": {
8766
+ const { project_id, merge_request_iid, skip_ci, wait, timeout_seconds, poll_interval_seconds, } = RebaseMergeRequestSchema.parse(params.arguments);
8767
+ const enqueued = await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
8768
+ if (!wait) {
8769
+ return { content: [{ type: "text", text: JSON.stringify(enqueued) }] };
8770
+ }
8771
+ const { mergeRequest } = await waitForStatus(signal => getMergeRequestRebaseStatus(project_id, merge_request_iid, signal), TERMINAL_REBASE_STATUSES, timeout_seconds, poll_interval_seconds);
8772
+ return { content: [{ type: "text", text: JSON.stringify(mergeRequest) }] };
8773
+ }
8662
8774
  case "get_merge_request_approval_state": {
8663
8775
  const args = GetMergeRequestApprovalStateSchema.parse(params.arguments);
8664
8776
  const approvalState = await getMergeRequestApprovalState(args.project_id, args.merge_request_iid);
@@ -8683,7 +8795,7 @@ async function handleToolCall(params) {
8683
8795
  }
8684
8796
  case "list_namespaces": {
8685
8797
  const args = ListNamespacesSchema.parse(params.arguments);
8686
- const url = new URL(`${GITLAB_API_URL}/namespaces`);
8798
+ const url = new URL(`${getEffectiveApiUrl()}/namespaces`);
8687
8799
  if (args.search) {
8688
8800
  url.searchParams.append("search", args.search);
8689
8801
  }
@@ -8708,7 +8820,7 @@ async function handleToolCall(params) {
8708
8820
  }
8709
8821
  case "get_namespace": {
8710
8822
  const args = GetNamespaceSchema.parse(params.arguments);
8711
- const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeGitLabPathSegment(args.namespace_id)}`);
8823
+ const url = new URL(`${getEffectiveApiUrl()}/namespaces/${encodeGitLabPathSegment(args.namespace_id)}`);
8712
8824
  const response = await fetch(url.toString(), {
8713
8825
  ...getFetchConfig(),
8714
8826
  });
@@ -8721,7 +8833,7 @@ async function handleToolCall(params) {
8721
8833
  }
8722
8834
  case "verify_namespace": {
8723
8835
  const args = VerifyNamespaceSchema.parse(params.arguments);
8724
- const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeGitLabPathSegment(args.path)}/exists`);
8836
+ const url = new URL(`${getEffectiveApiUrl()}/namespaces/${encodeGitLabPathSegment(args.path)}/exists`);
8725
8837
  if (args.parent_id !== undefined)
8726
8838
  url.searchParams.set("parent_id", String(args.parent_id));
8727
8839
  const response = await fetch(url.toString(), {
@@ -9179,7 +9291,8 @@ async function handleToolCall(params) {
9179
9291
  }
9180
9292
  case "list_work_item_note_emoji_reactions": {
9181
9293
  const args = ListWorkItemNoteEmojiReactionsSchema.parse(params.arguments);
9182
- const result = await listGraphQLAwardEmoji(args.note_id);
9294
+ const noteGID = await resolveWorkItemNoteGID(args.project_id, args.iid, args.note_id);
9295
+ const result = await listGraphQLAwardEmoji(noteGID);
9183
9296
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
9184
9297
  }
9185
9298
  case "create_work_item_emoji_reaction": {
@@ -9203,12 +9316,14 @@ async function handleToolCall(params) {
9203
9316
  }
9204
9317
  case "create_work_item_note_emoji_reaction": {
9205
9318
  const args = CreateWorkItemNoteEmojiReactionSchema.parse(params.arguments);
9206
- const result = await addGraphQLAwardEmoji(args.note_id, args.name);
9319
+ const noteGID = await resolveWorkItemNoteGID(args.project_id, args.iid, args.note_id);
9320
+ const result = await addGraphQLAwardEmoji(noteGID, args.name);
9207
9321
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
9208
9322
  }
9209
9323
  case "delete_work_item_note_emoji_reaction": {
9210
9324
  const args = DeleteWorkItemNoteEmojiReactionSchema.parse(params.arguments);
9211
- const result = await removeGraphQLAwardEmoji(args.note_id, args.name);
9325
+ const noteGID = await resolveWorkItemNoteGID(args.project_id, args.iid, args.note_id);
9326
+ const result = await removeGraphQLAwardEmoji(noteGID, args.name);
9212
9327
  return {
9213
9328
  content: [
9214
9329
  {
@@ -10131,6 +10246,9 @@ async function handleToolCall(params) {
10131
10246
  ],
10132
10247
  };
10133
10248
  }
10249
+ if (GITLAB_PERMISSION_MODE === "readonly") {
10250
+ throw new Error("download_job_artifacts saves files to disk, which readonly mode does not allow. Use list_job_artifacts and get_job_artifact_file instead.");
10251
+ }
10134
10252
  const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
10135
10253
  return {
10136
10254
  content: [
@@ -10639,6 +10757,10 @@ async function handleToolCall(params) {
10639
10757
  ],
10640
10758
  };
10641
10759
  }
10760
+ // Readonly mode never writes to disk: images still come back inline.
10761
+ if (GITLAB_PERMISSION_MODE === "readonly" && (!mimeType || args.local_path)) {
10762
+ throw new Error("download_attachment saves this file to disk, which readonly mode does not allow. Only images without local_path can be downloaded in readonly mode.");
10763
+ }
10642
10764
  const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
10643
10765
  if (result.mimeType && !args.local_path) {
10644
10766
  // Return image inline as base64 so the AI can see it
@@ -11350,7 +11472,8 @@ async function startSSEServer() {
11350
11472
  const transport = transports[sessionId];
11351
11473
  delete transports[sessionId];
11352
11474
  sessionLastActivity.delete(sessionId);
11353
- logger.info({ sessionId, event: "sse_session_idle_closed" }, `SSE session ${sessionId} closed after ${SESSION_TIMEOUT_SECONDS}s of inactivity`);
11475
+ const sidPrefix = redactSessionIdForLog(sessionId);
11476
+ logger.info({ sidPrefix, event: "sse_session_idle_closed" }, `SSE session ${sidPrefix} closed after ${SESSION_TIMEOUT_SECONDS}s of inactivity`);
11354
11477
  if (transport) {
11355
11478
  void transport.close().catch(error => {
11356
11479
  logger.error({ err: error }, "Error closing idle SSE transport");
@@ -11386,7 +11509,7 @@ async function startSSEServer() {
11386
11509
  await serverInstance.connect(transport);
11387
11510
  }
11388
11511
  catch (error) {
11389
- logger.error({ err: error, sessionId }, "SSE connection error");
11512
+ logger.error({ err: error, sidPrefix: redactSessionIdForLog(sessionId) }, "SSE connection error");
11390
11513
  // Release the capacity slot immediately instead of waiting for the
11391
11514
  // client to disconnect or the idle sweep to run.
11392
11515
  delete transports[sessionId];
@@ -11489,6 +11612,8 @@ async function startStreamableHTTPServer() {
11489
11612
  };
11490
11613
  // Rate limiting per session
11491
11614
  const sessionRequestCounts = {};
11615
+ // MCP OAuth mode: the GitLab user each stateful session belongs to
11616
+ const sessionOwners = Object.create(null);
11492
11617
  /**
11493
11618
  * Validate token format and length
11494
11619
  */
@@ -11596,7 +11721,18 @@ async function startStreamableHTTPServer() {
11596
11721
  }
11597
11722
  return null;
11598
11723
  };
11599
- const validateAuthDataUpstream = async (authData) => {
11724
+ /**
11725
+ * Identify the GitLab user behind a credential. When GitLab does not report a
11726
+ * user id, fall back to a digest of the token so only that exact token matches.
11727
+ */
11728
+ const sessionOwnerKey = (apiUrl, userId, token) => typeof userId === "number" || typeof userId === "string"
11729
+ ? `${apiUrl.replace(/\/$/, "")}#${userId}`
11730
+ : `token:${createHash("sha256").update(token).digest("hex")}`;
11731
+ /**
11732
+ * Validate header auth against GitLab. Resolves to the session owner key of
11733
+ * the token, or null when GitLab rejects it.
11734
+ */
11735
+ const resolveAuthOwnerUpstream = async (authData) => {
11600
11736
  const apiUrl = authData.apiUrl.replace(/\/$/, "");
11601
11737
  const paths = authData.header === "JOB-TOKEN" ? ["user", "job"] : ["user"];
11602
11738
  try {
@@ -11612,19 +11748,66 @@ async function startStreamableHTTPServer() {
11612
11748
  },
11613
11749
  signal: controller.signal,
11614
11750
  });
11615
- if (response.ok)
11616
- return true;
11751
+ if (response.ok) {
11752
+ const body = (await response.json().catch(() => undefined));
11753
+ const userId = path === "job" ? body?.user?.id : body?.id;
11754
+ return sessionOwnerKey(apiUrl, userId, authData.token);
11755
+ }
11617
11756
  }
11618
11757
  finally {
11619
11758
  clearTimeout(timeout);
11620
11759
  }
11621
11760
  }
11622
- return false;
11761
+ return null;
11623
11762
  }
11624
11763
  catch (error) {
11625
11764
  logger.warn({ err: error }, "Remote auth token validation failed");
11626
- return false;
11765
+ return null;
11766
+ }
11767
+ };
11768
+ const validateAuthDataUpstream = async (authData) => (await resolveAuthOwnerUpstream(authData)) !== null;
11769
+ /**
11770
+ * With GITLAB_OAUTH_ALLOWED_GROUPS set in MCP OAuth mode, credentials that do
11771
+ * not pass through verifyAccessToken (Private-Token / JOB-TOKEN headers and
11772
+ * sealed stateless session ids) get the same group check, using the OAuth
11773
+ * provider's per-token membership cache. CI job tokens cannot list the
11774
+ * user's groups, so they are refused. Returns null when access is allowed.
11775
+ */
11776
+ const checkAllowedGroups = async (header, token) => {
11777
+ if (!GITLAB_MCP_OAUTH || !GITLAB_OAUTH_ALLOWED_GROUPS)
11778
+ return null;
11779
+ if (header === "JOB-TOKEN") {
11780
+ return {
11781
+ status: 403,
11782
+ body: {
11783
+ error: "JOB-TOKEN header not accepted",
11784
+ message: "GITLAB_OAUTH_ALLOWED_GROUPS is set and CI job tokens cannot be checked for group " +
11785
+ "membership. Use OAuth or a Private-Token header instead.",
11786
+ },
11787
+ };
11788
+ }
11789
+ const provider = app._mcpOAuthProvider;
11790
+ try {
11791
+ if (await provider.isAllowedGroupMember(token))
11792
+ return null;
11627
11793
  }
11794
+ catch (error) {
11795
+ logger.warn({ err: error }, "Group membership lookup failed");
11796
+ return {
11797
+ status: 503,
11798
+ body: {
11799
+ error: "Group membership check unavailable",
11800
+ message: "Could not verify group membership with GitLab. Please retry.",
11801
+ },
11802
+ };
11803
+ }
11804
+ return {
11805
+ status: 403,
11806
+ body: {
11807
+ error: "Access denied",
11808
+ message: "The token owner is not a member of an allowed group.",
11809
+ },
11810
+ };
11628
11811
  };
11629
11812
  const sessionAuthTokensMatch = (left, right) => left.header === right.header && left.token === right.token && left.apiUrl === right.apiUrl;
11630
11813
  const storeValidatedSessionAuth = async (targetSessionId, authData, publicBaseUrl, existing, options) => {
@@ -11656,15 +11839,15 @@ async function startStreamableHTTPServer() {
11656
11839
  const transport = streamableTransports[sessionId];
11657
11840
  if (authData || transport) {
11658
11841
  logger.info(authData
11659
- ? `Session ${sessionId}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`
11660
- : `Session ${sessionId}: unauthenticated discovery expired after ${SESSION_TIMEOUT_SECONDS}s`);
11842
+ ? `Session ${redactSessionIdForLog(sessionId)}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`
11843
+ : `Session ${redactSessionIdForLog(sessionId)}: unauthenticated discovery expired after ${SESSION_TIMEOUT_SECONDS}s`);
11661
11844
  delete authBySession[sessionId];
11662
11845
  delete authTimeouts[sessionId];
11663
11846
  metrics.expiredSessions++;
11664
11847
  // Close the transport to free the slot; without this, stale sessions accumulate and exhaust MAX_SESSIONS.
11665
11848
  if (transport) {
11666
11849
  transport.close().catch(err => {
11667
- logger.error(`Error closing transport for expired session ${sessionId}:`, err);
11850
+ logger.error(`Error closing transport for expired session ${redactSessionIdForLog(sessionId)}:`, err);
11668
11851
  });
11669
11852
  }
11670
11853
  }
@@ -11685,6 +11868,7 @@ async function startStreamableHTTPServer() {
11685
11868
  */
11686
11869
  const cleanupSessionAuth = (sessionId) => {
11687
11870
  delete authBySession[sessionId];
11871
+ delete sessionOwners[sessionId];
11688
11872
  clearAuthTimeout(sessionId);
11689
11873
  };
11690
11874
  /**
@@ -11813,6 +11997,15 @@ async function startStreamableHTTPServer() {
11813
11997
  });
11814
11998
  return;
11815
11999
  }
12000
+ // Sid-only requests never reach verifyAccessToken or the header check in
12001
+ // mcpBearerAuth, so the sealed credential is checked here (cached per token).
12002
+ const groupDenial = await checkAllowedGroups(effective.header, effective.token);
12003
+ if (groupDenial) {
12004
+ metrics.authFailures++;
12005
+ metrics.statelessAuthFailures++;
12006
+ res.status(groupDenial.status).json(groupDenial.body);
12007
+ return;
12008
+ }
11816
12009
  // The project scope sealed into the sid belongs to the session: live headers
11817
12010
  // may refresh the token but cannot drop or replace the scope.
11818
12011
  if (ENABLE_DYNAMIC_PROJECT_SCOPE && freshAuthPresent && !isInit && opened) {
@@ -11942,7 +12135,7 @@ async function startStreamableHTTPServer() {
11942
12135
  storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
11943
12136
  }
11944
12137
  : null;
11945
- const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_PERMISSION_MODE === "readonly", GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions);
12138
+ const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_PERMISSION_MODE === "readonly", GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions, parseAllowedRedirectUris(GITLAB_OAUTH_ALLOWED_REDIRECT_URIS_RAW));
11946
12139
  const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
11947
12140
  // When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
11948
12141
  // advertises root-level endpoints. Override to use path-prefixed endpoints.
@@ -12072,18 +12265,25 @@ async function startStreamableHTTPServer() {
12072
12265
  }
12073
12266
  const sessionId = readMcpSessionIdHeader(req);
12074
12267
  const cached = sessionId ? authBySession[sessionId] : undefined;
12075
- if (cached && sessionAuthTokensMatch(cached, authData)) {
12076
- next();
12268
+ const owner = sessionId && cached && sessionAuthTokensMatch(cached, authData)
12269
+ ? sessionOwners[sessionId]
12270
+ : await resolveAuthOwnerUpstream(authData);
12271
+ if (owner === null) {
12272
+ res.status(401).json({
12273
+ error: "Invalid Private-Token or JOB-TOKEN header",
12274
+ message: "The provided token failed validation. Check the token value and format.",
12275
+ });
12077
12276
  return;
12078
12277
  }
12079
- if (await validateAuthDataUpstream(authData)) {
12080
- next();
12278
+ // Also runs for existing sessions so group removals take effect
12279
+ // once the cached membership result expires.
12280
+ const denial = await checkAllowedGroups(authData.header, authData.token);
12281
+ if (denial) {
12282
+ res.status(denial.status).json(denial.body);
12081
12283
  return;
12082
12284
  }
12083
- res.status(401).json({
12084
- error: "Invalid Private-Token or JOB-TOKEN header",
12085
- message: "The provided token failed validation. Check the token value and format.",
12086
- });
12285
+ res.locals.sessionOwner = owner;
12286
+ next();
12087
12287
  return;
12088
12288
  }
12089
12289
  // Stateless-mode sid bypass: when the client sends only an
@@ -12116,8 +12316,38 @@ async function startStreamableHTTPServer() {
12116
12316
  }
12117
12317
  next();
12118
12318
  };
12319
+ // Owner key of the credential mcpBearerAuth accepted for this request
12320
+ const readRequestOwner = (req, res) => {
12321
+ if (hasHeaderAuth(req)) {
12322
+ return res.locals.sessionOwner;
12323
+ }
12324
+ const authInfo = req.auth;
12325
+ return authInfo?.token
12326
+ ? sessionOwnerKey(GITLAB_API_URL, authInfo.extra?.resourceOwnerId, authInfo.token)
12327
+ : undefined;
12328
+ };
12329
+ const bindSessionOwner = (sessionId, req, res) => {
12330
+ const owner = readRequestOwner(req, res);
12331
+ if (owner !== undefined) {
12332
+ sessionOwners[sessionId] = owner;
12333
+ }
12334
+ };
12335
+ // In MCP OAuth mode a stateful session belongs to the GitLab user who opened it.
12336
+ // A credential for another user gets the same 404 as an unknown session, so it
12337
+ // can neither use, stream from, nor close the session. A refreshed token for the
12338
+ // same user still matches.
12339
+ const rejectForeignSession = (req, res, next) => {
12340
+ const sessionId = readMcpSessionIdHeader(req);
12341
+ const owner = sessionId ? sessionOwners[sessionId] : undefined;
12342
+ if (owner !== undefined && readRequestOwner(req, res) !== owner) {
12343
+ metrics.authFailures++;
12344
+ res.status(404).json({ error: "Session not found" });
12345
+ return;
12346
+ }
12347
+ next();
12348
+ };
12119
12349
  // Streamable HTTP endpoint - handles both session creation and message handling
12120
- app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
12350
+ app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
12121
12351
  const sessionId = readMcpSessionIdHeader(req);
12122
12352
  const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
12123
12353
  // Track request
@@ -12205,7 +12435,7 @@ async function startStreamableHTTPServer() {
12205
12435
  }
12206
12436
  remoteAuthValidatedForInit = true;
12207
12437
  if (result === "stored") {
12208
- logger.info(`Session ${sessionId}: stored ${authData.header} header`);
12438
+ logger.info(`Session ${redactSessionIdForLog(sessionId)}: stored ${authData.header} header`);
12209
12439
  }
12210
12440
  }
12211
12441
  else if (allowUnauthenticatedDiscovery) {
@@ -12231,7 +12461,7 @@ async function startStreamableHTTPServer() {
12231
12461
  }
12232
12462
  remoteAuthValidatedForInit = true;
12233
12463
  if (result === "stored") {
12234
- logger.debug(`Session ${sessionId}: updated ${authData.header} header`);
12464
+ logger.debug(`Session ${redactSessionIdForLog(sessionId)}: updated ${authData.header} header`);
12235
12465
  }
12236
12466
  else {
12237
12467
  // Same token: keep the stored auth but let an unscoped session narrow.
@@ -12258,7 +12488,8 @@ async function startStreamableHTTPServer() {
12258
12488
  if (headerAuthData && sessionId) {
12259
12489
  if (!authBySession[sessionId]) {
12260
12490
  authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl);
12261
- logger.info(`Session ${sessionId}: stored ${headerAuthData.header} header (header auth)`);
12491
+ bindSessionOwner(sessionId, req, res);
12492
+ logger.info(`Session ${redactSessionIdForLog(sessionId)}: stored ${headerAuthData.header} header (header auth)`);
12262
12493
  setAuthTimeout(sessionId);
12263
12494
  }
12264
12495
  else {
@@ -12284,7 +12515,8 @@ async function startStreamableHTTPServer() {
12284
12515
  apiUrl: GITLAB_API_URL,
12285
12516
  publicBaseUrl,
12286
12517
  };
12287
- logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
12518
+ bindSessionOwner(sessionId, req, res);
12519
+ logger.info(`Session ${redactSessionIdForLog(sessionId)}: stored OAuth token (client: ${authInfo.clientId})`);
12288
12520
  setAuthTimeout(sessionId);
12289
12521
  }
12290
12522
  else {
@@ -12314,13 +12546,13 @@ async function startStreamableHTTPServer() {
12314
12546
  streamableTransports[newSessionId] = transport;
12315
12547
  metrics.totalSessions++;
12316
12548
  metrics.activeSessions++;
12317
- logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
12549
+ logger.warn(`Streamable HTTP session initialized: ${redactSessionIdForLog(newSessionId)}`);
12318
12550
  // Store auth for newly created session in remote mode
12319
12551
  if (REMOTE_AUTHORIZATION && !authBySession[newSessionId]) {
12320
12552
  const authData = parseAuthHeaders(req);
12321
12553
  if (authData && remoteAuthValidatedForInit) {
12322
12554
  authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
12323
- logger.info(`Session ${newSessionId}: stored ${authData.header} header`);
12555
+ logger.info(`Session ${redactSessionIdForLog(newSessionId)}: stored ${authData.header} header`);
12324
12556
  setAuthTimeout(newSessionId);
12325
12557
  }
12326
12558
  else if (unauthenticatedDiscoveryRequested) {
@@ -12334,7 +12566,8 @@ async function startStreamableHTTPServer() {
12334
12566
  const authData = parseAuthHeaders(req);
12335
12567
  if (authData) {
12336
12568
  authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
12337
- logger.info(`Session ${newSessionId}: stored ${authData.header} header (header auth)`);
12569
+ bindSessionOwner(newSessionId, req, res);
12570
+ logger.info(`Session ${redactSessionIdForLog(newSessionId)}: stored ${authData.header} header (header auth)`);
12338
12571
  setAuthTimeout(newSessionId);
12339
12572
  }
12340
12573
  }
@@ -12348,7 +12581,8 @@ async function startStreamableHTTPServer() {
12348
12581
  apiUrl: GITLAB_API_URL,
12349
12582
  publicBaseUrl,
12350
12583
  };
12351
- logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
12584
+ bindSessionOwner(newSessionId, req, res);
12585
+ logger.info(`Session ${redactSessionIdForLog(newSessionId)}: stored OAuth token (client: ${authInfo.clientId})`);
12352
12586
  setAuthTimeout(newSessionId);
12353
12587
  }
12354
12588
  }
@@ -12359,13 +12593,13 @@ async function startStreamableHTTPServer() {
12359
12593
  transport.onclose = () => {
12360
12594
  const sid = transport.sessionId;
12361
12595
  if (sid && streamableTransports[sid]) {
12362
- logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
12596
+ logger.warn(`Streamable HTTP transport closed for session ${redactSessionIdForLog(sid)}, cleaning up`);
12363
12597
  delete streamableTransports[sid];
12364
12598
  metrics.activeSessions--;
12365
12599
  if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
12366
12600
  cleanupSessionAuth(sid);
12367
12601
  delete sessionRequestCounts[sid];
12368
- logger.info(`Session ${sid}: cleaned up auth mapping`);
12602
+ logger.info(`Session ${redactSessionIdForLog(sid)}: cleaned up auth mapping`);
12369
12603
  }
12370
12604
  }
12371
12605
  };
@@ -12406,7 +12640,7 @@ async function startStreamableHTTPServer() {
12406
12640
  }
12407
12641
  });
12408
12642
  // Streamable HTTP GET endpoint for listening to server-sent events (SSE)
12409
- app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
12643
+ app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
12410
12644
  const sessionId = readMcpSessionIdHeader(req);
12411
12645
  const acceptHeader = readAcceptHeader(req);
12412
12646
  if (!acceptHeader.includes("text/event-stream")) {
@@ -12530,7 +12764,7 @@ async function startStreamableHTTPServer() {
12530
12764
  });
12531
12765
  });
12532
12766
  // to delete a mcp server session explicitly
12533
- app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
12767
+ app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
12534
12768
  const sessionId = readMcpSessionIdHeader(req);
12535
12769
  if (!sessionId) {
12536
12770
  res.status(400).json({ error: "mcp-session-id header is required" });
@@ -12540,16 +12774,16 @@ async function startStreamableHTTPServer() {
12540
12774
  if (transport) {
12541
12775
  try {
12542
12776
  await transport.close();
12543
- logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
12777
+ logger.info(`Explicitly closed session via DELETE request: ${redactSessionIdForLog(sessionId)}`);
12544
12778
  if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
12545
12779
  cleanupSessionAuth(sessionId);
12546
12780
  delete sessionRequestCounts[sessionId];
12547
- logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
12781
+ logger.info(`Session ${redactSessionIdForLog(sessionId)}: cleaned up auth mapping on DELETE`);
12548
12782
  }
12549
12783
  res.status(204).send();
12550
12784
  }
12551
12785
  catch (error) {
12552
- logger.error({ err: error }, `Error closing session ${sessionId}`);
12786
+ logger.error({ err: error }, `Error closing session ${redactSessionIdForLog(sessionId)}`);
12553
12787
  res.status(500).json({ error: "Failed to close session" });
12554
12788
  }
12555
12789
  }
@@ -12592,7 +12826,7 @@ async function startStreamableHTTPServer() {
12592
12826
  }
12593
12827
  }
12594
12828
  catch (error) {
12595
- logger.error({ err: error }, `Error closing session ${sessionId}`);
12829
+ logger.error({ err: error }, `Error closing session ${redactSessionIdForLog(sessionId)}`);
12596
12830
  }
12597
12831
  });
12598
12832
  await Promise.allSettled(closePromises);