@zereight/mcp-gitlab 2.2.5 → 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");
@@ -1186,6 +1191,27 @@ function getEffectiveAllowedProjectIds() {
1186
1191
  }
1187
1192
  return GITLAB_ALLOWED_PROJECT_IDS;
1188
1193
  }
1194
+ /**
1195
+ * Keep a dynamic project scope bound to its session
1196
+ * A request that omits X-GitLab-Allowed-Project-Ids keeps the session scope, and a
1197
+ * request naming a different scope is refused (null). An unscoped session may be
1198
+ * narrowed. Re-initialize to change the scope.
1199
+ */
1200
+ function bindSessionProjectScope(requested, sessionScope) {
1201
+ if (!sessionScope || sessionScope.length === 0) {
1202
+ return requested;
1203
+ }
1204
+ if (requested === undefined) {
1205
+ return sessionScope;
1206
+ }
1207
+ const want = new Set(requested);
1208
+ const have = new Set(sessionScope);
1209
+ return want.size === have.size && [...want].every(id => have.has(id)) ? sessionScope : null;
1210
+ }
1211
+ const SESSION_PROJECT_SCOPE_MISMATCH = {
1212
+ error: "Project scope mismatch",
1213
+ message: "X-GitLab-Allowed-Project-Ids does not match the project scope of this session. Re-initialize to change it.",
1214
+ };
1189
1215
  /**
1190
1216
  * Reject tools that cannot be limited to allowed projects when
1191
1217
  * ENABLE_STRICT_PROJECT_SCOPE is set and an allowlist is in effect
@@ -2255,6 +2281,43 @@ async function createWorkItemNote(projectId, iid, body, options = {}) {
2255
2281
  return data.createNote.note;
2256
2282
  }
2257
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
+ }
2258
2321
  async function addGraphQLAwardEmoji(awardableId, name) {
2259
2322
  const data = await executeGraphQL(`mutation($awardableId: AwardableID!, $name: String!) {
2260
2323
  awardEmojiAdd(input: { awardableId: $awardableId, name: $name }) {
@@ -3251,11 +3314,13 @@ async function createIssueLink(projectId, issueIid, targetProjectId, targetIssue
3251
3314
  projectId = decodeURIComponent(projectId); // Decode project ID
3252
3315
  targetProjectId = decodeURIComponent(targetProjectId); // Decode target project ID as well
3253
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);
3254
3319
  const response = await fetch(url.toString(), {
3255
3320
  ...getFetchConfig(),
3256
3321
  method: "POST",
3257
3322
  body: JSON.stringify({
3258
- target_project_id: targetProjectId,
3323
+ target_project_id: effectiveTargetProjectId,
3259
3324
  target_issue_iid: targetIssueIid,
3260
3325
  link_type: linkType,
3261
3326
  }),
@@ -3293,6 +3358,10 @@ async function deleteIssueLink(projectId, issueIid, issueLinkId) {
3293
3358
  async function createMergeRequest(projectId, options) {
3294
3359
  projectId = decodeURIComponent(projectId); // Decode project ID
3295
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;
3296
3365
  const response = await fetch(url.toString(), {
3297
3366
  ...getFetchConfig(),
3298
3367
  method: "POST",
@@ -3301,7 +3370,7 @@ async function createMergeRequest(projectId, options) {
3301
3370
  description: options.description,
3302
3371
  source_branch: options.source_branch,
3303
3372
  target_branch: options.target_branch,
3304
- target_project_id: options.target_project_id,
3373
+ target_project_id: targetProjectId,
3305
3374
  assignee_ids: options.assignee_ids,
3306
3375
  reviewer_ids: options.reviewer_ids,
3307
3376
  labels: options.labels?.join(","),
@@ -4408,6 +4477,51 @@ async function unapproveMergeRequest(projectId, mergeRequestIid) {
4408
4477
  await handleGitLabError(response);
4409
4478
  return parseApprovalsResponse(await response.json());
4410
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
+ }
4411
4525
  /**
4412
4526
  * Get the approval state of a merge request
4413
4527
  *
@@ -5875,7 +5989,8 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
5875
5989
  if (!response.body) {
5876
5990
  throw new Error("No response body from GitLab");
5877
5991
  }
5878
- 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 });
5879
5994
  await streamPipeline(response.body, saveStream);
5880
5995
  return savePath;
5881
5996
  }
@@ -6246,6 +6361,7 @@ async function erasePipelineJob(projectId, jobId) {
6246
6361
  }
6247
6362
  const TERMINAL_PIPELINE_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
6248
6363
  const TERMINAL_JOB_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
6364
+ const TERMINAL_REBASE_STATUSES = new Set(["finished"]);
6249
6365
  async function waitForStatus(fetchStatus, terminalStatuses, timeoutSeconds = 300, pollIntervalSeconds = 5) {
6250
6366
  const deadline = Date.now() + timeoutSeconds * 1000;
6251
6367
  const fetchBeforeDeadline = async () => {
@@ -6743,7 +6859,7 @@ async function getCommit(projectId, sha, stats) {
6743
6859
  async function getCommitDiff(projectId, sha, full_diff) {
6744
6860
  projectId = decodeURIComponent(projectId);
6745
6861
  const baseUrl = `${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/repository/commits/${encodeGitLabPathSegment(sha)}/diff`;
6746
- let allDiffs = [];
6862
+ const allDiffs = [];
6747
6863
  let page = 1;
6748
6864
  while (true) {
6749
6865
  const url = new URL(baseUrl);
@@ -6887,7 +7003,7 @@ async function myIssues(options = {}) {
6887
7003
  }
6888
7004
  }
6889
7005
  // Use listIssues with assignee_username filter
6890
- let listIssuesOptions = {
7006
+ const listIssuesOptions = {
6891
7007
  state: options.state || "opened", // Default to "opened" if not specified
6892
7008
  labels: options.labels,
6893
7009
  milestone: options.milestone,
@@ -7595,15 +7711,23 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
7595
7711
  await handleGitLabError(response);
7596
7712
  }
7597
7713
  const mimeType = getImageMimeType(safeFilename);
7598
- // For non-image files, always save to disk.
7599
- // 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.
7600
7717
  if (!mimeType || localPath) {
7601
7718
  if (!response.body) {
7602
7719
  throw new Error("No response body from GitLab");
7603
7720
  }
7604
- 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 });
7605
7722
  // Stream directly to disk instead of buffering in memory
7606
- 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
+ }
7607
7731
  return { buffer: Buffer.alloc(0), filename: safeFilename, mimeType, savedPath: savePath };
7608
7732
  }
7609
7733
  // Images returned inline — buffer into memory for base64 encoding
@@ -8638,6 +8762,15 @@ async function handleToolCall(params) {
8638
8762
  content: [{ type: "text", text: JSON.stringify(approvalState) }],
8639
8763
  };
8640
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
+ }
8641
8774
  case "get_merge_request_approval_state": {
8642
8775
  const args = GetMergeRequestApprovalStateSchema.parse(params.arguments);
8643
8776
  const approvalState = await getMergeRequestApprovalState(args.project_id, args.merge_request_iid);
@@ -8662,7 +8795,7 @@ async function handleToolCall(params) {
8662
8795
  }
8663
8796
  case "list_namespaces": {
8664
8797
  const args = ListNamespacesSchema.parse(params.arguments);
8665
- const url = new URL(`${GITLAB_API_URL}/namespaces`);
8798
+ const url = new URL(`${getEffectiveApiUrl()}/namespaces`);
8666
8799
  if (args.search) {
8667
8800
  url.searchParams.append("search", args.search);
8668
8801
  }
@@ -8687,7 +8820,7 @@ async function handleToolCall(params) {
8687
8820
  }
8688
8821
  case "get_namespace": {
8689
8822
  const args = GetNamespaceSchema.parse(params.arguments);
8690
- const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeGitLabPathSegment(args.namespace_id)}`);
8823
+ const url = new URL(`${getEffectiveApiUrl()}/namespaces/${encodeGitLabPathSegment(args.namespace_id)}`);
8691
8824
  const response = await fetch(url.toString(), {
8692
8825
  ...getFetchConfig(),
8693
8826
  });
@@ -8700,7 +8833,7 @@ async function handleToolCall(params) {
8700
8833
  }
8701
8834
  case "verify_namespace": {
8702
8835
  const args = VerifyNamespaceSchema.parse(params.arguments);
8703
- const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeGitLabPathSegment(args.path)}/exists`);
8836
+ const url = new URL(`${getEffectiveApiUrl()}/namespaces/${encodeGitLabPathSegment(args.path)}/exists`);
8704
8837
  if (args.parent_id !== undefined)
8705
8838
  url.searchParams.set("parent_id", String(args.parent_id));
8706
8839
  const response = await fetch(url.toString(), {
@@ -9158,7 +9291,8 @@ async function handleToolCall(params) {
9158
9291
  }
9159
9292
  case "list_work_item_note_emoji_reactions": {
9160
9293
  const args = ListWorkItemNoteEmojiReactionsSchema.parse(params.arguments);
9161
- 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);
9162
9296
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
9163
9297
  }
9164
9298
  case "create_work_item_emoji_reaction": {
@@ -9182,12 +9316,14 @@ async function handleToolCall(params) {
9182
9316
  }
9183
9317
  case "create_work_item_note_emoji_reaction": {
9184
9318
  const args = CreateWorkItemNoteEmojiReactionSchema.parse(params.arguments);
9185
- 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);
9186
9321
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
9187
9322
  }
9188
9323
  case "delete_work_item_note_emoji_reaction": {
9189
9324
  const args = DeleteWorkItemNoteEmojiReactionSchema.parse(params.arguments);
9190
- 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);
9191
9327
  return {
9192
9328
  content: [
9193
9329
  {
@@ -10110,6 +10246,9 @@ async function handleToolCall(params) {
10110
10246
  ],
10111
10247
  };
10112
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
+ }
10113
10252
  const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
10114
10253
  return {
10115
10254
  content: [
@@ -10618,6 +10757,10 @@ async function handleToolCall(params) {
10618
10757
  ],
10619
10758
  };
10620
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
+ }
10621
10764
  const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
10622
10765
  if (result.mimeType && !args.local_path) {
10623
10766
  // Return image inline as base64 so the AI can see it
@@ -11329,7 +11472,8 @@ async function startSSEServer() {
11329
11472
  const transport = transports[sessionId];
11330
11473
  delete transports[sessionId];
11331
11474
  sessionLastActivity.delete(sessionId);
11332
- 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`);
11333
11477
  if (transport) {
11334
11478
  void transport.close().catch(error => {
11335
11479
  logger.error({ err: error }, "Error closing idle SSE transport");
@@ -11365,7 +11509,7 @@ async function startSSEServer() {
11365
11509
  await serverInstance.connect(transport);
11366
11510
  }
11367
11511
  catch (error) {
11368
- logger.error({ err: error, sessionId }, "SSE connection error");
11512
+ logger.error({ err: error, sidPrefix: redactSessionIdForLog(sessionId) }, "SSE connection error");
11369
11513
  // Release the capacity slot immediately instead of waiting for the
11370
11514
  // client to disconnect or the idle sweep to run.
11371
11515
  delete transports[sessionId];
@@ -11468,6 +11612,8 @@ async function startStreamableHTTPServer() {
11468
11612
  };
11469
11613
  // Rate limiting per session
11470
11614
  const sessionRequestCounts = {};
11615
+ // MCP OAuth mode: the GitLab user each stateful session belongs to
11616
+ const sessionOwners = Object.create(null);
11471
11617
  /**
11472
11618
  * Validate token format and length
11473
11619
  */
