@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/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 nodeFetch from "node-fetch";
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(nodeFetch, defaultAuthRetryConfig());
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(nodeFetch, newJar) : nodeFetch, defaultAuthRetryConfig());
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(nodeFetch, defaultAuthRetryConfig());
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
- initialSessionRequestMade = response.ok || response.status === 401;
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 agent = clientPool.getAgentFunctionForUrl(effectiveApiUrl);
1110
+ const dispatcher = clientPool.getDispatcherForUrl(effectiveApiUrl);
1064
1111
  return {
1065
1112
  headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
1066
- agent: agent,
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 {import("node-fetch").Response} response - The response from GitLab API
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
- if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
1267
+ const allowedProjectIds = getEffectiveAllowedProjectIds();
1268
+ if (allowedProjectIds.length > 0) {
1221
1269
  // If there's only one allowed project, use it as default
1222
- if (GITLAB_ALLOWED_PROJECT_IDS.length === 1 && !projectId) {
1223
- return GITLAB_ALLOWED_PROJECT_IDS[0];
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 && !GITLAB_ALLOWED_PROJECT_IDS.includes(projectId)) {
1227
- throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}`);
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 && GITLAB_ALLOWED_PROJECT_IDS.length > 1) {
1231
- throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}). Please specify a project ID.`);
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 || GITLAB_ALLOWED_PROJECT_IDS[0];
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 (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
1249
- throw new Error(`${toolName} is not allowed when GITLAB_ALLOWED_PROJECT_IDS is set (server access is restricted to configured projects)`);
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
- projectId = decodeURIComponent(projectId); // Decode project ID
1395
- const effectiveProjectId = getEffectiveProjectId(projectId);
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) : undefined;
1490
- const endpoint = decodedProjectId
1491
- ? `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(decodedProjectId))}/merge_requests`
1492
- : `${getEffectiveApiUrl()}/merge_requests`;
1493
- const url = new URL(endpoint);
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" && GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
2183
- throw new Error("group:<id-or-path> cannot be used when GITLAB_ALLOWED_PROJECT_IDS is set, because the project allowlist does not cover groups");
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
- GITLAB_ALLOWED_PROJECT_IDS.length === 0 &&
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 projects = (await response.json());
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
- // GitLab API doesn't return these headers for results > 10,000
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) : Math.ceil(count / perPage),
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
- if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) {
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 && GITLAB_ALLOWED_PROJECT_IDS.includes(fullPath)) ||
6774
- (numericId !== undefined && GITLAB_ALLOWED_PROJECT_IDS.includes(numericId));
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: ${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}`);
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 (GITLAB_ALLOWED_PROJECT_IDS.length === 0) {
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"]; // Let form-data set the correct Content-Type with boundary
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
- getAgentFunctionForUrl: clientPool.getAgentFunctionForUrl.bind(clientPool),
9985
- fetch: nodeFetch,
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 = { header: opened.h, token: opened.t, apiUrl: opened.u };
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
  }