@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/README.ko.md +7 -4
- package/README.md +11 -7
- package/README.pt-BR.md +7 -4
- package/README.zh-CN.md +7 -4
- package/build/cli/args.js +2 -0
- package/build/config.js +4 -0
- package/build/deprecated-env.js +3 -1
- package/build/index.js +370 -86
- package/build/oauth-consent.js +323 -0
- package/build/oauth-proxy.js +99 -12
- package/build/oauth.js +147 -3
- package/build/schemas.js +25 -1
- package/build/stateless/types.js +2 -0
- package/build/tools/exposed-tools.js +14 -0
- package/build/tools/registry.js +24 -4
- package/build/tools/tool-descriptions.js +59 -17
- package/build/utils/graphql-query.js +1 -2
- package/build/utils/helpers.js +63 -4
- package/build/utils/safe-redirect-fetch.js +1 -1
- package/build/utils/url.js +1 -0
- package/package.json +4 -3
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
|
|
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
|
|
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
|
|
373
|
-
//
|
|
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
|
-
!
|
|
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
|
|
388
|
+
const isActivationSkipped = (name, hidden) => isSkippedByDiscoverActivation({
|
|
386
389
|
allowedByPermissionMode: isToolAllowedByPermissionMode(name),
|
|
387
390
|
deniedByRegex: GITLAB_DENIED_TOOLS_REGEX?.test(name) === true,
|
|
388
|
-
hidden
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
7599
|
-
//
|
|
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
|
-
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
11761
|
+
return null;
|
|
11602
11762
|
}
|
|
11603
11763
|
catch (error) {
|
|
11604
11764
|
logger.warn({ err: error }, "Remote auth token validation failed");
|
|
11605
|
-
return
|
|
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
|
-
|
|
12042
|
-
|
|
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
|
-
|
|
12046
|
-
|
|
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.
|
|
12050
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|