@zereight/mcp-gitlab 2.2.6 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +316 -82
- 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");
|
|
@@ -2276,6 +2281,43 @@ async function createWorkItemNote(projectId, iid, body, options = {}) {
|
|
|
2276
2281
|
return data.createNote.note;
|
|
2277
2282
|
}
|
|
2278
2283
|
// --- Emoji Reactions (GraphQL) ---
|
|
2284
|
+
const NOTE_GID_PATTERN = /^gid:\/\/gitlab\/\w*Note\/\d+$/;
|
|
2285
|
+
/**
|
|
2286
|
+
* Check that a note's noteable is the given work item. Work item notes may report
|
|
2287
|
+
* the noteable as an Issue, which shares the work item's numeric ID. GitLab.com
|
|
2288
|
+
* returns noteableId as a bare number; a global ID string is accepted too.
|
|
2289
|
+
*/
|
|
2290
|
+
function isNoteOfWorkItem(note, workItemGID) {
|
|
2291
|
+
const workItemId = workItemGID.match(/\/(\d+)$/)?.[1];
|
|
2292
|
+
const noteable = String(note.noteableId ?? "").match(/^(?:gid:\/\/gitlab\/(\w+)\/)?(\d+)$/);
|
|
2293
|
+
if (workItemId === undefined || noteable?.[2] !== workItemId) {
|
|
2294
|
+
return false;
|
|
2295
|
+
}
|
|
2296
|
+
const types = [noteable[1], note.noteableType]
|
|
2297
|
+
.filter((type) => Boolean(type))
|
|
2298
|
+
.map(type => type.replace(/[^a-z]/gi, "").toLowerCase());
|
|
2299
|
+
return types.length > 0 && types.every(type => type === "issue" || type === "workitem");
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* Resolve the work item and return the note GID only if the note belongs to it.
|
|
2303
|
+
*/
|
|
2304
|
+
async function resolveWorkItemNoteGID(projectId, iid, noteId) {
|
|
2305
|
+
if (!NOTE_GID_PATTERN.test(noteId)) {
|
|
2306
|
+
throw new ToolInputError(`note_id must be a note global ID such as gid://gitlab/Note/123 (got ${noteId})`);
|
|
2307
|
+
}
|
|
2308
|
+
const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, iid);
|
|
2309
|
+
const data = await executeGraphQL(`query($id: NoteID!) {
|
|
2310
|
+
note(id: $id) {
|
|
2311
|
+
id
|
|
2312
|
+
noteableId
|
|
2313
|
+
noteableType
|
|
2314
|
+
}
|
|
2315
|
+
}`, { id: noteId });
|
|
2316
|
+
if (!data.note || !isNoteOfWorkItem(data.note, workItemGID)) {
|
|
2317
|
+
throw new ProjectAccessDeniedError(`Access denied: Note ${noteId} was not found on work item #${iid} in ${projectPath}`);
|
|
2318
|
+
}
|
|
2319
|
+
return data.note.id;
|
|
2320
|
+
}
|
|
2279
2321
|
async function addGraphQLAwardEmoji(awardableId, name) {
|
|
2280
2322
|
const data = await executeGraphQL(`mutation($awardableId: AwardableID!, $name: String!) {
|
|
2281
2323
|
awardEmojiAdd(input: { awardableId: $awardableId, name: $name }) {
|
|
@@ -3272,11 +3314,13 @@ async function createIssueLink(projectId, issueIid, targetProjectId, targetIssue
|
|
|
3272
3314
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
3273
3315
|
targetProjectId = decodeURIComponent(targetProjectId); // Decode target project ID as well
|
|
3274
3316
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/issues/${encodeGitLabPathSegment(issueIid)}/links`);
|
|
3317
|
+
// The target project is subject to the same allowlist as the source project.
|
|
3318
|
+
const effectiveTargetProjectId = getEffectiveProjectId(targetProjectId);
|
|
3275
3319
|
const response = await fetch(url.toString(), {
|
|
3276
3320
|
...getFetchConfig(),
|
|
3277
3321
|
method: "POST",
|
|
3278
3322
|
body: JSON.stringify({
|
|
3279
|
-
target_project_id:
|
|
3323
|
+
target_project_id: effectiveTargetProjectId,
|
|
3280
3324
|
target_issue_iid: targetIssueIid,
|
|
3281
3325
|
link_type: linkType,
|
|
3282
3326
|
}),
|
|
@@ -3314,6 +3358,10 @@ async function deleteIssueLink(projectId, issueIid, issueLinkId) {
|
|
|
3314
3358
|
async function createMergeRequest(projectId, options) {
|
|
3315
3359
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
3316
3360
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/merge_requests`);
|
|
3361
|
+
// A cross-project target is subject to the same allowlist as the source project.
|
|
3362
|
+
const targetProjectId = options.target_project_id
|
|
3363
|
+
? getEffectiveProjectId(decodeURIComponent(options.target_project_id))
|
|
3364
|
+
: undefined;
|
|
3317
3365
|
const response = await fetch(url.toString(), {
|
|
3318
3366
|
...getFetchConfig(),
|
|
3319
3367
|
method: "POST",
|
|
@@ -3322,7 +3370,7 @@ async function createMergeRequest(projectId, options) {
|
|
|
3322
3370
|
description: options.description,
|
|
3323
3371
|
source_branch: options.source_branch,
|
|
3324
3372
|
target_branch: options.target_branch,
|
|
3325
|
-
target_project_id:
|
|
3373
|
+
target_project_id: targetProjectId,
|
|
3326
3374
|
assignee_ids: options.assignee_ids,
|
|
3327
3375
|
reviewer_ids: options.reviewer_ids,
|
|
3328
3376
|
labels: options.labels?.join(","),
|
|
@@ -4429,6 +4477,51 @@ async function unapproveMergeRequest(projectId, mergeRequestIid) {
|
|
|
4429
4477
|
await handleGitLabError(response);
|
|
4430
4478
|
return parseApprovalsResponse(await response.json());
|
|
4431
4479
|
}
|
|
4480
|
+
/**
|
|
4481
|
+
* Enqueue a rebase of the merge request source branch onto its target branch
|
|
4482
|
+
*
|
|
4483
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4484
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
4485
|
+
* @param {boolean} skipCi - If true, GitLab does not create a pipeline for the rebased branch
|
|
4486
|
+
* @returns {Promise<unknown>} GitLab's enqueue response, `{"rebase_in_progress": true}`
|
|
4487
|
+
*/
|
|
4488
|
+
async function rebaseMergeRequest(projectId, mergeRequestIid, skipCi) {
|
|
4489
|
+
projectId = decodeURIComponent(projectId);
|
|
4490
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}/rebase`);
|
|
4491
|
+
const body = {};
|
|
4492
|
+
if (skipCi !== undefined) {
|
|
4493
|
+
body.skip_ci = skipCi;
|
|
4494
|
+
}
|
|
4495
|
+
const response = await fetch(url.toString(), {
|
|
4496
|
+
...getFetchConfig(),
|
|
4497
|
+
method: "PUT",
|
|
4498
|
+
body: JSON.stringify(body),
|
|
4499
|
+
});
|
|
4500
|
+
await handleGitLabError(response);
|
|
4501
|
+
return response.json();
|
|
4502
|
+
}
|
|
4503
|
+
/**
|
|
4504
|
+
* Get a merge request with its rebase state, shaped for waitForStatus
|
|
4505
|
+
*
|
|
4506
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4507
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
4508
|
+
* @returns {Promise<{status: string, mergeRequest: GitLabMergeRequest}>} `in_progress` or `finished`
|
|
4509
|
+
*/
|
|
4510
|
+
async function getMergeRequestRebaseStatus(projectId, mergeRequestIid, signal) {
|
|
4511
|
+
projectId = decodeURIComponent(projectId);
|
|
4512
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}`);
|
|
4513
|
+
url.searchParams.append("include_rebase_in_progress", "true");
|
|
4514
|
+
const response = await fetch(url.toString(), {
|
|
4515
|
+
...getFetchConfig(),
|
|
4516
|
+
signal,
|
|
4517
|
+
});
|
|
4518
|
+
await handleGitLabError(response);
|
|
4519
|
+
const mergeRequest = GitLabMergeRequestSchema.parse(await response.json());
|
|
4520
|
+
return {
|
|
4521
|
+
status: mergeRequest.rebase_in_progress === false ? "finished" : "in_progress",
|
|
4522
|
+
mergeRequest,
|
|
4523
|
+
};
|
|
4524
|
+
}
|
|
4432
4525
|
/**
|
|
4433
4526
|
* Get the approval state of a merge request
|
|
4434
4527
|
*
|
|
@@ -5896,7 +5989,8 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
|
|
|
5896
5989
|
if (!response.body) {
|
|
5897
5990
|
throw new Error("No response body from GitLab");
|
|
5898
5991
|
}
|
|
5899
|
-
|
|
5992
|
+
// The archive name is fixed, but local_path still comes from the model.
|
|
5993
|
+
const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(filename, localPath, "local_path", undefined, { rejectDotNames: true });
|
|
5900
5994
|
await streamPipeline(response.body, saveStream);
|
|
5901
5995
|
return savePath;
|
|
5902
5996
|
}
|
|
@@ -6267,6 +6361,7 @@ async function erasePipelineJob(projectId, jobId) {
|
|
|
6267
6361
|
}
|
|
6268
6362
|
const TERMINAL_PIPELINE_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
|
|
6269
6363
|
const TERMINAL_JOB_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
|
|
6364
|
+
const TERMINAL_REBASE_STATUSES = new Set(["finished"]);
|
|
6270
6365
|
async function waitForStatus(fetchStatus, terminalStatuses, timeoutSeconds = 300, pollIntervalSeconds = 5) {
|
|
6271
6366
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
6272
6367
|
const fetchBeforeDeadline = async () => {
|
|
@@ -6764,7 +6859,7 @@ async function getCommit(projectId, sha, stats) {
|
|
|
6764
6859
|
async function getCommitDiff(projectId, sha, full_diff) {
|
|
6765
6860
|
projectId = decodeURIComponent(projectId);
|
|
6766
6861
|
const baseUrl = `${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/repository/commits/${encodeGitLabPathSegment(sha)}/diff`;
|
|
6767
|
-
|
|
6862
|
+
const allDiffs = [];
|
|
6768
6863
|
let page = 1;
|
|
6769
6864
|
while (true) {
|
|
6770
6865
|
const url = new URL(baseUrl);
|
|
@@ -6908,7 +7003,7 @@ async function myIssues(options = {}) {
|
|
|
6908
7003
|
}
|
|
6909
7004
|
}
|
|
6910
7005
|
// Use listIssues with assignee_username filter
|
|
6911
|
-
|
|
7006
|
+
const listIssuesOptions = {
|
|
6912
7007
|
state: options.state || "opened", // Default to "opened" if not specified
|
|
6913
7008
|
labels: options.labels,
|
|
6914
7009
|
milestone: options.milestone,
|
|
@@ -7616,15 +7711,23 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
|
7616
7711
|
await handleGitLabError(response);
|
|
7617
7712
|
}
|
|
7618
7713
|
const mimeType = getImageMimeType(safeFilename);
|
|
7619
|
-
//
|
|
7620
|
-
//
|
|
7714
|
+
// Non-image files, and images with local_path, are saved as new files under the
|
|
7715
|
+
// download directory. Upload names and local_path come from untrusted content, so
|
|
7716
|
+
// dot names (.git, .vscode, .envrc) are refused and existing files are never replaced.
|
|
7621
7717
|
if (!mimeType || localPath) {
|
|
7622
7718
|
if (!response.body) {
|
|
7623
7719
|
throw new Error("No response body from GitLab");
|
|
7624
7720
|
}
|
|
7625
|
-
const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(safeFilename, localPath, "local_path");
|
|
7721
|
+
const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(safeFilename, localPath, "local_path", resolveDownloadRoot(GITLAB_DOWNLOAD_DIR), { exclusive: true, rejectDotNames: true });
|
|
7626
7722
|
// Stream directly to disk instead of buffering in memory
|
|
7627
|
-
|
|
7723
|
+
try {
|
|
7724
|
+
await streamPipeline(response.body, saveStream);
|
|
7725
|
+
}
|
|
7726
|
+
catch (error) {
|
|
7727
|
+
// The file was created by this call; drop the partial copy so a retry can succeed.
|
|
7728
|
+
fs.rmSync(savePath, { force: true });
|
|
7729
|
+
throw error;
|
|
7730
|
+
}
|
|
7628
7731
|
return { buffer: Buffer.alloc(0), filename: safeFilename, mimeType, savedPath: savePath };
|
|
7629
7732
|
}
|
|
7630
7733
|
// Images returned inline — buffer into memory for base64 encoding
|
|
@@ -8659,6 +8762,15 @@ async function handleToolCall(params) {
|
|
|
8659
8762
|
content: [{ type: "text", text: JSON.stringify(approvalState) }],
|
|
8660
8763
|
};
|
|
8661
8764
|
}
|
|
8765
|
+
case "rebase_merge_request": {
|
|
8766
|
+
const { project_id, merge_request_iid, skip_ci, wait, timeout_seconds, poll_interval_seconds, } = RebaseMergeRequestSchema.parse(params.arguments);
|
|
8767
|
+
const enqueued = await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
|
|
8768
|
+
if (!wait) {
|
|
8769
|
+
return { content: [{ type: "text", text: JSON.stringify(enqueued) }] };
|
|
8770
|
+
}
|
|
8771
|
+
const { mergeRequest } = await waitForStatus(signal => getMergeRequestRebaseStatus(project_id, merge_request_iid, signal), TERMINAL_REBASE_STATUSES, timeout_seconds, poll_interval_seconds);
|
|
8772
|
+
return { content: [{ type: "text", text: JSON.stringify(mergeRequest) }] };
|
|
8773
|
+
}
|
|
8662
8774
|
case "get_merge_request_approval_state": {
|
|
8663
8775
|
const args = GetMergeRequestApprovalStateSchema.parse(params.arguments);
|
|
8664
8776
|
const approvalState = await getMergeRequestApprovalState(args.project_id, args.merge_request_iid);
|
|
@@ -8683,7 +8795,7 @@ async function handleToolCall(params) {
|
|
|
8683
8795
|
}
|
|
8684
8796
|
case "list_namespaces": {
|
|
8685
8797
|
const args = ListNamespacesSchema.parse(params.arguments);
|
|
8686
|
-
const url = new URL(`${
|
|
8798
|
+
const url = new URL(`${getEffectiveApiUrl()}/namespaces`);
|
|
8687
8799
|
if (args.search) {
|
|
8688
8800
|
url.searchParams.append("search", args.search);
|
|
8689
8801
|
}
|
|
@@ -8708,7 +8820,7 @@ async function handleToolCall(params) {
|
|
|
8708
8820
|
}
|
|
8709
8821
|
case "get_namespace": {
|
|
8710
8822
|
const args = GetNamespaceSchema.parse(params.arguments);
|
|
8711
|
-
const url = new URL(`${
|
|
8823
|
+
const url = new URL(`${getEffectiveApiUrl()}/namespaces/${encodeGitLabPathSegment(args.namespace_id)}`);
|
|
8712
8824
|
const response = await fetch(url.toString(), {
|
|
8713
8825
|
...getFetchConfig(),
|
|
8714
8826
|
});
|
|
@@ -8721,7 +8833,7 @@ async function handleToolCall(params) {
|
|
|
8721
8833
|
}
|
|
8722
8834
|
case "verify_namespace": {
|
|
8723
8835
|
const args = VerifyNamespaceSchema.parse(params.arguments);
|
|
8724
|
-
const url = new URL(`${
|
|
8836
|
+
const url = new URL(`${getEffectiveApiUrl()}/namespaces/${encodeGitLabPathSegment(args.path)}/exists`);
|
|
8725
8837
|
if (args.parent_id !== undefined)
|
|
8726
8838
|
url.searchParams.set("parent_id", String(args.parent_id));
|
|
8727
8839
|
const response = await fetch(url.toString(), {
|
|
@@ -9179,7 +9291,8 @@ async function handleToolCall(params) {
|
|
|
9179
9291
|
}
|
|
9180
9292
|
case "list_work_item_note_emoji_reactions": {
|
|
9181
9293
|
const args = ListWorkItemNoteEmojiReactionsSchema.parse(params.arguments);
|
|
9182
|
-
const
|
|
9294
|
+
const noteGID = await resolveWorkItemNoteGID(args.project_id, args.iid, args.note_id);
|
|
9295
|
+
const result = await listGraphQLAwardEmoji(noteGID);
|
|
9183
9296
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9184
9297
|
}
|
|
9185
9298
|
case "create_work_item_emoji_reaction": {
|
|
@@ -9203,12 +9316,14 @@ async function handleToolCall(params) {
|
|
|
9203
9316
|
}
|
|
9204
9317
|
case "create_work_item_note_emoji_reaction": {
|
|
9205
9318
|
const args = CreateWorkItemNoteEmojiReactionSchema.parse(params.arguments);
|
|
9206
|
-
const
|
|
9319
|
+
const noteGID = await resolveWorkItemNoteGID(args.project_id, args.iid, args.note_id);
|
|
9320
|
+
const result = await addGraphQLAwardEmoji(noteGID, args.name);
|
|
9207
9321
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9208
9322
|
}
|
|
9209
9323
|
case "delete_work_item_note_emoji_reaction": {
|
|
9210
9324
|
const args = DeleteWorkItemNoteEmojiReactionSchema.parse(params.arguments);
|
|
9211
|
-
const
|
|
9325
|
+
const noteGID = await resolveWorkItemNoteGID(args.project_id, args.iid, args.note_id);
|
|
9326
|
+
const result = await removeGraphQLAwardEmoji(noteGID, args.name);
|
|
9212
9327
|
return {
|
|
9213
9328
|
content: [
|
|
9214
9329
|
{
|
|
@@ -10131,6 +10246,9 @@ async function handleToolCall(params) {
|
|
|
10131
10246
|
],
|
|
10132
10247
|
};
|
|
10133
10248
|
}
|
|
10249
|
+
if (GITLAB_PERMISSION_MODE === "readonly") {
|
|
10250
|
+
throw new Error("download_job_artifacts saves files to disk, which readonly mode does not allow. Use list_job_artifacts and get_job_artifact_file instead.");
|
|
10251
|
+
}
|
|
10134
10252
|
const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
|
|
10135
10253
|
return {
|
|
10136
10254
|
content: [
|
|
@@ -10639,6 +10757,10 @@ async function handleToolCall(params) {
|
|
|
10639
10757
|
],
|
|
10640
10758
|
};
|
|
10641
10759
|
}
|
|
10760
|
+
// Readonly mode never writes to disk: images still come back inline.
|
|
10761
|
+
if (GITLAB_PERMISSION_MODE === "readonly" && (!mimeType || args.local_path)) {
|
|
10762
|
+
throw new Error("download_attachment saves this file to disk, which readonly mode does not allow. Only images without local_path can be downloaded in readonly mode.");
|
|
10763
|
+
}
|
|
10642
10764
|
const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
|
|
10643
10765
|
if (result.mimeType && !args.local_path) {
|
|
10644
10766
|
// Return image inline as base64 so the AI can see it
|
|
@@ -11350,7 +11472,8 @@ async function startSSEServer() {
|
|
|
11350
11472
|
const transport = transports[sessionId];
|
|
11351
11473
|
delete transports[sessionId];
|
|
11352
11474
|
sessionLastActivity.delete(sessionId);
|
|
11353
|
-
|
|
11475
|
+
const sidPrefix = redactSessionIdForLog(sessionId);
|
|
11476
|
+
logger.info({ sidPrefix, event: "sse_session_idle_closed" }, `SSE session ${sidPrefix} closed after ${SESSION_TIMEOUT_SECONDS}s of inactivity`);
|
|
11354
11477
|
if (transport) {
|
|
11355
11478
|
void transport.close().catch(error => {
|
|
11356
11479
|
logger.error({ err: error }, "Error closing idle SSE transport");
|
|
@@ -11386,7 +11509,7 @@ async function startSSEServer() {
|
|
|
11386
11509
|
await serverInstance.connect(transport);
|
|
11387
11510
|
}
|
|
11388
11511
|
catch (error) {
|
|
11389
|
-
logger.error({ err: error, sessionId }, "SSE connection error");
|
|
11512
|
+
logger.error({ err: error, sidPrefix: redactSessionIdForLog(sessionId) }, "SSE connection error");
|
|
11390
11513
|
// Release the capacity slot immediately instead of waiting for the
|
|
11391
11514
|
// client to disconnect or the idle sweep to run.
|
|
11392
11515
|
delete transports[sessionId];
|
|
@@ -11489,6 +11612,8 @@ async function startStreamableHTTPServer() {
|
|
|
11489
11612
|
};
|
|
11490
11613
|
// Rate limiting per session
|
|
11491
11614
|
const sessionRequestCounts = {};
|
|
11615
|
+
// MCP OAuth mode: the GitLab user each stateful session belongs to
|
|
11616
|
+
const sessionOwners = Object.create(null);
|
|
11492
11617
|
/**
|
|
11493
11618
|
* Validate token format and length
|
|
11494
11619
|
*/
|
|
@@ -11596,7 +11721,18 @@ async function startStreamableHTTPServer() {
|
|
|
11596
11721
|
}
|
|
11597
11722
|
return null;
|
|
11598
11723
|
};
|
|
11599
|
-
|
|
11724
|
+
/**
|
|
11725
|
+
* Identify the GitLab user behind a credential. When GitLab does not report a
|
|
11726
|
+
* user id, fall back to a digest of the token so only that exact token matches.
|
|
11727
|
+
*/
|
|
11728
|
+
const sessionOwnerKey = (apiUrl, userId, token) => typeof userId === "number" || typeof userId === "string"
|
|
11729
|
+
? `${apiUrl.replace(/\/$/, "")}#${userId}`
|
|
11730
|
+
: `token:${createHash("sha256").update(token).digest("hex")}`;
|
|
11731
|
+
/**
|
|
11732
|
+
* Validate header auth against GitLab. Resolves to the session owner key of
|
|
11733
|
+
* the token, or null when GitLab rejects it.
|
|
11734
|
+
*/
|
|
11735
|
+
const resolveAuthOwnerUpstream = async (authData) => {
|
|
11600
11736
|
const apiUrl = authData.apiUrl.replace(/\/$/, "");
|
|
11601
11737
|
const paths = authData.header === "JOB-TOKEN" ? ["user", "job"] : ["user"];
|
|
11602
11738
|
try {
|
|
@@ -11612,19 +11748,66 @@ async function startStreamableHTTPServer() {
|
|
|
11612
11748
|
},
|
|
11613
11749
|
signal: controller.signal,
|
|
11614
11750
|
});
|
|
11615
|
-
if (response.ok)
|
|
11616
|
-
|
|
11751
|
+
if (response.ok) {
|
|
11752
|
+
const body = (await response.json().catch(() => undefined));
|
|
11753
|
+
const userId = path === "job" ? body?.user?.id : body?.id;
|
|
11754
|
+
return sessionOwnerKey(apiUrl, userId, authData.token);
|
|
11755
|
+
}
|
|
11617
11756
|
}
|
|
11618
11757
|
finally {
|
|
11619
11758
|
clearTimeout(timeout);
|
|
11620
11759
|
}
|
|
11621
11760
|
}
|
|
11622
|
-
return
|
|
11761
|
+
return null;
|
|
11623
11762
|
}
|
|
11624
11763
|
catch (error) {
|
|
11625
11764
|
logger.warn({ err: error }, "Remote auth token validation failed");
|
|
11626
|
-
return
|
|
11765
|
+
return null;
|
|
11766
|
+
}
|
|
11767
|
+
};
|
|
11768
|
+
const validateAuthDataUpstream = async (authData) => (await resolveAuthOwnerUpstream(authData)) !== null;
|
|
11769
|
+
/**
|
|
11770
|
+
* With GITLAB_OAUTH_ALLOWED_GROUPS set in MCP OAuth mode, credentials that do
|
|
11771
|
+
* not pass through verifyAccessToken (Private-Token / JOB-TOKEN headers and
|
|
11772
|
+
* sealed stateless session ids) get the same group check, using the OAuth
|
|
11773
|
+
* provider's per-token membership cache. CI job tokens cannot list the
|
|
11774
|
+
* user's groups, so they are refused. Returns null when access is allowed.
|
|
11775
|
+
*/
|
|
11776
|
+
const checkAllowedGroups = async (header, token) => {
|
|
11777
|
+
if (!GITLAB_MCP_OAUTH || !GITLAB_OAUTH_ALLOWED_GROUPS)
|
|
11778
|
+
return null;
|
|
11779
|
+
if (header === "JOB-TOKEN") {
|
|
11780
|
+
return {
|
|
11781
|
+
status: 403,
|
|
11782
|
+
body: {
|
|
11783
|
+
error: "JOB-TOKEN header not accepted",
|
|
11784
|
+
message: "GITLAB_OAUTH_ALLOWED_GROUPS is set and CI job tokens cannot be checked for group " +
|
|
11785
|
+
"membership. Use OAuth or a Private-Token header instead.",
|
|
11786
|
+
},
|
|
11787
|
+
};
|
|
11788
|
+
}
|
|
11789
|
+
const provider = app._mcpOAuthProvider;
|
|
11790
|
+
try {
|
|
11791
|
+
if (await provider.isAllowedGroupMember(token))
|
|
11792
|
+
return null;
|
|
11627
11793
|
}
|
|
11794
|
+
catch (error) {
|
|
11795
|
+
logger.warn({ err: error }, "Group membership lookup failed");
|
|
11796
|
+
return {
|
|
11797
|
+
status: 503,
|
|
11798
|
+
body: {
|
|
11799
|
+
error: "Group membership check unavailable",
|
|
11800
|
+
message: "Could not verify group membership with GitLab. Please retry.",
|
|
11801
|
+
},
|
|
11802
|
+
};
|
|
11803
|
+
}
|
|
11804
|
+
return {
|
|
11805
|
+
status: 403,
|
|
11806
|
+
body: {
|
|
11807
|
+
error: "Access denied",
|
|
11808
|
+
message: "The token owner is not a member of an allowed group.",
|
|
11809
|
+
},
|
|
11810
|
+
};
|
|
11628
11811
|
};
|
|
11629
11812
|
const sessionAuthTokensMatch = (left, right) => left.header === right.header && left.token === right.token && left.apiUrl === right.apiUrl;
|
|
11630
11813
|
const storeValidatedSessionAuth = async (targetSessionId, authData, publicBaseUrl, existing, options) => {
|
|
@@ -11656,15 +11839,15 @@ async function startStreamableHTTPServer() {
|
|
|
11656
11839
|
const transport = streamableTransports[sessionId];
|
|
11657
11840
|
if (authData || transport) {
|
|
11658
11841
|
logger.info(authData
|
|
11659
|
-
? `Session ${sessionId}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`
|
|
11660
|
-
: `Session ${sessionId}: unauthenticated discovery expired after ${SESSION_TIMEOUT_SECONDS}s`);
|
|
11842
|
+
? `Session ${redactSessionIdForLog(sessionId)}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`
|
|
11843
|
+
: `Session ${redactSessionIdForLog(sessionId)}: unauthenticated discovery expired after ${SESSION_TIMEOUT_SECONDS}s`);
|
|
11661
11844
|
delete authBySession[sessionId];
|
|
11662
11845
|
delete authTimeouts[sessionId];
|
|
11663
11846
|
metrics.expiredSessions++;
|
|
11664
11847
|
// Close the transport to free the slot; without this, stale sessions accumulate and exhaust MAX_SESSIONS.
|
|
11665
11848
|
if (transport) {
|
|
11666
11849
|
transport.close().catch(err => {
|
|
11667
|
-
logger.error(`Error closing transport for expired session ${sessionId}:`, err);
|
|
11850
|
+
logger.error(`Error closing transport for expired session ${redactSessionIdForLog(sessionId)}:`, err);
|
|
11668
11851
|
});
|
|
11669
11852
|
}
|
|
11670
11853
|
}
|
|
@@ -11685,6 +11868,7 @@ async function startStreamableHTTPServer() {
|
|
|
11685
11868
|
*/
|
|
11686
11869
|
const cleanupSessionAuth = (sessionId) => {
|
|
11687
11870
|
delete authBySession[sessionId];
|
|
11871
|
+
delete sessionOwners[sessionId];
|
|
11688
11872
|
clearAuthTimeout(sessionId);
|
|
11689
11873
|
};
|
|
11690
11874
|
/**
|
|
@@ -11813,6 +11997,15 @@ async function startStreamableHTTPServer() {
|
|
|
11813
11997
|
});
|
|
11814
11998
|
return;
|
|
11815
11999
|
}
|
|
12000
|
+
// Sid-only requests never reach verifyAccessToken or the header check in
|
|
12001
|
+
// mcpBearerAuth, so the sealed credential is checked here (cached per token).
|
|
12002
|
+
const groupDenial = await checkAllowedGroups(effective.header, effective.token);
|
|
12003
|
+
if (groupDenial) {
|
|
12004
|
+
metrics.authFailures++;
|
|
12005
|
+
metrics.statelessAuthFailures++;
|
|
12006
|
+
res.status(groupDenial.status).json(groupDenial.body);
|
|
12007
|
+
return;
|
|
12008
|
+
}
|
|
11816
12009
|
// The project scope sealed into the sid belongs to the session: live headers
|
|
11817
12010
|
// may refresh the token but cannot drop or replace the scope.
|
|
11818
12011
|
if (ENABLE_DYNAMIC_PROJECT_SCOPE && freshAuthPresent && !isInit && opened) {
|
|
@@ -11942,7 +12135,7 @@ async function startStreamableHTTPServer() {
|
|
|
11942
12135
|
storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
|
|
11943
12136
|
}
|
|
11944
12137
|
: null;
|
|
11945
|
-
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_PERMISSION_MODE === "readonly", GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions);
|
|
12138
|
+
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_PERMISSION_MODE === "readonly", GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions, parseAllowedRedirectUris(GITLAB_OAUTH_ALLOWED_REDIRECT_URIS_RAW));
|
|
11946
12139
|
const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
|
|
11947
12140
|
// When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
|
|
11948
12141
|
// advertises root-level endpoints. Override to use path-prefixed endpoints.
|
|
@@ -12072,18 +12265,25 @@ async function startStreamableHTTPServer() {
|
|
|
12072
12265
|
}
|
|
12073
12266
|
const sessionId = readMcpSessionIdHeader(req);
|
|
12074
12267
|
const cached = sessionId ? authBySession[sessionId] : undefined;
|
|
12075
|
-
|
|
12076
|
-
|
|
12268
|
+
const owner = sessionId && cached && sessionAuthTokensMatch(cached, authData)
|
|
12269
|
+
? sessionOwners[sessionId]
|
|
12270
|
+
: await resolveAuthOwnerUpstream(authData);
|
|
12271
|
+
if (owner === null) {
|
|
12272
|
+
res.status(401).json({
|
|
12273
|
+
error: "Invalid Private-Token or JOB-TOKEN header",
|
|
12274
|
+
message: "The provided token failed validation. Check the token value and format.",
|
|
12275
|
+
});
|
|
12077
12276
|
return;
|
|
12078
12277
|
}
|
|
12079
|
-
|
|
12080
|
-
|
|
12278
|
+
// Also runs for existing sessions so group removals take effect
|
|
12279
|
+
// once the cached membership result expires.
|
|
12280
|
+
const denial = await checkAllowedGroups(authData.header, authData.token);
|
|
12281
|
+
if (denial) {
|
|
12282
|
+
res.status(denial.status).json(denial.body);
|
|
12081
12283
|
return;
|
|
12082
12284
|
}
|
|
12083
|
-
res.
|
|
12084
|
-
|
|
12085
|
-
message: "The provided token failed validation. Check the token value and format.",
|
|
12086
|
-
});
|
|
12285
|
+
res.locals.sessionOwner = owner;
|
|
12286
|
+
next();
|
|
12087
12287
|
return;
|
|
12088
12288
|
}
|
|
12089
12289
|
// Stateless-mode sid bypass: when the client sends only an
|
|
@@ -12116,8 +12316,38 @@ async function startStreamableHTTPServer() {
|
|
|
12116
12316
|
}
|
|
12117
12317
|
next();
|
|
12118
12318
|
};
|
|
12319
|
+
// Owner key of the credential mcpBearerAuth accepted for this request
|
|
12320
|
+
const readRequestOwner = (req, res) => {
|
|
12321
|
+
if (hasHeaderAuth(req)) {
|
|
12322
|
+
return res.locals.sessionOwner;
|
|
12323
|
+
}
|
|
12324
|
+
const authInfo = req.auth;
|
|
12325
|
+
return authInfo?.token
|
|
12326
|
+
? sessionOwnerKey(GITLAB_API_URL, authInfo.extra?.resourceOwnerId, authInfo.token)
|
|
12327
|
+
: undefined;
|
|
12328
|
+
};
|
|
12329
|
+
const bindSessionOwner = (sessionId, req, res) => {
|
|
12330
|
+
const owner = readRequestOwner(req, res);
|
|
12331
|
+
if (owner !== undefined) {
|
|
12332
|
+
sessionOwners[sessionId] = owner;
|
|
12333
|
+
}
|
|
12334
|
+
};
|
|
12335
|
+
// In MCP OAuth mode a stateful session belongs to the GitLab user who opened it.
|
|
12336
|
+
// A credential for another user gets the same 404 as an unknown session, so it
|
|
12337
|
+
// can neither use, stream from, nor close the session. A refreshed token for the
|
|
12338
|
+
// same user still matches.
|
|
12339
|
+
const rejectForeignSession = (req, res, next) => {
|
|
12340
|
+
const sessionId = readMcpSessionIdHeader(req);
|
|
12341
|
+
const owner = sessionId ? sessionOwners[sessionId] : undefined;
|
|
12342
|
+
if (owner !== undefined && readRequestOwner(req, res) !== owner) {
|
|
12343
|
+
metrics.authFailures++;
|
|
12344
|
+
res.status(404).json({ error: "Session not found" });
|
|
12345
|
+
return;
|
|
12346
|
+
}
|
|
12347
|
+
next();
|
|
12348
|
+
};
|
|
12119
12349
|
// Streamable HTTP endpoint - handles both session creation and message handling
|
|
12120
|
-
app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
12350
|
+
app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
|
|
12121
12351
|
const sessionId = readMcpSessionIdHeader(req);
|
|
12122
12352
|
const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
|
|
12123
12353
|
// Track request
|
|
@@ -12205,7 +12435,7 @@ async function startStreamableHTTPServer() {
|
|
|
12205
12435
|
}
|
|
12206
12436
|
remoteAuthValidatedForInit = true;
|
|
12207
12437
|
if (result === "stored") {
|
|
12208
|
-
logger.info(`Session ${sessionId}: stored ${authData.header} header`);
|
|
12438
|
+
logger.info(`Session ${redactSessionIdForLog(sessionId)}: stored ${authData.header} header`);
|
|
12209
12439
|
}
|
|
12210
12440
|
}
|
|
12211
12441
|
else if (allowUnauthenticatedDiscovery) {
|
|
@@ -12231,7 +12461,7 @@ async function startStreamableHTTPServer() {
|
|
|
12231
12461
|
}
|
|
12232
12462
|
remoteAuthValidatedForInit = true;
|
|
12233
12463
|
if (result === "stored") {
|
|
12234
|
-
logger.debug(`Session ${sessionId}: updated ${authData.header} header`);
|
|
12464
|
+
logger.debug(`Session ${redactSessionIdForLog(sessionId)}: updated ${authData.header} header`);
|
|
12235
12465
|
}
|
|
12236
12466
|
else {
|
|
12237
12467
|
// Same token: keep the stored auth but let an unscoped session narrow.
|
|
@@ -12258,7 +12488,8 @@ async function startStreamableHTTPServer() {
|
|
|
12258
12488
|
if (headerAuthData && sessionId) {
|
|
12259
12489
|
if (!authBySession[sessionId]) {
|
|
12260
12490
|
authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl);
|
|
12261
|
-
|
|
12491
|
+
bindSessionOwner(sessionId, req, res);
|
|
12492
|
+
logger.info(`Session ${redactSessionIdForLog(sessionId)}: stored ${headerAuthData.header} header (header auth)`);
|
|
12262
12493
|
setAuthTimeout(sessionId);
|
|
12263
12494
|
}
|
|
12264
12495
|
else {
|
|
@@ -12284,7 +12515,8 @@ async function startStreamableHTTPServer() {
|
|
|
12284
12515
|
apiUrl: GITLAB_API_URL,
|
|
12285
12516
|
publicBaseUrl,
|
|
12286
12517
|
};
|
|
12287
|
-
|
|
12518
|
+
bindSessionOwner(sessionId, req, res);
|
|
12519
|
+
logger.info(`Session ${redactSessionIdForLog(sessionId)}: stored OAuth token (client: ${authInfo.clientId})`);
|
|
12288
12520
|
setAuthTimeout(sessionId);
|
|
12289
12521
|
}
|
|
12290
12522
|
else {
|
|
@@ -12314,13 +12546,13 @@ async function startStreamableHTTPServer() {
|
|
|
12314
12546
|
streamableTransports[newSessionId] = transport;
|
|
12315
12547
|
metrics.totalSessions++;
|
|
12316
12548
|
metrics.activeSessions++;
|
|
12317
|
-
logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
|
|
12549
|
+
logger.warn(`Streamable HTTP session initialized: ${redactSessionIdForLog(newSessionId)}`);
|
|
12318
12550
|
// Store auth for newly created session in remote mode
|
|
12319
12551
|
if (REMOTE_AUTHORIZATION && !authBySession[newSessionId]) {
|
|
12320
12552
|
const authData = parseAuthHeaders(req);
|
|
12321
12553
|
if (authData && remoteAuthValidatedForInit) {
|
|
12322
12554
|
authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
|
|
12323
|
-
logger.info(`Session ${newSessionId}: stored ${authData.header} header`);
|
|
12555
|
+
logger.info(`Session ${redactSessionIdForLog(newSessionId)}: stored ${authData.header} header`);
|
|
12324
12556
|
setAuthTimeout(newSessionId);
|
|
12325
12557
|
}
|
|
12326
12558
|
else if (unauthenticatedDiscoveryRequested) {
|
|
@@ -12334,7 +12566,8 @@ async function startStreamableHTTPServer() {
|
|
|
12334
12566
|
const authData = parseAuthHeaders(req);
|
|
12335
12567
|
if (authData) {
|
|
12336
12568
|
authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
|
|
12337
|
-
|
|
12569
|
+
bindSessionOwner(newSessionId, req, res);
|
|
12570
|
+
logger.info(`Session ${redactSessionIdForLog(newSessionId)}: stored ${authData.header} header (header auth)`);
|
|
12338
12571
|
setAuthTimeout(newSessionId);
|
|
12339
12572
|
}
|
|
12340
12573
|
}
|
|
@@ -12348,7 +12581,8 @@ async function startStreamableHTTPServer() {
|
|
|
12348
12581
|
apiUrl: GITLAB_API_URL,
|
|
12349
12582
|
publicBaseUrl,
|
|
12350
12583
|
};
|
|
12351
|
-
|
|
12584
|
+
bindSessionOwner(newSessionId, req, res);
|
|
12585
|
+
logger.info(`Session ${redactSessionIdForLog(newSessionId)}: stored OAuth token (client: ${authInfo.clientId})`);
|
|
12352
12586
|
setAuthTimeout(newSessionId);
|
|
12353
12587
|
}
|
|
12354
12588
|
}
|
|
@@ -12359,13 +12593,13 @@ async function startStreamableHTTPServer() {
|
|
|
12359
12593
|
transport.onclose = () => {
|
|
12360
12594
|
const sid = transport.sessionId;
|
|
12361
12595
|
if (sid && streamableTransports[sid]) {
|
|
12362
|
-
logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
|
|
12596
|
+
logger.warn(`Streamable HTTP transport closed for session ${redactSessionIdForLog(sid)}, cleaning up`);
|
|
12363
12597
|
delete streamableTransports[sid];
|
|
12364
12598
|
metrics.activeSessions--;
|
|
12365
12599
|
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
|
|
12366
12600
|
cleanupSessionAuth(sid);
|
|
12367
12601
|
delete sessionRequestCounts[sid];
|
|
12368
|
-
logger.info(`Session ${sid}: cleaned up auth mapping`);
|
|
12602
|
+
logger.info(`Session ${redactSessionIdForLog(sid)}: cleaned up auth mapping`);
|
|
12369
12603
|
}
|
|
12370
12604
|
}
|
|
12371
12605
|
};
|
|
@@ -12406,7 +12640,7 @@ async function startStreamableHTTPServer() {
|
|
|
12406
12640
|
}
|
|
12407
12641
|
});
|
|
12408
12642
|
// Streamable HTTP GET endpoint for listening to server-sent events (SSE)
|
|
12409
|
-
app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
12643
|
+
app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
|
|
12410
12644
|
const sessionId = readMcpSessionIdHeader(req);
|
|
12411
12645
|
const acceptHeader = readAcceptHeader(req);
|
|
12412
12646
|
if (!acceptHeader.includes("text/event-stream")) {
|
|
@@ -12530,7 +12764,7 @@ async function startStreamableHTTPServer() {
|
|
|
12530
12764
|
});
|
|
12531
12765
|
});
|
|
12532
12766
|
// to delete a mcp server session explicitly
|
|
12533
|
-
app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
12767
|
+
app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
|
|
12534
12768
|
const sessionId = readMcpSessionIdHeader(req);
|
|
12535
12769
|
if (!sessionId) {
|
|
12536
12770
|
res.status(400).json({ error: "mcp-session-id header is required" });
|
|
@@ -12540,16 +12774,16 @@ async function startStreamableHTTPServer() {
|
|
|
12540
12774
|
if (transport) {
|
|
12541
12775
|
try {
|
|
12542
12776
|
await transport.close();
|
|
12543
|
-
logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
|
|
12777
|
+
logger.info(`Explicitly closed session via DELETE request: ${redactSessionIdForLog(sessionId)}`);
|
|
12544
12778
|
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
|
|
12545
12779
|
cleanupSessionAuth(sessionId);
|
|
12546
12780
|
delete sessionRequestCounts[sessionId];
|
|
12547
|
-
logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
|
|
12781
|
+
logger.info(`Session ${redactSessionIdForLog(sessionId)}: cleaned up auth mapping on DELETE`);
|
|
12548
12782
|
}
|
|
12549
12783
|
res.status(204).send();
|
|
12550
12784
|
}
|
|
12551
12785
|
catch (error) {
|
|
12552
|
-
logger.error({ err: error }, `Error closing session ${sessionId}`);
|
|
12786
|
+
logger.error({ err: error }, `Error closing session ${redactSessionIdForLog(sessionId)}`);
|
|
12553
12787
|
res.status(500).json({ error: "Failed to close session" });
|
|
12554
12788
|
}
|
|
12555
12789
|
}
|
|
@@ -12592,7 +12826,7 @@ async function startStreamableHTTPServer() {
|
|
|
12592
12826
|
}
|
|
12593
12827
|
}
|
|
12594
12828
|
catch (error) {
|
|
12595
|
-
logger.error({ err: error }, `Error closing session ${sessionId}`);
|
|
12829
|
+
logger.error({ err: error }, `Error closing session ${redactSessionIdForLog(sessionId)}`);
|
|
12596
12830
|
}
|
|
12597
12831
|
});
|
|
12598
12832
|
await Promise.allSettled(closePromises);
|