@@ -11575,7 +11721,18 @@ async function startStreamableHTTPServer() {
11575
11721
  }
11576
11722
  return null;
11577
11723
  };
11578
- 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) => {
11579
11736
  const apiUrl = authData.apiUrl.replace(/\/$/, "");
11580
11737
  const paths = authData.header === "JOB-TOKEN" ? ["user", "job"] : ["user"];
11581
11738
  try {
@@ -11591,20 +11748,67 @@ async function startStreamableHTTPServer() {
11591
11748
  },
11592
11749
  signal: controller.signal,
11593
11750
  });
11594
- if (response.ok)
11595
- 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
+ }
11596
11756
  }
11597
11757
  finally {
11598
11758
  clearTimeout(timeout);
11599
11759
  }
11600
11760
  }
11601
- return false;
11761
+ return null;
11602
11762
  }
11603
11763
  catch (error) {
11604
11764
  logger.warn({ err: error }, "Remote auth token validation failed");
11605
- return false;
11765
+ return null;
11606
11766
  }
11607
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;
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
+ };
11811
+ };
11608
11812
  const sessionAuthTokensMatch = (left, right) => left.header === right.header && left.token === right.token && left.apiUrl === right.apiUrl;
11609
11813
  const storeValidatedSessionAuth = async (targetSessionId, authData, publicBaseUrl, existing, options) => {
11610
11814
  const current = authBySession[targetSessionId];
@@ -11635,15 +11839,15 @@ async function startStreamableHTTPServer() {
11635
11839
  const transport = streamableTransports[sessionId];
11636
11840
  if (authData || transport) {
11637
11841
  logger.info(authData
11638
- ? `Session ${sessionId}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`
11639
- : `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`);
11640
11844
  delete authBySession[sessionId];
11641
11845
  delete authTimeouts[sessionId];
11642
11846
  metrics.expiredSessions++;
11643
11847
  // Close the transport to free the slot; without this, stale sessions accumulate and exhaust MAX_SESSIONS.
11644
11848
  if (transport) {
11645
11849
  transport.close().catch(err => {
11646
- logger.error(`Error closing transport for expired session ${sessionId}:`, err);
11850
+ logger.error(`Error closing transport for expired session ${redactSessionIdForLog(sessionId)}:`, err);
11647
11851
  });
11648
11852
  }
11649
11853
  }
@@ -11664,6 +11868,7 @@ async function startStreamableHTTPServer() {
11664
11868
  */
11665
11869
  const cleanupSessionAuth = (sessionId) => {
11666
11870
  delete authBySession[sessionId];
11871
+ delete sessionOwners[sessionId];
11667
11872
  clearAuthTimeout(sessionId);
11668
11873
  };
11669
11874
  /**
@@ -11694,6 +11899,9 @@ async function startStreamableHTTPServer() {
11694
11899
  const incomingSid = readMcpSessionIdHeader(req);
11695
11900
  const isInit = isInitializationRequestBody(req.body);
11696
11901
  const headerAuth = parseAuthHeaders(req);
11902
+ const opened = incomingSid && looksLikeStatelessSessionId(incomingSid)
11903
+ ? openSessionId(material, incomingSid, sessionTtlSeconds)
11904
+ : null;
11697
11905
  // In GITLAB_MCP_OAUTH mode, req.auth may be populated by requireBearerAuth.
11698
11906
  // Use it when headerAuth is null (no Private-Token / JOB-TOKEN / Authorization).
11699
11907
  let effective = null;
@@ -11701,7 +11909,6 @@ async function startStreamableHTTPServer() {
11701
11909
  if (headerAuth) {
11702
11910
  let needsUpstreamValidation = isInit || !incomingSid;
11703
11911
  if (!needsUpstreamValidation && incomingSid && looksLikeStatelessSessionId(incomingSid)) {
11704
- const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
11705
11912
  needsUpstreamValidation =
11706
11913
  !opened ||
11707
11914
  opened.h !== headerAuth.header ||
@@ -11748,7 +11955,6 @@ async function startStreamableHTTPServer() {
11748
11955
  let sidPresentedButInvalid = false;
11749
11956
  if (!effective && incomingSid) {
11750
11957
  if (looksLikeStatelessSessionId(incomingSid)) {
11751
- const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
11752
11958
  if (opened) {
11753
11959
  effective = {
11754
11960
  header: opened.h,
@@ -11791,6 +11997,27 @@ async function startStreamableHTTPServer() {
11791
11997
  });
11792
11998
  return;
11793
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
+ }
12009
+ // The project scope sealed into the sid belongs to the session: live headers
12010
+ // may refresh the token but cannot drop or replace the scope.
12011
+ if (ENABLE_DYNAMIC_PROJECT_SCOPE && freshAuthPresent && !isInit && opened) {
12012
+ const allowedProjectIds = bindSessionProjectScope(effective.allowedProjectIds, opened.p);
12013
+ if (allowedProjectIds === null) {
12014
+ metrics.authFailures++;
12015
+ metrics.statelessAuthFailures++;
12016
+ res.status(401).json(SESSION_PROJECT_SCOPE_MISMATCH);
12017
+ return;
12018
+ }
12019
+ effective.allowedProjectIds = allowedProjectIds;
12020
+ }
11794
12021
  // Always mint a fresh sid so the embedded iat advances on every request.
11795
12022
  // This makes OAUTH_STATELESS_SESSION_TTL_SECONDS behave as an inactivity
11796
12023
  // timeout rather than an absolute-age cap — matching the legacy
@@ -11908,7 +12135,7 @@ async function startStreamableHTTPServer() {
11908
12135
  storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
11909
12136
  }
11910
12137
  : null;
11911
- 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));
11912
12139
  const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
11913
12140
  // When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
11914
12141
  // advertises root-level endpoints. Override to use path-prefixed endpoints.
@@ -12038,18 +12265,25 @@ async function startStreamableHTTPServer() {
12038
12265
  }
12039
12266
  const sessionId = readMcpSessionIdHeader(req);
12040
12267
  const cached = sessionId ? authBySession[sessionId] : undefined;
12041
- if (cached && sessionAuthTokensMatch(cached, authData)) {
12042
- 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
+ });
12043
12276
  return;
12044
12277
  }
12045
- if (await validateAuthDataUpstream(authData)) {
12046
- 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);
12047
12283
  return;
12048
12284
  }
12049
- res.status(401).json({
12050
- error: "Invalid Private-Token or JOB-TOKEN header",
12051
- message: "The provided token failed validation. Check the token value and format.",
12052
- });
12285
+ res.locals.sessionOwner = owner;
12286
+ next();
12053
12287
  return;
12054
12288
  }
12055
12289
  // Stateless-mode sid bypass: when the client sends only an
@@ -12082,8 +12316,38 @@ async function startStreamableHTTPServer() {
12082
12316
  }
12083
12317
  next();
12084
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
+ };
12085
12349
  // Streamable HTTP endpoint - handles both session creation and message handling
12086
- app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
12350
+ app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
12087
12351
  const sessionId = readMcpSessionIdHeader(req);
12088
12352
  const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
12089
12353
  // Track request
@@ -12171,7 +12435,7 @@ async function startStreamableHTTPServer() {
12171
12435
  }
12172
12436
  remoteAuthValidatedForInit = true;
12173
12437
  if (result === "stored") {
12174
- logger.info(`Session ${sessionId}: stored ${authData.header} header`);
12438
+ logger.info(`Session ${redactSessionIdForLog(sessionId)}: stored ${authData.header} header`);
12175
12439
  }
12176
12440
  }
12177
12441
  else if (allowUnauthenticatedDiscovery) {
@@ -12180,7 +12444,13 @@ async function startStreamableHTTPServer() {
12180
12444
  }
12181
12445
  }
12182
12446
  else if (sessionId && authData) {
12183
- const result = await storeValidatedSessionAuth(sessionId, authData, publicBaseUrl, authBySession[sessionId], { skipIfUnchanged: true });
12447
+ const allowedProjectIds = bindSessionProjectScope(authData.allowedProjectIds, authBySession[sessionId].allowedProjectIds);
12448
+ if (allowedProjectIds === null) {
12449
+ metrics.authFailures++;
12450
+ res.status(401).json(SESSION_PROJECT_SCOPE_MISMATCH);
12451
+ return;
12452
+ }
12453
+ const result = await storeValidatedSessionAuth(sessionId, { ...authData, allowedProjectIds }, publicBaseUrl, authBySession[sessionId], { skipIfUnchanged: true });
12184
12454
  if (result === "invalid") {
12185
12455
  metrics.authFailures++;
12186
12456
  res.status(401).json({
@@ -12191,7 +12461,11 @@ async function startStreamableHTTPServer() {
12191
12461
  }
12192
12462
  remoteAuthValidatedForInit = true;
12193
12463
  if (result === "stored") {
12194
- logger.debug(`Session ${sessionId}: updated ${authData.header} header`);
12464
+ logger.debug(`Session ${redactSessionIdForLog(sessionId)}: updated ${authData.header} header`);
12465
+ }
12466
+ else {
12467
+ // Same token: keep the stored auth but let an unscoped session narrow.
12468
+ authBySession[sessionId].allowedProjectIds = allowedProjectIds;
12195
12469
  }
12196
12470
  }
12197
12471
  else if (sessionId && authBySession[sessionId]) {
@@ -12214,11 +12488,18 @@ async function startStreamableHTTPServer() {
12214
12488
  if (headerAuthData && sessionId) {
12215
12489
  if (!authBySession[sessionId]) {
12216
12490
  authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl);
12217
- 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)`);
12218
12493
  setAuthTimeout(sessionId);
12219
12494
  }
12220
12495
  else {
12221
- authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl, authBySession[sessionId]);
12496
+ const allowedProjectIds = bindSessionProjectScope(headerAuthData.allowedProjectIds, authBySession[sessionId].allowedProjectIds);
12497
+ if (allowedProjectIds === null) {
12498
+ metrics.authFailures++;
12499
+ res.status(401).json(SESSION_PROJECT_SCOPE_MISMATCH);
12500
+ return;
12501
+ }
12502
+ authBySession[sessionId] = withPublicBaseUrl({ ...headerAuthData, allowedProjectIds }, publicBaseUrl, authBySession[sessionId]);
12222
12503
  setAuthTimeout(sessionId);
12223
12504
  }
12224
12505
  }
@@ -12234,7 +12515,8 @@ async function startStreamableHTTPServer() {
12234
12515
  apiUrl: GITLAB_API_URL,
12235
12516
  publicBaseUrl,
12236
12517
  };
12237
- 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})`);
12238
12520
  setAuthTimeout(sessionId);
12239
12521
  }
12240
12522
  else {
@@ -12264,13 +12546,13 @@ async function startStreamableHTTPServer() {
12264
12546
  streamableTransports[newSessionId] = transport;
12265
12547
  metrics.totalSessions++;
12266
12548
  metrics.activeSessions++;
12267
- logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
12549
+ logger.warn(`Streamable HTTP session initialized: ${redactSessionIdForLog(newSessionId)}`);
12268
12550
  // Store auth for newly created session in remote mode
12269
12551
  if (REMOTE_AUTHORIZATION && !authBySession[newSessionId]) {
12270
12552
  const authData = parseAuthHeaders(req);
12271
12553
  if (authData && remoteAuthValidatedForInit) {
12272
12554
  authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
12273
- logger.info(`Session ${newSessionId}: stored ${authData.header} header`);
12555
+ logger.info(`Session ${redactSessionIdForLog(newSessionId)}: stored ${authData.header} header`);
12274
12556
  setAuthTimeout(newSessionId);
12275
12557
  }
12276
12558
  else if (unauthenticatedDiscoveryRequested) {
@@ -12284,7 +12566,8 @@ async function startStreamableHTTPServer() {
12284
12566
  const authData = parseAuthHeaders(req);
12285
12567
  if (authData) {
12286
12568
  authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
12287
- 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)`);
12288
12571
  setAuthTimeout(newSessionId);
12289
12572
  }
12290
12573
  }
@@ -12298,7 +12581,8 @@ async function startStreamableHTTPServer() {
12298
12581
  apiUrl: GITLAB_API_URL,
12299
12582
  publicBaseUrl,
12300
12583
  };
12301
- 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})`);
12302
12586
  setAuthTimeout(newSessionId);
12303
12587
  }
12304
12588
  }
@@ -12309,13 +12593,13 @@ async function startStreamableHTTPServer() {
12309
12593
  transport.onclose = () => {
12310
12594
  const sid = transport.sessionId;
12311
12595
  if (sid && streamableTransports[sid]) {
12312
- logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
12596
+ logger.warn(`Streamable HTTP transport closed for session ${redactSessionIdForLog(sid)}, cleaning up`);
12313
12597
  delete streamableTransports[sid];
12314
12598
  metrics.activeSessions--;
12315
12599
  if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
12316
12600
  cleanupSessionAuth(sid);
12317
12601
  delete sessionRequestCounts[sid];
12318
- logger.info(`Session ${sid}: cleaned up auth mapping`);
12602
+ logger.info(`Session ${redactSessionIdForLog(sid)}: cleaned up auth mapping`);
12319
12603
  }
12320
12604
  }
12321
12605
  };
@@ -12356,7 +12640,7 @@ async function startStreamableHTTPServer() {
12356
12640
  }
12357
12641
  });
12358
12642
  // Streamable HTTP GET endpoint for listening to server-sent events (SSE)
12359
- app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
12643
+ app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
12360
12644
  const sessionId = readMcpSessionIdHeader(req);
12361
12645
  const acceptHeader = readAcceptHeader(req);
12362
12646
  if (!acceptHeader.includes("text/event-stream")) {
@@ -12480,7 +12764,7 @@ async function startStreamableHTTPServer() {
12480
12764
  });
12481
12765
  });
12482
12766
  // to delete a mcp server session explicitly
12483
- app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
12767
+ app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
12484
12768
  const sessionId = readMcpSessionIdHeader(req);
12485
12769
  if (!sessionId) {
12486
12770
  res.status(400).json({ error: "mcp-session-id header is required" });
@@ -12490,16 +12774,16 @@ async function startStreamableHTTPServer() {
12490
12774
  if (transport) {
12491
12775
  try {
12492
12776
  await transport.close();
12493
- logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
12777
+ logger.info(`Explicitly closed session via DELETE request: ${redactSessionIdForLog(sessionId)}`);
12494
12778
  if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
12495
12779
  cleanupSessionAuth(sessionId);
12496
12780
  delete sessionRequestCounts[sessionId];
12497
- logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
12781
+ logger.info(`Session ${redactSessionIdForLog(sessionId)}: cleaned up auth mapping on DELETE`);
12498
12782
  }
12499
12783
  res.status(204).send();
12500
12784
  }
12501
12785
  catch (error) {
12502
- logger.error({ err: error }, `Error closing session ${sessionId}`);
12786
+ logger.error({ err: error }, `Error closing session ${redactSessionIdForLog(sessionId)}`);
12503
12787
  res.status(500).json({ error: "Failed to close session" });
12504
12788
  }
12505
12789
  }
@@ -12542,7 +12826,7 @@ async function startStreamableHTTPServer() {
12542
12826
  }
12543
12827
  }
12544
12828
  catch (error) {
12545
- logger.error({ err: error }, `Error closing session ${sessionId}`);
12829
+ logger.error({ err: error }, `Error closing session ${redactSessionIdForLog(sessionId)}`);
12546
12830
  }
12547
12831
  });
12548
12832
  await Promise.allSettled(closePromises);