@zereight/mcp-gitlab 2.1.56 → 2.1.58
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 +4 -4
- package/README.md +31 -28
- package/README.zh-CN.md +4 -4
- package/build/auth-retry.js +37 -20
- package/build/config.js +2 -0
- package/build/downloads/proxy.js +6 -6
- package/build/gitlab-client-pool.js +148 -112
- package/build/index.js +233 -51
- package/build/schemas.js +119 -8
- package/build/scripts/generate-tool-docs.js +1 -1
- package/build/stateless/session-id.js +6 -1
- package/build/test/no-proxy-test.js +11 -0
- package/build/test/remote-auth-tests.js +0 -1
- package/build/test/stateless/session-id.test.js +26 -0
- package/build/test/test-auth-retry.js +39 -32
- package/build/test/test-dynamic-project-scope.js +362 -0
- package/build/test/test-permission-mode.js +1 -0
- package/build/test/test-toolset-filtering.js +2 -2
- package/build/test/test-webhooks.js +218 -0
- package/build/test-note.js +0 -1
- package/build/test-resolve-issue-note.js +0 -1
- package/build/tools/registry.js +21 -1
- package/package.json +3 -7
package/build/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_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_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
|
|
2
|
+
import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, 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_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
|
|
3
3
|
/** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
|
|
4
4
|
const IS_REMOTE = SSE || STREAMABLE_HTTP;
|
|
5
5
|
const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STREAMABLE_HTTP_AUTH_TOKEN");
|
|
@@ -69,7 +69,7 @@ import fetchCookie from "fetch-cookie";
|
|
|
69
69
|
import fs from "node:fs";
|
|
70
70
|
import { pipeline as streamPipeline } from "node:stream/promises";
|
|
71
71
|
import os from "node:os";
|
|
72
|
-
import
|
|
72
|
+
import { fetch as undiciFetch, File, FormData } from "undici";
|
|
73
73
|
import path from "node:path";
|
|
74
74
|
import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
75
75
|
import { URL } from "node:url";
|
|
@@ -111,7 +111,7 @@ GitLabDiscussionNoteSchema, // Added
|
|
|
111
111
|
GitLabDiscussionSchema,
|
|
112
112
|
// Draft Notes Schemas
|
|
113
113
|
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineScheduleSchema, GitLabPipelineScheduleVariableSchema, GitLabPipelineSchedulePlayResultSchema, ListPipelineSchedulesSchema, GetPipelineScheduleSchema, ListPipelineSchedulePipelinesSchema, CreatePipelineScheduleSchema, UpdatePipelineScheduleSchema, DeletePipelineScheduleSchema, PlayPipelineScheduleSchema, TakeOwnershipPipelineScheduleSchema, GetPipelineScheduleVariableSchema, CreatePipelineScheduleVariableSchema, UpdatePipelineScheduleVariableSchema, DeletePipelineScheduleVariableSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
114
|
-
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
|
|
114
|
+
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, 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";
|
|
115
115
|
import { randomUUID, timingSafeEqual, } from "node:crypto";
|
|
116
116
|
import { createLogger } from "./utils/logger.js";
|
|
117
117
|
const logger = createLogger();
|
|
@@ -430,6 +430,7 @@ function createServer() {
|
|
|
430
430
|
lastUsed: authData.lastUsed,
|
|
431
431
|
apiUrl: authData.apiUrl,
|
|
432
432
|
publicBaseUrl: authData.publicBaseUrl,
|
|
433
|
+
allowedProjectIds: authData.allowedProjectIds,
|
|
433
434
|
};
|
|
434
435
|
// Run the handler within the retrieved context
|
|
435
436
|
const result = await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
|
|
@@ -707,6 +708,10 @@ function validateConfiguration() {
|
|
|
707
708
|
if (enableDynamicApiUrl && !remoteAuth) {
|
|
708
709
|
errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
|
|
709
710
|
}
|
|
711
|
+
const enableDynamicProjectScope = getConfig("enable-dynamic-project-scope", "ENABLE_DYNAMIC_PROJECT_SCOPE") === "true";
|
|
712
|
+
if (enableDynamicProjectScope && !remoteAuth) {
|
|
713
|
+
errors.push("ENABLE_DYNAMIC_PROJECT_SCOPE=true requires REMOTE_AUTHORIZATION=true");
|
|
714
|
+
}
|
|
710
715
|
if (errors.length > 0) {
|
|
711
716
|
logger.error("Configuration validation failed:");
|
|
712
717
|
errors.forEach(err => logger.error(` - ${err}`));
|
|
@@ -913,7 +918,7 @@ function defaultAuthRetryConfig() {
|
|
|
913
918
|
}
|
|
914
919
|
// Cookie jar and fetch - reloaded when cookie file changes
|
|
915
920
|
let cookieJar = null;
|
|
916
|
-
let fetch = wrapWithAuthRetry(
|
|
921
|
+
let fetch = wrapWithAuthRetry(undiciFetch, defaultAuthRetryConfig());
|
|
917
922
|
let lastCookieMtime = 0;
|
|
918
923
|
let cookieReloadLock = null; // Mutex to prevent parallel reloads
|
|
919
924
|
// Auth proxies may redirect and set cookies on the first request. We make a throwaway
|
|
@@ -933,7 +938,7 @@ async function reloadCookiesIfChanged() {
|
|
|
933
938
|
lastCookieMtime = mtime;
|
|
934
939
|
const newJar = await createCookieJar();
|
|
935
940
|
cookieJar = newJar;
|
|
936
|
-
fetch = wrapWithAuthRetry(newJar ? fetchCookie(
|
|
941
|
+
fetch = wrapWithAuthRetry(newJar ? fetchCookie(undiciFetch, newJar) : undiciFetch, defaultAuthRetryConfig());
|
|
937
942
|
initialSessionRequestMade = false;
|
|
938
943
|
}
|
|
939
944
|
}
|
|
@@ -942,7 +947,7 @@ async function reloadCookiesIfChanged() {
|
|
|
942
947
|
if (cookieJar) {
|
|
943
948
|
logger.info("Cookie file removed, clearing cached cookies");
|
|
944
949
|
cookieJar = null;
|
|
945
|
-
fetch = wrapWithAuthRetry(
|
|
950
|
+
fetch = wrapWithAuthRetry(undiciFetch, defaultAuthRetryConfig());
|
|
946
951
|
lastCookieMtime = 0;
|
|
947
952
|
initialSessionRequestMade = false;
|
|
948
953
|
}
|
|
@@ -967,7 +972,8 @@ async function ensureSessionForRequest() {
|
|
|
967
972
|
redirect: "follow",
|
|
968
973
|
});
|
|
969
974
|
// 401 means auth failed but the request completed - cookies were still exchanged
|
|
970
|
-
|
|
975
|
+
// 403 means insufficient scope (e.g. missing User: Read) but auth is valid
|
|
976
|
+
initialSessionRequestMade = response.ok || response.status === 401 || response.status === 403;
|
|
971
977
|
}
|
|
972
978
|
catch {
|
|
973
979
|
logger.debug("Session warmup request failed, will retry on next request");
|
|
@@ -1048,6 +1054,47 @@ function getEffectiveApiUrl() {
|
|
|
1048
1054
|
}
|
|
1049
1055
|
return GITLAB_API_URL;
|
|
1050
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Get the effective project allowlist for the current request
|
|
1059
|
+
* In REMOTE_AUTHORIZATION mode with ENABLE_DYNAMIC_PROJECT_SCOPE, reads the scope
|
|
1060
|
+
* narrowed via the X-GitLab-Allowed-Project-Ids header from session context
|
|
1061
|
+
* (already validated against GITLAB_ALLOWED_PROJECT_IDS by parseAuthHeaders)
|
|
1062
|
+
* Otherwise, uses environment GITLAB_ALLOWED_PROJECT_IDS
|
|
1063
|
+
*/
|
|
1064
|
+
function getEffectiveAllowedProjectIds() {
|
|
1065
|
+
if (ENABLE_DYNAMIC_PROJECT_SCOPE) {
|
|
1066
|
+
const ctx = sessionAuthStore.getStore();
|
|
1067
|
+
if (ctx?.allowedProjectIds && ctx.allowedProjectIds.length > 0) {
|
|
1068
|
+
return ctx.allowedProjectIds;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return GITLAB_ALLOWED_PROJECT_IDS;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Reject tools that cannot be limited to allowed projects when
|
|
1075
|
+
* ENABLE_STRICT_PROJECT_SCOPE is set and an allowlist is in effect
|
|
1076
|
+
*/
|
|
1077
|
+
function rejectIfStrictProjectScope(toolName) {
|
|
1078
|
+
if (ENABLE_STRICT_PROJECT_SCOPE) {
|
|
1079
|
+
rejectIfProjectScopedDeployment(toolName);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Filter a project listing down to the effective allowlist
|
|
1084
|
+
* Applies only when ENABLE_STRICT_PROJECT_SCOPE is set and an allowlist is in effect
|
|
1085
|
+
* Allowlist entries may be numeric IDs or full namespace paths
|
|
1086
|
+
*/
|
|
1087
|
+
function filterProjectsByAllowlist(projects) {
|
|
1088
|
+
if (!ENABLE_STRICT_PROJECT_SCOPE) {
|
|
1089
|
+
return projects;
|
|
1090
|
+
}
|
|
1091
|
+
const allowedProjectIds = getEffectiveAllowedProjectIds();
|
|
1092
|
+
if (allowedProjectIds.length === 0) {
|
|
1093
|
+
return projects;
|
|
1094
|
+
}
|
|
1095
|
+
return projects.filter(project => allowedProjectIds.includes(String(project.id)) ||
|
|
1096
|
+
allowedProjectIds.includes(project.path_with_namespace));
|
|
1097
|
+
}
|
|
1051
1098
|
/**
|
|
1052
1099
|
* Get fetch configuration with proper client from pool
|
|
1053
1100
|
* Uses connection pooling when dynamic API URLs are enabled
|
|
@@ -1060,10 +1107,10 @@ function getEffectiveApiUrl() {
|
|
|
1060
1107
|
*/
|
|
1061
1108
|
const getFetchConfig = () => {
|
|
1062
1109
|
const effectiveApiUrl = getEffectiveApiUrl();
|
|
1063
|
-
const
|
|
1110
|
+
const dispatcher = clientPool.getDispatcherForUrl(effectiveApiUrl);
|
|
1064
1111
|
return {
|
|
1065
1112
|
headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
|
|
1066
|
-
|
|
1113
|
+
dispatcher,
|
|
1067
1114
|
};
|
|
1068
1115
|
};
|
|
1069
1116
|
// Compute at startup
|
|
@@ -1193,7 +1240,7 @@ if (getPositionalCliCommand(process.argv) !== "auth" &&
|
|
|
1193
1240
|
* Utility function for handling GitLab API errors
|
|
1194
1241
|
* API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
|
|
1195
1242
|
*
|
|
1196
|
-
* @param {
|
|
1243
|
+
* @param {Response} response - The response from GitLab API
|
|
1197
1244
|
* @throws {Error} Throws an error with response details if the request failed
|
|
1198
1245
|
*/
|
|
1199
1246
|
async function handleGitLabError(response) {
|
|
@@ -1217,20 +1264,21 @@ async function handleGitLabError(response) {
|
|
|
1217
1264
|
* @throws {Error} If GITLAB_ALLOWED_PROJECT_IDS is set and the requested project is not in the whitelist
|
|
1218
1265
|
*/
|
|
1219
1266
|
function getEffectiveProjectId(projectId) {
|
|
1220
|
-
|
|
1267
|
+
const allowedProjectIds = getEffectiveAllowedProjectIds();
|
|
1268
|
+
if (allowedProjectIds.length > 0) {
|
|
1221
1269
|
// If there's only one allowed project, use it as default
|
|
1222
|
-
if (
|
|
1223
|
-
return
|
|
1270
|
+
if (allowedProjectIds.length === 1 && !projectId) {
|
|
1271
|
+
return allowedProjectIds[0];
|
|
1224
1272
|
}
|
|
1225
1273
|
// If a project ID is provided, check if it's in the whitelist
|
|
1226
|
-
if (projectId && !
|
|
1227
|
-
throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${
|
|
1274
|
+
if (projectId && !allowedProjectIds.includes(projectId)) {
|
|
1275
|
+
throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
|
|
1228
1276
|
}
|
|
1229
1277
|
// If no project ID provided but we have multiple allowed projects, require an explicit choice
|
|
1230
|
-
if (!projectId &&
|
|
1231
|
-
throw new Error(`Multiple projects allowed (${
|
|
1278
|
+
if (!projectId && allowedProjectIds.length > 1) {
|
|
1279
|
+
throw new Error(`Multiple projects allowed (${allowedProjectIds.join(", ")}). Please specify a project ID.`);
|
|
1232
1280
|
}
|
|
1233
|
-
return projectId ||
|
|
1281
|
+
return projectId || allowedProjectIds[0];
|
|
1234
1282
|
}
|
|
1235
1283
|
// Prioritize the passed projectId over GITLAB_PROJECT_ID to allow querying different projects
|
|
1236
1284
|
if (projectId) {
|
|
@@ -1245,8 +1293,8 @@ function rejectIfProjectScopedDeployment(toolName) {
|
|
|
1245
1293
|
if (GITLAB_PROJECT_ID) {
|
|
1246
1294
|
throw new Error(`${toolName} is not allowed when GITLAB_PROJECT_ID is set (server is locked to a single project)`);
|
|
1247
1295
|
}
|
|
1248
|
-
if (
|
|
1249
|
-
throw new Error(`${toolName} is not allowed
|
|
1296
|
+
if (getEffectiveAllowedProjectIds().length > 0) {
|
|
1297
|
+
throw new Error(`${toolName} is not allowed while a project allowlist is in effect (GITLAB_ALLOWED_PROJECT_IDS or a session scope restricts access to configured projects)`);
|
|
1250
1298
|
}
|
|
1251
1299
|
}
|
|
1252
1300
|
/**
|
|
@@ -1390,9 +1438,11 @@ async function createIssue(projectId, options) {
|
|
|
1390
1438
|
*/
|
|
1391
1439
|
async function listIssues(projectId, options = {}) {
|
|
1392
1440
|
let url;
|
|
1393
|
-
if (projectId) {
|
|
1394
|
-
|
|
1395
|
-
|
|
1441
|
+
if (projectId || (ENABLE_STRICT_PROJECT_SCOPE && getEffectiveAllowedProjectIds().length > 0)) {
|
|
1442
|
+
// In strict scope, never fall back to the instance-wide endpoint;
|
|
1443
|
+
// resolve the default project (or fail for a multi-project allowlist)
|
|
1444
|
+
const decodedProjectId = projectId ? decodeURIComponent(projectId) : "";
|
|
1445
|
+
const effectiveProjectId = getEffectiveProjectId(decodedProjectId);
|
|
1396
1446
|
url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/issues`);
|
|
1397
1447
|
}
|
|
1398
1448
|
else {
|
|
@@ -1486,11 +1536,18 @@ function appendMergeRequestFilters(url, options) {
|
|
|
1486
1536
|
* @returns {Promise<GitLabMergeRequest[]>} List of merge requests
|
|
1487
1537
|
*/
|
|
1488
1538
|
async function listMergeRequests(projectId, options = {}) {
|
|
1489
|
-
const decodedProjectId = projectId ? decodeURIComponent(projectId) :
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1539
|
+
const decodedProjectId = projectId ? decodeURIComponent(projectId) : "";
|
|
1540
|
+
let url;
|
|
1541
|
+
if (decodedProjectId ||
|
|
1542
|
+
(ENABLE_STRICT_PROJECT_SCOPE && getEffectiveAllowedProjectIds().length > 0)) {
|
|
1543
|
+
// In strict scope, never fall back to the instance-wide endpoint;
|
|
1544
|
+
// resolve the default project (or fail for a multi-project allowlist)
|
|
1545
|
+
const effectiveProjectId = getEffectiveProjectId(decodedProjectId);
|
|
1546
|
+
url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/merge_requests`);
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
url = new URL(`${getEffectiveApiUrl()}/merge_requests`);
|
|
1550
|
+
}
|
|
1494
1551
|
appendMergeRequestFilters(url, options);
|
|
1495
1552
|
const response = await fetch(url.toString(), {
|
|
1496
1553
|
...getFetchConfig(),
|
|
@@ -2179,8 +2236,8 @@ async function resolveProjectOrGroupPath(projectId) {
|
|
|
2179
2236
|
const namespaceMatch = decodedProjectId.match(/^(group|project):(.+)$/i);
|
|
2180
2237
|
const explicitKind = namespaceMatch?.[1]?.toLowerCase();
|
|
2181
2238
|
const requestedProjectId = namespaceMatch ? namespaceMatch[2] : decodedProjectId;
|
|
2182
|
-
if (explicitKind === "group" &&
|
|
2183
|
-
throw new Error("group:<id-or-path> cannot be used
|
|
2239
|
+
if (explicitKind === "group" && getEffectiveAllowedProjectIds().length > 0) {
|
|
2240
|
+
throw new Error("group:<id-or-path> cannot be used while a project allowlist is in effect (GITLAB_ALLOWED_PROJECT_IDS or a session scope), because the project allowlist does not cover groups");
|
|
2184
2241
|
}
|
|
2185
2242
|
const effectiveProjectId = getEffectiveProjectId(requestedProjectId);
|
|
2186
2243
|
if (explicitKind === "group") {
|
|
@@ -2202,7 +2259,7 @@ async function resolveProjectOrGroupPath(projectId) {
|
|
|
2202
2259
|
// resolving to an unrelated group with the same integer ID.
|
|
2203
2260
|
if (projectResponse.status === 404 &&
|
|
2204
2261
|
!explicitKind &&
|
|
2205
|
-
|
|
2262
|
+
getEffectiveAllowedProjectIds().length === 0 &&
|
|
2206
2263
|
!/^\d+$/.test(effectiveProjectId)) {
|
|
2207
2264
|
const groupUrl = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(effectiveProjectId)}`);
|
|
2208
2265
|
const groupResponse = await fetch(groupUrl.toString(), {
|
|
@@ -3522,14 +3579,19 @@ async function searchProjects(query, page = 1, perPage = 20) {
|
|
|
3522
3579
|
const errorBody = await response.text();
|
|
3523
3580
|
throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3524
3581
|
}
|
|
3525
|
-
const
|
|
3582
|
+
const unfiltered = (await response.json());
|
|
3583
|
+
const projects = filterProjectsByAllowlist(unfiltered);
|
|
3526
3584
|
const totalCount = response.headers.get("x-total");
|
|
3527
3585
|
const totalPages = response.headers.get("x-total-pages");
|
|
3528
|
-
|
|
3586
|
+
const nextPage = response.headers.get("x-next-page");
|
|
3587
|
+
// GitLab API doesn't return the total headers for results > 10,000
|
|
3588
|
+
// Keep the unfiltered totals so callers paginate past pages the allowlist filter thinned out;
|
|
3589
|
+
// without them, total_pages stays unset and next_page carries the continuation signal
|
|
3529
3590
|
const count = totalCount ? Number.parseInt(totalCount, 10) : projects.length;
|
|
3530
3591
|
return GitLabSearchResponseSchema.parse({
|
|
3531
3592
|
count,
|
|
3532
|
-
total_pages: totalPages ? Number.parseInt(totalPages, 10) :
|
|
3593
|
+
total_pages: totalPages ? Number.parseInt(totalPages, 10) : undefined,
|
|
3594
|
+
next_page: nextPage ? Number.parseInt(nextPage, 10) : undefined,
|
|
3533
3595
|
current_page: page,
|
|
3534
3596
|
items: projects,
|
|
3535
3597
|
});
|
|
@@ -4623,7 +4685,7 @@ async function listProjects(options = {}) {
|
|
|
4623
4685
|
await handleGitLabError(response);
|
|
4624
4686
|
// Parse and return the data
|
|
4625
4687
|
const data = await response.json();
|
|
4626
|
-
return z.array(GitLabProjectSchema).parse(data);
|
|
4688
|
+
return filterProjectsByAllowlist(z.array(GitLabProjectSchema).parse(data));
|
|
4627
4689
|
}
|
|
4628
4690
|
/**
|
|
4629
4691
|
* List labels for a project
|
|
@@ -4789,7 +4851,7 @@ async function listGroupProjects(options) {
|
|
|
4789
4851
|
});
|
|
4790
4852
|
await handleGitLabError(response);
|
|
4791
4853
|
const projects = await response.json();
|
|
4792
|
-
return GitLabProjectSchema.array().parse(projects);
|
|
4854
|
+
return filterProjectsByAllowlist(GitLabProjectSchema.array().parse(projects));
|
|
4793
4855
|
}
|
|
4794
4856
|
// Webhook API helper functions
|
|
4795
4857
|
/**
|
|
@@ -4800,6 +4862,7 @@ function buildWebhookBaseUrl(projectId, groupId) {
|
|
|
4800
4862
|
projectId = decodeURIComponent(projectId);
|
|
4801
4863
|
return `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/hooks`;
|
|
4802
4864
|
}
|
|
4865
|
+
rejectIfStrictProjectScope("group webhooks");
|
|
4803
4866
|
const decodedGroupId = decodeURIComponent(groupId);
|
|
4804
4867
|
return `${getEffectiveApiUrl()}/groups/${encodeURIComponent(decodedGroupId)}/hooks`;
|
|
4805
4868
|
}
|
|
@@ -4816,6 +4879,54 @@ async function listWebhooks(options) {
|
|
|
4816
4879
|
await handleGitLabError(response);
|
|
4817
4880
|
return (await response.json());
|
|
4818
4881
|
}
|
|
4882
|
+
function buildWebhookMutationBody(options) {
|
|
4883
|
+
const body = {};
|
|
4884
|
+
for (const [key, value] of Object.entries(options)) {
|
|
4885
|
+
if (key === "project_id" || key === "group_id" || key === "hook_id")
|
|
4886
|
+
continue;
|
|
4887
|
+
if (value !== undefined)
|
|
4888
|
+
body[key] = value;
|
|
4889
|
+
}
|
|
4890
|
+
return body;
|
|
4891
|
+
}
|
|
4892
|
+
/**
|
|
4893
|
+
* Create a webhook on a project or group
|
|
4894
|
+
*/
|
|
4895
|
+
async function createWebhook(options) {
|
|
4896
|
+
const url = buildWebhookBaseUrl(options.project_id, options.group_id);
|
|
4897
|
+
const response = await fetch(url, {
|
|
4898
|
+
...getFetchConfig(),
|
|
4899
|
+
method: "POST",
|
|
4900
|
+
body: JSON.stringify(buildWebhookMutationBody(options)),
|
|
4901
|
+
});
|
|
4902
|
+
await handleGitLabError(response);
|
|
4903
|
+
return await response.json();
|
|
4904
|
+
}
|
|
4905
|
+
/**
|
|
4906
|
+
* Update a webhook on a project or group
|
|
4907
|
+
*/
|
|
4908
|
+
async function updateWebhook(options) {
|
|
4909
|
+
const url = `${buildWebhookBaseUrl(options.project_id, options.group_id)}/${options.hook_id}`;
|
|
4910
|
+
const response = await fetch(url, {
|
|
4911
|
+
...getFetchConfig(),
|
|
4912
|
+
method: "PUT",
|
|
4913
|
+
body: JSON.stringify(buildWebhookMutationBody(options)),
|
|
4914
|
+
});
|
|
4915
|
+
await handleGitLabError(response);
|
|
4916
|
+
return await response.json();
|
|
4917
|
+
}
|
|
4918
|
+
/**
|
|
4919
|
+
* Delete a webhook from a project or group
|
|
4920
|
+
*/
|
|
4921
|
+
async function deleteWebhook(options) {
|
|
4922
|
+
const url = `${buildWebhookBaseUrl(options.project_id, options.group_id)}/${options.hook_id}`;
|
|
4923
|
+
const response = await fetch(url, {
|
|
4924
|
+
...getFetchConfig(),
|
|
4925
|
+
method: "DELETE",
|
|
4926
|
+
});
|
|
4927
|
+
await handleGitLabError(response);
|
|
4928
|
+
return { status: "deleted", hook_id: options.hook_id };
|
|
4929
|
+
}
|
|
4819
4930
|
/**
|
|
4820
4931
|
* Fetch a single page of webhook events
|
|
4821
4932
|
*/
|
|
@@ -6765,15 +6876,16 @@ async function resolveProjectFullPath(projectId) {
|
|
|
6765
6876
|
* Allowlist entries may be numeric IDs or full namespace paths.
|
|
6766
6877
|
*/
|
|
6767
6878
|
function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
|
|
6768
|
-
|
|
6879
|
+
const allowedProjectIds = getEffectiveAllowedProjectIds();
|
|
6880
|
+
if (allowedProjectIds.length === 0) {
|
|
6769
6881
|
return;
|
|
6770
6882
|
}
|
|
6771
6883
|
const fullPath = project?.fullPath ?? undefined;
|
|
6772
6884
|
const numericId = project?.id?.match(/^gid:\/\/gitlab\/Project\/(\d+)$/)?.[1];
|
|
6773
|
-
const allowed = (fullPath !== undefined &&
|
|
6774
|
-
(numericId !== undefined &&
|
|
6885
|
+
const allowed = (fullPath !== undefined && allowedProjectIds.includes(fullPath)) ||
|
|
6886
|
+
(numericId !== undefined && allowedProjectIds.includes(numericId));
|
|
6775
6887
|
if (!allowed) {
|
|
6776
|
-
throw new Error(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${
|
|
6888
|
+
throw new Error(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
|
|
6777
6889
|
}
|
|
6778
6890
|
}
|
|
6779
6891
|
/**
|
|
@@ -6783,7 +6895,7 @@ function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
|
|
|
6783
6895
|
* is not configured.
|
|
6784
6896
|
*/
|
|
6785
6897
|
async function ensureVulnerabilityProjectAllowed(vulnerabilityId) {
|
|
6786
|
-
if (
|
|
6898
|
+
if (getEffectiveAllowedProjectIds().length === 0) {
|
|
6787
6899
|
return;
|
|
6788
6900
|
}
|
|
6789
6901
|
const data = await executeGraphQL(`query getVulnerabilityProject($id: VulnerabilityID!) {
|
|
@@ -7015,15 +7127,11 @@ async function markdownUpload(projectId, filePath, content, filename) {
|
|
|
7015
7127
|
throw new Error("Either file_path or content must be provided");
|
|
7016
7128
|
}
|
|
7017
7129
|
// Create form data
|
|
7018
|
-
const FormData = (await import("form-data")).default;
|
|
7019
7130
|
const form = new FormData();
|
|
7020
|
-
form.append("file", fileBuffer, {
|
|
7021
|
-
filename: fileName,
|
|
7022
|
-
contentType: "application/octet-stream",
|
|
7023
|
-
});
|
|
7131
|
+
form.append("file", new File([fileBuffer], fileName, { type: "application/octet-stream" }));
|
|
7024
7132
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads`);
|
|
7025
7133
|
const defaultFetchConfig = getFetchConfig();
|
|
7026
|
-
delete defaultFetchConfig.headers["Content-Type"];
|
|
7134
|
+
delete defaultFetchConfig.headers["Content-Type"];
|
|
7027
7135
|
const response = await fetch(url.toString(), {
|
|
7028
7136
|
...defaultFetchConfig,
|
|
7029
7137
|
method: "POST",
|
|
@@ -7514,6 +7622,7 @@ async function handleToolCall(params) {
|
|
|
7514
7622
|
};
|
|
7515
7623
|
}
|
|
7516
7624
|
case "search_code": {
|
|
7625
|
+
rejectIfStrictProjectScope("search_code");
|
|
7517
7626
|
const args = SearchCodeSchema.parse(params.arguments);
|
|
7518
7627
|
const results = await searchBlobs({
|
|
7519
7628
|
search: args.search,
|
|
@@ -7544,6 +7653,7 @@ async function handleToolCall(params) {
|
|
|
7544
7653
|
};
|
|
7545
7654
|
}
|
|
7546
7655
|
case "search_group_code": {
|
|
7656
|
+
rejectIfStrictProjectScope("search_group_code");
|
|
7547
7657
|
const args = SearchGroupCodeSchema.parse(params.arguments);
|
|
7548
7658
|
const results = await searchBlobs({
|
|
7549
7659
|
search: args.search,
|
|
@@ -7806,6 +7916,7 @@ async function handleToolCall(params) {
|
|
|
7806
7916
|
};
|
|
7807
7917
|
}
|
|
7808
7918
|
case "list_todos": {
|
|
7919
|
+
rejectIfStrictProjectScope("list_todos");
|
|
7809
7920
|
const args = ListTodosSchema.parse(params.arguments);
|
|
7810
7921
|
const todos = await listTodos(args);
|
|
7811
7922
|
return {
|
|
@@ -7813,6 +7924,7 @@ async function handleToolCall(params) {
|
|
|
7813
7924
|
};
|
|
7814
7925
|
}
|
|
7815
7926
|
case "mark_todo_done": {
|
|
7927
|
+
rejectIfStrictProjectScope("mark_todo_done");
|
|
7816
7928
|
const args = MarkTodoDoneSchema.parse(params.arguments);
|
|
7817
7929
|
const todo = await markTodoDone(args.id);
|
|
7818
7930
|
return {
|
|
@@ -7820,6 +7932,7 @@ async function handleToolCall(params) {
|
|
|
7820
7932
|
};
|
|
7821
7933
|
}
|
|
7822
7934
|
case "mark_all_todos_done": {
|
|
7935
|
+
rejectIfStrictProjectScope("mark_all_todos_done");
|
|
7823
7936
|
MarkAllTodosDoneSchema.parse(params.arguments);
|
|
7824
7937
|
await markAllTodosDone();
|
|
7825
7938
|
return {
|
|
@@ -8071,6 +8184,7 @@ async function handleToolCall(params) {
|
|
|
8071
8184
|
};
|
|
8072
8185
|
}
|
|
8073
8186
|
case "list_group_members": {
|
|
8187
|
+
rejectIfStrictProjectScope("list_group_members");
|
|
8074
8188
|
const args = ListGroupMembersSchema.parse(params.arguments);
|
|
8075
8189
|
const { group_id, ...options } = args;
|
|
8076
8190
|
const members = await listGroupMembers(group_id, options);
|
|
@@ -8593,6 +8707,7 @@ async function handleToolCall(params) {
|
|
|
8593
8707
|
};
|
|
8594
8708
|
}
|
|
8595
8709
|
case "list_group_wiki_pages": {
|
|
8710
|
+
rejectIfStrictProjectScope("list_group_wiki_pages");
|
|
8596
8711
|
const { group_id, page, per_page, with_content, render_html } = ListGroupWikiPagesSchema.parse(params.arguments);
|
|
8597
8712
|
const wikiPages = await listGroupWikiPages(group_id, {
|
|
8598
8713
|
page,
|
|
@@ -8605,6 +8720,7 @@ async function handleToolCall(params) {
|
|
|
8605
8720
|
};
|
|
8606
8721
|
}
|
|
8607
8722
|
case "get_group_wiki_page": {
|
|
8723
|
+
rejectIfStrictProjectScope("get_group_wiki_page");
|
|
8608
8724
|
const { group_id, slug, render_html } = GetGroupWikiPageSchema.parse(params.arguments);
|
|
8609
8725
|
const wikiPage = await getGroupWikiPage(group_id, slug, render_html);
|
|
8610
8726
|
return {
|
|
@@ -8612,6 +8728,7 @@ async function handleToolCall(params) {
|
|
|
8612
8728
|
};
|
|
8613
8729
|
}
|
|
8614
8730
|
case "create_group_wiki_page": {
|
|
8731
|
+
rejectIfStrictProjectScope("create_group_wiki_page");
|
|
8615
8732
|
const { group_id, title, content, format } = CreateGroupWikiPageSchema.parse(params.arguments);
|
|
8616
8733
|
const wikiPage = await createGroupWikiPage(group_id, title, content, format);
|
|
8617
8734
|
return {
|
|
@@ -8619,6 +8736,7 @@ async function handleToolCall(params) {
|
|
|
8619
8736
|
};
|
|
8620
8737
|
}
|
|
8621
8738
|
case "update_group_wiki_page": {
|
|
8739
|
+
rejectIfStrictProjectScope("update_group_wiki_page");
|
|
8622
8740
|
const { group_id, slug, title, content, format } = UpdateGroupWikiPageSchema.parse(params.arguments);
|
|
8623
8741
|
const wikiPage = await updateGroupWikiPage(group_id, slug, title, content, format);
|
|
8624
8742
|
return {
|
|
@@ -8626,6 +8744,7 @@ async function handleToolCall(params) {
|
|
|
8626
8744
|
};
|
|
8627
8745
|
}
|
|
8628
8746
|
case "delete_group_wiki_page": {
|
|
8747
|
+
rejectIfStrictProjectScope("delete_group_wiki_page");
|
|
8629
8748
|
const { group_id, slug } = DeleteGroupWikiPageSchema.parse(params.arguments);
|
|
8630
8749
|
await deleteGroupWikiPage(group_id, slug);
|
|
8631
8750
|
return {
|
|
@@ -9148,6 +9267,7 @@ async function handleToolCall(params) {
|
|
|
9148
9267
|
};
|
|
9149
9268
|
}
|
|
9150
9269
|
case "list_group_merge_requests": {
|
|
9270
|
+
rejectIfStrictProjectScope("list_group_merge_requests");
|
|
9151
9271
|
const { group_id, ...options } = ListGroupMergeRequestsSchema.parse(params.arguments);
|
|
9152
9272
|
const cleanedOptions = cleanMutuallyExclusiveIdUsernameOptions(options, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS);
|
|
9153
9273
|
const mergeRequests = await listGroupMergeRequests(group_id, cleanedOptions);
|
|
@@ -9267,6 +9387,7 @@ async function handleToolCall(params) {
|
|
|
9267
9387
|
};
|
|
9268
9388
|
}
|
|
9269
9389
|
case "list_group_milestones": {
|
|
9390
|
+
rejectIfStrictProjectScope("list_group_milestones");
|
|
9270
9391
|
const { group_id, ...options } = ListGroupMilestonesSchema.parse(params.arguments);
|
|
9271
9392
|
const milestones = await listGroupMilestones(group_id, options);
|
|
9272
9393
|
return {
|
|
@@ -9279,6 +9400,7 @@ async function handleToolCall(params) {
|
|
|
9279
9400
|
};
|
|
9280
9401
|
}
|
|
9281
9402
|
case "get_group_milestone": {
|
|
9403
|
+
rejectIfStrictProjectScope("get_group_milestone");
|
|
9282
9404
|
const { group_id, milestone_id } = GetGroupMilestoneSchema.parse(params.arguments);
|
|
9283
9405
|
const milestone = await getGroupMilestone(group_id, milestone_id);
|
|
9284
9406
|
return {
|
|
@@ -9291,6 +9413,7 @@ async function handleToolCall(params) {
|
|
|
9291
9413
|
};
|
|
9292
9414
|
}
|
|
9293
9415
|
case "create_group_milestone": {
|
|
9416
|
+
rejectIfStrictProjectScope("create_group_milestone");
|
|
9294
9417
|
const { group_id, ...options } = CreateGroupMilestoneSchema.parse(params.arguments);
|
|
9295
9418
|
const milestone = await createGroupMilestone(group_id, options);
|
|
9296
9419
|
return {
|
|
@@ -9303,6 +9426,7 @@ async function handleToolCall(params) {
|
|
|
9303
9426
|
};
|
|
9304
9427
|
}
|
|
9305
9428
|
case "edit_group_milestone": {
|
|
9429
|
+
rejectIfStrictProjectScope("edit_group_milestone");
|
|
9306
9430
|
const { group_id, milestone_id, ...options } = EditGroupMilestoneSchema.parse(params.arguments);
|
|
9307
9431
|
const milestone = await editGroupMilestone(group_id, milestone_id, options);
|
|
9308
9432
|
return {
|
|
@@ -9315,6 +9439,7 @@ async function handleToolCall(params) {
|
|
|
9315
9439
|
};
|
|
9316
9440
|
}
|
|
9317
9441
|
case "delete_group_milestone": {
|
|
9442
|
+
rejectIfStrictProjectScope("delete_group_milestone");
|
|
9318
9443
|
const { group_id, milestone_id } = DeleteGroupMilestoneSchema.parse(params.arguments);
|
|
9319
9444
|
await deleteGroupMilestone(group_id, milestone_id);
|
|
9320
9445
|
return {
|
|
@@ -9330,6 +9455,7 @@ async function handleToolCall(params) {
|
|
|
9330
9455
|
};
|
|
9331
9456
|
}
|
|
9332
9457
|
case "get_group_milestone_issue": {
|
|
9458
|
+
rejectIfStrictProjectScope("get_group_milestone_issue");
|
|
9333
9459
|
const { group_id, milestone_id, ...options } = GetGroupMilestoneIssuesSchema.parse(params.arguments);
|
|
9334
9460
|
const issues = await getGroupMilestoneIssues(group_id, milestone_id, options);
|
|
9335
9461
|
return {
|
|
@@ -9342,6 +9468,7 @@ async function handleToolCall(params) {
|
|
|
9342
9468
|
};
|
|
9343
9469
|
}
|
|
9344
9470
|
case "get_group_milestone_merge_requests": {
|
|
9471
|
+
rejectIfStrictProjectScope("get_group_milestone_merge_requests");
|
|
9345
9472
|
const { group_id, milestone_id, ...options } = GetGroupMilestoneMergeRequestsSchema.parse(params.arguments);
|
|
9346
9473
|
const mergeRequests = await getGroupMilestoneMergeRequests(group_id, milestone_id, options);
|
|
9347
9474
|
return {
|
|
@@ -9354,6 +9481,7 @@ async function handleToolCall(params) {
|
|
|
9354
9481
|
};
|
|
9355
9482
|
}
|
|
9356
9483
|
case "get_group_milestone_burndown_events": {
|
|
9484
|
+
rejectIfStrictProjectScope("get_group_milestone_burndown_events");
|
|
9357
9485
|
const { group_id, milestone_id, ...options } = GetGroupMilestoneBurndownEventsSchema.parse(params.arguments);
|
|
9358
9486
|
const events = await getGroupMilestoneBurndownEvents(group_id, milestone_id, options);
|
|
9359
9487
|
return {
|
|
@@ -9411,6 +9539,7 @@ async function handleToolCall(params) {
|
|
|
9411
9539
|
};
|
|
9412
9540
|
}
|
|
9413
9541
|
case "list_group_iterations": {
|
|
9542
|
+
rejectIfStrictProjectScope("list_group_iterations");
|
|
9414
9543
|
const args = ListGroupIterationsSchema.parse(params.arguments);
|
|
9415
9544
|
const iterations = await listGroupIterations(args.group_id, args);
|
|
9416
9545
|
return {
|
|
@@ -9609,6 +9738,7 @@ async function handleToolCall(params) {
|
|
|
9609
9738
|
};
|
|
9610
9739
|
}
|
|
9611
9740
|
case "list_events": {
|
|
9741
|
+
rejectIfStrictProjectScope("list_events");
|
|
9612
9742
|
const args = ListEventsSchema.parse(params.arguments);
|
|
9613
9743
|
const events = await listEvents(args);
|
|
9614
9744
|
return {
|
|
@@ -9744,6 +9874,27 @@ async function handleToolCall(params) {
|
|
|
9744
9874
|
content: [{ type: "text", text: JSON.stringify(webhooks) }],
|
|
9745
9875
|
};
|
|
9746
9876
|
}
|
|
9877
|
+
case "create_webhook": {
|
|
9878
|
+
const args = CreateWebhookSchema.parse(params.arguments);
|
|
9879
|
+
const webhook = await createWebhook(args);
|
|
9880
|
+
return {
|
|
9881
|
+
content: [{ type: "text", text: JSON.stringify(webhook) }],
|
|
9882
|
+
};
|
|
9883
|
+
}
|
|
9884
|
+
case "update_webhook": {
|
|
9885
|
+
const args = UpdateWebhookSchema.parse(params.arguments);
|
|
9886
|
+
const webhook = await updateWebhook(args);
|
|
9887
|
+
return {
|
|
9888
|
+
content: [{ type: "text", text: JSON.stringify(webhook) }],
|
|
9889
|
+
};
|
|
9890
|
+
}
|
|
9891
|
+
case "delete_webhook": {
|
|
9892
|
+
const args = DeleteWebhookSchema.parse(params.arguments);
|
|
9893
|
+
const result = await deleteWebhook(args);
|
|
9894
|
+
return {
|
|
9895
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
9896
|
+
};
|
|
9897
|
+
}
|
|
9747
9898
|
case "list_webhook_events": {
|
|
9748
9899
|
const args = ListWebhookEventsSchema.parse(params.arguments);
|
|
9749
9900
|
const events = await listWebhookEvents(args);
|
|
@@ -9981,8 +10132,8 @@ function buildDownloadProxyDeps() {
|
|
|
9981
10132
|
encodeGitLabPathSegment,
|
|
9982
10133
|
encodeGitLabPath,
|
|
9983
10134
|
getEffectiveProjectId,
|
|
9984
|
-
|
|
9985
|
-
fetch:
|
|
10135
|
+
getDispatcherForUrl: clientPool.getDispatcherForUrl.bind(clientPool),
|
|
10136
|
+
fetch: undiciFetch,
|
|
9986
10137
|
logger,
|
|
9987
10138
|
};
|
|
9988
10139
|
}
|
|
@@ -10183,7 +10334,28 @@ async function startStreamableHTTPServer() {
|
|
|
10183
10334
|
const privateToken = req.headers["private-token"] || "";
|
|
10184
10335
|
const jobToken = req.headers["job-token"] || "";
|
|
10185
10336
|
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
|
|
10337
|
+
const requestedProjectScope = req.headers["x-gitlab-allowed-project-ids"]?.trim();
|
|
10186
10338
|
let apiUrl = GITLAB_API_URL; // Default API URL
|
|
10339
|
+
let allowedProjectIds;
|
|
10340
|
+
// Only process the requested project scope if the feature is enabled
|
|
10341
|
+
if (ENABLE_DYNAMIC_PROJECT_SCOPE && requestedProjectScope !== undefined) {
|
|
10342
|
+
const requested = requestedProjectScope
|
|
10343
|
+
.split(",")
|
|
10344
|
+
.map(id => id.trim())
|
|
10345
|
+
.filter(Boolean);
|
|
10346
|
+
if (requested.length === 0) {
|
|
10347
|
+
logger.warn("Empty X-GitLab-Allowed-Project-Ids provided");
|
|
10348
|
+
return null;
|
|
10349
|
+
}
|
|
10350
|
+
if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
|
|
10351
|
+
const outOfScope = requested.filter(id => !GITLAB_ALLOWED_PROJECT_IDS.includes(id));
|
|
10352
|
+
if (outOfScope.length > 0) {
|
|
10353
|
+
logger.warn(`X-GitLab-Allowed-Project-Ids requested projects outside GITLAB_ALLOWED_PROJECT_IDS: ${outOfScope.join(", ")}`);
|
|
10354
|
+
return null; // Reject if the header tries to widen the configured allowlist
|
|
10355
|
+
}
|
|
10356
|
+
}
|
|
10357
|
+
allowedProjectIds = requested;
|
|
10358
|
+
}
|
|
10187
10359
|
// Only process dynamic URL if the feature is enabled
|
|
10188
10360
|
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
|
|
10189
10361
|
try {
|
|
@@ -10219,7 +10391,7 @@ async function startStreamableHTTPServer() {
|
|
|
10219
10391
|
}
|
|
10220
10392
|
// Validate token and return AuthData object
|
|
10221
10393
|
if (token && header && validateToken(token)) {
|
|
10222
|
-
return { header, token, lastUsed: Date.now(), apiUrl };
|
|
10394
|
+
return { header, token, lastUsed: Date.now(), apiUrl, allowedProjectIds };
|
|
10223
10395
|
}
|
|
10224
10396
|
return null;
|
|
10225
10397
|
};
|
|
@@ -10369,6 +10541,7 @@ async function startStreamableHTTPServer() {
|
|
|
10369
10541
|
header: headerAuth.header,
|
|
10370
10542
|
token: headerAuth.token,
|
|
10371
10543
|
apiUrl: headerAuth.apiUrl,
|
|
10544
|
+
allowedProjectIds: headerAuth.allowedProjectIds,
|
|
10372
10545
|
};
|
|
10373
10546
|
freshAuthPresent = true;
|
|
10374
10547
|
}
|
|
@@ -10397,7 +10570,12 @@ async function startStreamableHTTPServer() {
|
|
|
10397
10570
|
if (looksLikeStatelessSessionId(incomingSid)) {
|
|
10398
10571
|
const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
|
|
10399
10572
|
if (opened) {
|
|
10400
|
-
effective = {
|
|
10573
|
+
effective = {
|
|
10574
|
+
header: opened.h,
|
|
10575
|
+
token: opened.t,
|
|
10576
|
+
apiUrl: opened.u,
|
|
10577
|
+
allowedProjectIds: opened.p,
|
|
10578
|
+
};
|
|
10401
10579
|
metrics.statelessAuthFromSealedSid++;
|
|
10402
10580
|
}
|
|
10403
10581
|
else {
|
|
@@ -10443,6 +10621,7 @@ async function startStreamableHTTPServer() {
|
|
|
10443
10621
|
header: effective.header,
|
|
10444
10622
|
token: effective.token,
|
|
10445
10623
|
apiUrl: effective.apiUrl,
|
|
10624
|
+
allowedProjectIds: effective.allowedProjectIds,
|
|
10446
10625
|
});
|
|
10447
10626
|
if (freshAuthPresent) {
|
|
10448
10627
|
metrics.statelessSidRotated++;
|
|
@@ -10461,6 +10640,7 @@ async function startStreamableHTTPServer() {
|
|
|
10461
10640
|
lastUsed: Date.now(),
|
|
10462
10641
|
apiUrl: effective.apiUrl,
|
|
10463
10642
|
publicBaseUrl: getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY),
|
|
10643
|
+
allowedProjectIds: effective.allowedProjectIds,
|
|
10464
10644
|
};
|
|
10465
10645
|
// Step 4: create a fresh transport per request.
|
|
10466
10646
|
// DNS rebinding protection is enforced by requireMcpHostAndOrigin middleware;
|
|
@@ -10987,6 +11167,7 @@ async function startStreamableHTTPServer() {
|
|
|
10987
11167
|
lastUsed: authData.lastUsed,
|
|
10988
11168
|
apiUrl: authData.apiUrl,
|
|
10989
11169
|
publicBaseUrl: authData.publicBaseUrl,
|
|
11170
|
+
allowedProjectIds: authData.allowedProjectIds,
|
|
10990
11171
|
};
|
|
10991
11172
|
// Run the entire request handling within AsyncLocalStorage context
|
|
10992
11173
|
await sessionAuthStore.run(ctx, handleRequest);
|
|
@@ -11065,6 +11246,7 @@ async function startStreamableHTTPServer() {
|
|
|
11065
11246
|
lastUsed: authData.lastUsed,
|
|
11066
11247
|
apiUrl: authData.apiUrl,
|
|
11067
11248
|
publicBaseUrl: authData.publicBaseUrl,
|
|
11249
|
+
allowedProjectIds: authData.allowedProjectIds,
|
|
11068
11250
|
};
|
|
11069
11251
|
await sessionAuthStore.run(ctx, handleGetRequest);
|
|
11070
11252
|
}
|