@zereight/mcp-gitlab 2.1.57 → 2.1.59

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");
@@ -104,8 +104,8 @@ import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from
104
104
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
105
105
  import { GitLabClientPool } from "./gitlab-client-pool.js";
106
106
  import { allTools, readOnlyTools, destructiveTools, deleteTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
107
- import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
108
- CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateGroupMilestoneSchema, CreateRepositorySchema, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, UnprotectBranchSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupMilestoneSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, EditGroupMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetGroupMilestoneSchema, GetGroupMilestoneIssuesSchema, GetGroupMilestoneMergeRequestsSchema, GetGroupMilestoneBurndownEventsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
107
+ import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, ErasePipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
108
+ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateGroupMilestoneSchema, CreateRepositorySchema, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, UnprotectBranchSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupMilestoneSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, EditGroupMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetGroupMilestoneSchema, GetGroupMilestoneIssuesSchema, GetGroupMilestoneMergeRequestsSchema, GetGroupMilestoneBurndownEventsSchema, GetDeploymentSchema, CreateDeploymentSchema, UpdateDeploymentSchema, DeploymentApprovalSchema, ListDeploymentMergeRequestsSchema, GetEnvironmentSchema, UpdateEnvironmentSchema, StopEnvironmentSchema, StopStaleEnvironmentsSchema, DeleteReviewAppEnvironmentsSchema, PipelineTriggerIdSchema, ListPipelineTriggersSchema, CreatePipelineTriggerSchema, UpdatePipelineTriggerSchema, TriggerPipelineSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetPipelineVariablesSchema, UpdatePipelineMetadataSchema, DeletePipelineSchema, PipelineReportSchema, WaitForPipelineSchema, WaitForPipelineJobSchema, PlayPipelineJobsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
109
109
  // Discussion Schemas
110
110
  GitLabDiscussionNoteSchema, // Added
111
111
  GitLabDiscussionSchema,
@@ -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}`));
@@ -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
@@ -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
  }
@@ -5121,11 +5184,12 @@ async function listPipelines(projectId, options = {}) {
5121
5184
  * @param {number} pipelineId - The ID of the pipeline
5122
5185
  * @returns {Promise<GitLabPipeline>} Pipeline details
5123
5186
  */
5124
- async function getPipeline(projectId, pipelineId) {
5187
+ async function getPipeline(projectId, pipelineId, signal) {
5125
5188
  projectId = decodeURIComponent(projectId); // Decode project ID
5126
5189
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}`);
5127
5190
  const response = await fetch(url.toString(), {
5128
5191
  ...getFetchConfig(),
5192
+ signal,
5129
5193
  });
5130
5194
  if (response.status === 404) {
5131
5195
  throw new Error(`Pipeline not found`);
@@ -5134,6 +5198,41 @@ async function getPipeline(projectId, pipelineId) {
5134
5198
  const data = await response.json();
5135
5199
  return GitLabPipelineSchema.parse(data);
5136
5200
  }
5201
+ async function getPipelineVariables(projectId, pipelineId, page, perPage) {
5202
+ projectId = decodeURIComponent(projectId);
5203
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/variables`);
5204
+ if (page !== undefined)
5205
+ url.searchParams.set("page", String(page));
5206
+ if (perPage !== undefined)
5207
+ url.searchParams.set("per_page", String(perPage));
5208
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
5209
+ await handleGitLabError(response);
5210
+ return response.json();
5211
+ }
5212
+ async function getPipelineReport(projectId, pipelineId, summary = false, page, perPage) {
5213
+ projectId = decodeURIComponent(projectId);
5214
+ const endpoint = summary ? "test_report_summary" : "test_report";
5215
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/${endpoint}`);
5216
+ if (page !== undefined)
5217
+ url.searchParams.set("page", String(page));
5218
+ if (perPage !== undefined)
5219
+ url.searchParams.set("per_page", String(perPage));
5220
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
5221
+ await handleGitLabError(response);
5222
+ return response.json();
5223
+ }
5224
+ async function deletePipeline(projectId, pipelineId) {
5225
+ projectId = decodeURIComponent(projectId);
5226
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}`, { ...getFetchConfig(), method: "DELETE" });
5227
+ await handleGitLabError(response);
5228
+ return response.status === 204 ? { deleted: true } : response.json();
5229
+ }
5230
+ async function updatePipelineMetadata(projectId, pipelineId, name) {
5231
+ projectId = decodeURIComponent(projectId);
5232
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/metadata`, { ...getFetchConfig(), method: "PUT", body: JSON.stringify({ name }) });
5233
+ await handleGitLabError(response);
5234
+ return response.json();
5235
+ }
5137
5236
  /**
5138
5237
  * List deployments in a GitLab project
5139
5238
  *
@@ -5165,7 +5264,7 @@ async function listDeployments(projectId, options = {}) {
5165
5264
  */
5166
5265
  async function getDeployment(projectId, deploymentId) {
5167
5266
  projectId = decodeURIComponent(projectId);
5168
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments/${deploymentId}`);
5267
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments/${encodeGitLabPathSegment(deploymentId)}`);
5169
5268
  const response = await fetch(url.toString(), {
5170
5269
  ...getFetchConfig(),
5171
5270
  });
@@ -5176,6 +5275,20 @@ async function getDeployment(projectId, deploymentId) {
5176
5275
  const data = await response.json();
5177
5276
  return GitLabDeploymentSchema.parse(data);
5178
5277
  }
5278
+ async function deploymentRequest(projectId, path = "", method = "GET", body, query) {
5279
+ projectId = decodeURIComponent(projectId);
5280
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments${path}`);
5281
+ for (const [key, value] of Object.entries(query ?? {})) {
5282
+ if (value !== undefined)
5283
+ url.searchParams.set(key, String(value));
5284
+ }
5285
+ const response = await fetch(url.toString(), {
5286
+ ...getFetchConfig(), method,
5287
+ body: body === undefined ? undefined : JSON.stringify(body),
5288
+ });
5289
+ await handleGitLabError(response);
5290
+ return response.status === 204 ? { success: true } : response.json();
5291
+ }
5179
5292
  /**
5180
5293
  * List environments in a GitLab project
5181
5294
  *
@@ -5207,7 +5320,7 @@ async function listEnvironments(projectId, options = {}) {
5207
5320
  */
5208
5321
  async function getEnvironment(projectId, environmentId) {
5209
5322
  projectId = decodeURIComponent(projectId);
5210
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments/${environmentId}`);
5323
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments/${encodeGitLabPathSegment(environmentId)}`);
5211
5324
  const response = await fetch(url.toString(), {
5212
5325
  ...getFetchConfig(),
5213
5326
  });
@@ -5218,6 +5331,36 @@ async function getEnvironment(projectId, environmentId) {
5218
5331
  const data = await response.json();
5219
5332
  return GitLabEnvironmentSchema.parse(data);
5220
5333
  }
5334
+ async function environmentRequest(projectId, path, method, body, query) {
5335
+ projectId = decodeURIComponent(projectId);
5336
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments${path}`);
5337
+ for (const [key, value] of Object.entries(query ?? {}))
5338
+ if (value !== undefined)
5339
+ url.searchParams.set(key, String(value));
5340
+ const response = await fetch(url.toString(), { ...getFetchConfig(), method, body: body === undefined ? undefined : JSON.stringify(body) });
5341
+ await handleGitLabError(response);
5342
+ return response.status === 204 ? { success: true } : response.json();
5343
+ }
5344
+ async function pipelineTriggerRequest(projectId, path = "", method = "GET", body) {
5345
+ projectId = decodeURIComponent(projectId);
5346
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/triggers${path}`, { ...getFetchConfig(), method, body: body === undefined ? undefined : JSON.stringify(body) });
5347
+ await handleGitLabError(response);
5348
+ return response.status === 204 ? { success: true } : response.json();
5349
+ }
5350
+ async function triggerPipeline(projectId, token, ref, variables, inputs) {
5351
+ projectId = decodeURIComponent(projectId);
5352
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/trigger/pipeline`);
5353
+ url.searchParams.set("token", token);
5354
+ url.searchParams.set("ref", ref);
5355
+ const body = {};
5356
+ if (variables)
5357
+ body.variables = variables;
5358
+ if (inputs)
5359
+ body.inputs = inputs;
5360
+ const response = await fetch(url.toString(), { ...getFetchConfig(), method: "POST", body: Object.keys(body).length ? JSON.stringify(body) : undefined });
5361
+ await handleGitLabError(response);
5362
+ return response.json();
5363
+ }
5221
5364
  /**
5222
5365
  * List all jobs in a specific pipeline
5223
5366
  *
@@ -5282,11 +5425,12 @@ async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
5282
5425
  const data = await response.json();
5283
5426
  return z.array(GitLabPipelineTriggerJobSchema).parse(data);
5284
5427
  }
5285
- async function getPipelineJob(projectId, jobId) {
5428
+ async function getPipelineJob(projectId, jobId, signal) {
5286
5429
  projectId = decodeURIComponent(projectId); // Decode project ID
5287
5430
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}`);
5288
5431
  const response = await fetch(url.toString(), {
5289
5432
  ...getFetchConfig(),
5433
+ signal,
5290
5434
  });
5291
5435
  if (response.status === 404) {
5292
5436
  throw new Error(`Job not found`);
@@ -5756,13 +5900,15 @@ async function deletePipelineScheduleVariable(projectId, pipelineScheduleId, key
5756
5900
  * @param {Object} variables - Optional job variables
5757
5901
  * @returns {Promise<GitLabPipelineJob>} The run job
5758
5902
  */
5759
- async function playPipelineJob(projectId, jobId, variables) {
5903
+ async function playPipelineJob(projectId, jobId, variables, jobInputs) {
5760
5904
  projectId = decodeURIComponent(projectId);
5761
5905
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/play`);
5762
5906
  const body = {};
5763
5907
  if (variables && variables.length > 0) {
5764
5908
  body.job_variables_attributes = variables;
5765
5909
  }
5910
+ if (jobInputs)
5911
+ body.job_inputs = jobInputs;
5766
5912
  const response = await fetch(url.toString(), {
5767
5913
  ...getFetchConfig(),
5768
5914
  method: "POST",
@@ -5772,6 +5918,15 @@ async function playPipelineJob(projectId, jobId, variables) {
5772
5918
  const data = await response.json();
5773
5919
  return GitLabPipelineJobSchema.parse(data);
5774
5920
  }
5921
+ async function playPipelineJobs(projectId, jobIds, variables, timeoutSeconds = 300, pollIntervalSeconds = 5) {
5922
+ const jobs = [];
5923
+ for (const jobId of jobIds) {
5924
+ const played = await playPipelineJob(projectId, jobId, variables);
5925
+ const completed = await waitForStatus(signal => getPipelineJob(projectId, played.id, signal), TERMINAL_JOB_STATUSES, timeoutSeconds, pollIntervalSeconds);
5926
+ jobs.push(completed);
5927
+ }
5928
+ return jobs;
5929
+ }
5775
5930
  /**
5776
5931
  * Retry a job
5777
5932
  *
@@ -5779,17 +5934,46 @@ async function playPipelineJob(projectId, jobId, variables) {
5779
5934
  * @param {number} jobId - The ID of the job to retry
5780
5935
  * @returns {Promise<GitLabPipelineJob>} The retried job
5781
5936
  */
5782
- async function retryPipelineJob(projectId, jobId) {
5937
+ async function retryPipelineJob(projectId, jobId, jobInputs) {
5783
5938
  projectId = decodeURIComponent(projectId);
5784
5939
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/retry`);
5785
5940
  const response = await fetch(url.toString(), {
5786
5941
  ...getFetchConfig(),
5787
5942
  method: "POST",
5943
+ body: jobInputs ? JSON.stringify({ job_inputs: jobInputs }) : undefined,
5788
5944
  });
5789
5945
  await handleGitLabError(response);
5790
5946
  const data = await response.json();
5791
5947
  return GitLabPipelineJobSchema.parse(data);
5792
5948
  }
5949
+ async function erasePipelineJob(projectId, jobId) {
5950
+ projectId = decodeURIComponent(projectId);
5951
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/erase`, { ...getFetchConfig(), method: "POST" });
5952
+ await handleGitLabError(response);
5953
+ return response.status === 204 ? { erased: true } : response.json();
5954
+ }
5955
+ const TERMINAL_PIPELINE_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
5956
+ const TERMINAL_JOB_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
5957
+ async function waitForStatus(fetchStatus, terminalStatuses, timeoutSeconds = 300, pollIntervalSeconds = 5) {
5958
+ const deadline = Date.now() + timeoutSeconds * 1000;
5959
+ const fetchBeforeDeadline = async () => {
5960
+ const remaining = deadline - Date.now();
5961
+ if (remaining <= 0)
5962
+ throw new Error("Timed out waiting for terminal status");
5963
+ return fetchStatus(AbortSignal.timeout(remaining));
5964
+ };
5965
+ let value = await fetchBeforeDeadline();
5966
+ while (!terminalStatuses.has(value.status)) {
5967
+ const remaining = deadline - Date.now();
5968
+ if (remaining <= 0)
5969
+ break;
5970
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollIntervalSeconds * 1000, remaining)));
5971
+ value = await fetchBeforeDeadline();
5972
+ }
5973
+ if (!terminalStatuses.has(value.status))
5974
+ throw new Error(`Timed out waiting for terminal status; last status: ${value.status}`);
5975
+ return value;
5976
+ }
5793
5977
  /**
5794
5978
  * Cancel a job
5795
5979
  *
@@ -6813,15 +6997,16 @@ async function resolveProjectFullPath(projectId) {
6813
6997
  * Allowlist entries may be numeric IDs or full namespace paths.
6814
6998
  */
6815
6999
  function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
6816
- if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) {
7000
+ const allowedProjectIds = getEffectiveAllowedProjectIds();
7001
+ if (allowedProjectIds.length === 0) {
6817
7002
  return;
6818
7003
  }
6819
7004
  const fullPath = project?.fullPath ?? undefined;
6820
7005
  const numericId = project?.id?.match(/^gid:\/\/gitlab\/Project\/(\d+)$/)?.[1];
6821
- const allowed = (fullPath !== undefined && GITLAB_ALLOWED_PROJECT_IDS.includes(fullPath)) ||
6822
- (numericId !== undefined && GITLAB_ALLOWED_PROJECT_IDS.includes(numericId));
7006
+ const allowed = (fullPath !== undefined && allowedProjectIds.includes(fullPath)) ||
7007
+ (numericId !== undefined && allowedProjectIds.includes(numericId));
6823
7008
  if (!allowed) {
6824
- 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(", ")}`);
7009
+ throw new Error(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
6825
7010
  }
6826
7011
  }
6827
7012
  /**
@@ -6831,7 +7016,7 @@ function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
6831
7016
  * is not configured.
6832
7017
  */
6833
7018
  async function ensureVulnerabilityProjectAllowed(vulnerabilityId) {
6834
- if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) {
7019
+ if (getEffectiveAllowedProjectIds().length === 0) {
6835
7020
  return;
6836
7021
  }
6837
7022
  const data = await executeGraphQL(`query getVulnerabilityProject($id: VulnerabilityID!) {
@@ -7558,6 +7743,7 @@ async function handleToolCall(params) {
7558
7743
  };
7559
7744
  }
7560
7745
  case "search_code": {
7746
+ rejectIfStrictProjectScope("search_code");
7561
7747
  const args = SearchCodeSchema.parse(params.arguments);
7562
7748
  const results = await searchBlobs({
7563
7749
  search: args.search,
@@ -7588,6 +7774,7 @@ async function handleToolCall(params) {
7588
7774
  };
7589
7775
  }
7590
7776
  case "search_group_code": {
7777
+ rejectIfStrictProjectScope("search_group_code");
7591
7778
  const args = SearchGroupCodeSchema.parse(params.arguments);
7592
7779
  const results = await searchBlobs({
7593
7780
  search: args.search,
@@ -7850,6 +8037,7 @@ async function handleToolCall(params) {
7850
8037
  };
7851
8038
  }
7852
8039
  case "list_todos": {
8040
+ rejectIfStrictProjectScope("list_todos");
7853
8041
  const args = ListTodosSchema.parse(params.arguments);
7854
8042
  const todos = await listTodos(args);
7855
8043
  return {
@@ -7857,6 +8045,7 @@ async function handleToolCall(params) {
7857
8045
  };
7858
8046
  }
7859
8047
  case "mark_todo_done": {
8048
+ rejectIfStrictProjectScope("mark_todo_done");
7860
8049
  const args = MarkTodoDoneSchema.parse(params.arguments);
7861
8050
  const todo = await markTodoDone(args.id);
7862
8051
  return {
@@ -7864,6 +8053,7 @@ async function handleToolCall(params) {
7864
8053
  };
7865
8054
  }
7866
8055
  case "mark_all_todos_done": {
8056
+ rejectIfStrictProjectScope("mark_all_todos_done");
7867
8057
  MarkAllTodosDoneSchema.parse(params.arguments);
7868
8058
  await markAllTodosDone();
7869
8059
  return {
@@ -8115,6 +8305,7 @@ async function handleToolCall(params) {
8115
8305
  };
8116
8306
  }
8117
8307
  case "list_group_members": {
8308
+ rejectIfStrictProjectScope("list_group_members");
8118
8309
  const args = ListGroupMembersSchema.parse(params.arguments);
8119
8310
  const { group_id, ...options } = args;
8120
8311
  const members = await listGroupMembers(group_id, options);
@@ -8637,6 +8828,7 @@ async function handleToolCall(params) {
8637
8828
  };
8638
8829
  }
8639
8830
  case "list_group_wiki_pages": {
8831
+ rejectIfStrictProjectScope("list_group_wiki_pages");
8640
8832
  const { group_id, page, per_page, with_content, render_html } = ListGroupWikiPagesSchema.parse(params.arguments);
8641
8833
  const wikiPages = await listGroupWikiPages(group_id, {
8642
8834
  page,
@@ -8649,6 +8841,7 @@ async function handleToolCall(params) {
8649
8841
  };
8650
8842
  }
8651
8843
  case "get_group_wiki_page": {
8844
+ rejectIfStrictProjectScope("get_group_wiki_page");
8652
8845
  const { group_id, slug, render_html } = GetGroupWikiPageSchema.parse(params.arguments);
8653
8846
  const wikiPage = await getGroupWikiPage(group_id, slug, render_html);
8654
8847
  return {
@@ -8656,6 +8849,7 @@ async function handleToolCall(params) {
8656
8849
  };
8657
8850
  }
8658
8851
  case "create_group_wiki_page": {
8852
+ rejectIfStrictProjectScope("create_group_wiki_page");
8659
8853
  const { group_id, title, content, format } = CreateGroupWikiPageSchema.parse(params.arguments);
8660
8854
  const wikiPage = await createGroupWikiPage(group_id, title, content, format);
8661
8855
  return {
@@ -8663,6 +8857,7 @@ async function handleToolCall(params) {
8663
8857
  };
8664
8858
  }
8665
8859
  case "update_group_wiki_page": {
8860
+ rejectIfStrictProjectScope("update_group_wiki_page");
8666
8861
  const { group_id, slug, title, content, format } = UpdateGroupWikiPageSchema.parse(params.arguments);
8667
8862
  const wikiPage = await updateGroupWikiPage(group_id, slug, title, content, format);
8668
8863
  return {
@@ -8670,6 +8865,7 @@ async function handleToolCall(params) {
8670
8865
  };
8671
8866
  }
8672
8867
  case "delete_group_wiki_page": {
8868
+ rejectIfStrictProjectScope("delete_group_wiki_page");
8673
8869
  const { group_id, slug } = DeleteGroupWikiPageSchema.parse(params.arguments);
8674
8870
  await deleteGroupWikiPage(group_id, slug);
8675
8871
  return {
@@ -8720,6 +8916,26 @@ async function handleToolCall(params) {
8720
8916
  ],
8721
8917
  };
8722
8918
  }
8919
+ case "get_pipeline_variables": {
8920
+ const { project_id, pipeline_id, page, per_page } = GetPipelineVariablesSchema.parse(params.arguments);
8921
+ return { content: [{ type: "text", text: JSON.stringify(await getPipelineVariables(project_id, pipeline_id, page, per_page)) }] };
8922
+ }
8923
+ case "get_pipeline_test_report": {
8924
+ const { project_id, pipeline_id, page, per_page } = PipelineReportSchema.parse(params.arguments);
8925
+ return { content: [{ type: "text", text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, false, page, per_page)) }] };
8926
+ }
8927
+ case "get_pipeline_test_report_summary": {
8928
+ const { project_id, pipeline_id, page, per_page } = PipelineReportSchema.parse(params.arguments);
8929
+ return { content: [{ type: "text", text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, true, page, per_page)) }] };
8930
+ }
8931
+ case "delete_pipeline": {
8932
+ const { project_id, pipeline_id } = DeletePipelineSchema.parse(params.arguments);
8933
+ return { content: [{ type: "text", text: JSON.stringify(await deletePipeline(project_id, pipeline_id)) }] };
8934
+ }
8935
+ case "update_pipeline_metadata": {
8936
+ const { project_id, pipeline_id, name } = UpdatePipelineMetadataSchema.parse(params.arguments);
8937
+ return { content: [{ type: "text", text: JSON.stringify(await updatePipelineMetadata(project_id, pipeline_id, name)) }] };
8938
+ }
8723
8939
  case "list_deployments": {
8724
8940
  const args = ListDeploymentsSchema.parse(params.arguments);
8725
8941
  const { project_id, ...options } = args;
@@ -8735,6 +8951,26 @@ async function handleToolCall(params) {
8735
8951
  content: [{ type: "text", text: JSON.stringify(deployment) }],
8736
8952
  };
8737
8953
  }
8954
+ case "create_deployment": {
8955
+ const { project_id, ...body } = CreateDeploymentSchema.parse(params.arguments);
8956
+ return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, "", "POST", body)) }] };
8957
+ }
8958
+ case "update_deployment": {
8959
+ const { project_id, deployment_id, ...body } = UpdateDeploymentSchema.parse(params.arguments);
8960
+ return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "PUT", body)) }] };
8961
+ }
8962
+ case "delete_deployment": {
8963
+ const { project_id, deployment_id } = GetDeploymentSchema.parse(params.arguments);
8964
+ return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "DELETE")) }] };
8965
+ }
8966
+ case "list_deployment_merge_requests": {
8967
+ const { project_id, deployment_id, ...query } = ListDeploymentMergeRequestsSchema.parse(params.arguments);
8968
+ return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/merge_requests`, "GET", undefined, query)) }] };
8969
+ }
8970
+ case "approve_deployment": {
8971
+ const { project_id, deployment_id, ...body } = DeploymentApprovalSchema.parse(params.arguments);
8972
+ return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/approval`, "POST", body)) }] };
8973
+ }
8738
8974
  case "list_environments": {
8739
8975
  const args = ListEnvironmentsSchema.parse(params.arguments);
8740
8976
  const { project_id, ...options } = args;
@@ -8750,6 +8986,50 @@ async function handleToolCall(params) {
8750
8986
  content: [{ type: "text", text: JSON.stringify(environment) }],
8751
8987
  };
8752
8988
  }
8989
+ case "update_environment": {
8990
+ const { project_id, environment_id, ...body } = UpdateEnvironmentSchema.parse(params.arguments);
8991
+ return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "PUT", body)) }] };
8992
+ }
8993
+ case "delete_environment": {
8994
+ const { project_id, environment_id } = GetEnvironmentSchema.parse(params.arguments);
8995
+ return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "DELETE")) }] };
8996
+ }
8997
+ case "stop_environment": {
8998
+ const { project_id, environment_id, force } = StopEnvironmentSchema.parse(params.arguments);
8999
+ return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}/stop`, "POST", undefined, { force })) }] };
9000
+ }
9001
+ case "stop_stale_environments": {
9002
+ const { project_id, before } = StopStaleEnvironmentsSchema.parse(params.arguments);
9003
+ return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, "/stop_stale", "POST", undefined, { before })) }] };
9004
+ }
9005
+ case "delete_review_app_environments": {
9006
+ const { project_id, ...query } = DeleteReviewAppEnvironmentsSchema.parse(params.arguments);
9007
+ return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, "/review_apps", "DELETE", undefined, query)) }] };
9008
+ }
9009
+ case "list_pipeline_triggers": {
9010
+ const { project_id } = ListPipelineTriggersSchema.parse(params.arguments);
9011
+ return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id))) }] };
9012
+ }
9013
+ case "get_pipeline_trigger": {
9014
+ const { project_id, trigger_id } = PipelineTriggerIdSchema.parse(params.arguments);
9015
+ return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`))) }] };
9016
+ }
9017
+ case "create_pipeline_trigger": {
9018
+ const { project_id, ...body } = CreatePipelineTriggerSchema.parse(params.arguments);
9019
+ return { content: [{ type: "text", text: JSON.stringify(await pipelineTriggerRequest(project_id, "", "POST", body)) }] };
9020
+ }
9021
+ case "update_pipeline_trigger": {
9022
+ const { project_id, trigger_id, ...body } = UpdatePipelineTriggerSchema.parse(params.arguments);
9023
+ return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "PUT", body))) }] };
9024
+ }
9025
+ case "delete_pipeline_trigger": {
9026
+ const { project_id, trigger_id } = PipelineTriggerIdSchema.parse(params.arguments);
9027
+ return { content: [{ type: "text", text: JSON.stringify(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "DELETE")) }] };
9028
+ }
9029
+ case "trigger_pipeline": {
9030
+ const { project_id, token, ref, variables, inputs } = TriggerPipelineSchema.parse(params.arguments);
9031
+ return { content: [{ type: "text", text: JSON.stringify(await triggerPipeline(project_id, token, ref, variables, inputs)) }] };
9032
+ }
8753
9033
  case "list_pipeline_jobs": {
8754
9034
  const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse(params.arguments);
8755
9035
  const jobs = await listPipelineJobs(project_id, pipeline_id, options);
@@ -9103,8 +9383,8 @@ async function handleToolCall(params) {
9103
9383
  };
9104
9384
  }
9105
9385
  case "play_pipeline_job": {
9106
- const { project_id, job_id, job_variables_attributes } = PlayPipelineJobSchema.parse(params.arguments);
9107
- const job = await playPipelineJob(project_id, job_id, job_variables_attributes);
9386
+ const { project_id, job_id, job_variables_attributes, job_inputs } = PlayPipelineJobSchema.parse(params.arguments);
9387
+ const job = await playPipelineJob(project_id, job_id, job_variables_attributes, job_inputs);
9108
9388
  return {
9109
9389
  content: [
9110
9390
  {
@@ -9114,9 +9394,14 @@ async function handleToolCall(params) {
9114
9394
  ],
9115
9395
  };
9116
9396
  }
9397
+ case "play_pipeline_jobs": {
9398
+ const { project_id, job_ids, job_variables_attributes, timeout_seconds, poll_interval_seconds } = PlayPipelineJobsSchema.parse(params.arguments);
9399
+ const jobs = await playPipelineJobs(project_id, job_ids, job_variables_attributes, timeout_seconds, poll_interval_seconds);
9400
+ return { content: [{ type: "text", text: JSON.stringify(jobs) }] };
9401
+ }
9117
9402
  case "retry_pipeline_job": {
9118
- const { project_id, job_id } = RetryPipelineJobSchema.parse(params.arguments);
9119
- const job = await retryPipelineJob(project_id, job_id);
9403
+ const { project_id, job_id, job_inputs } = RetryPipelineJobSchema.parse(params.arguments);
9404
+ const job = await retryPipelineJob(project_id, job_id, job_inputs);
9120
9405
  return {
9121
9406
  content: [
9122
9407
  {
@@ -9138,6 +9423,20 @@ async function handleToolCall(params) {
9138
9423
  ],
9139
9424
  };
9140
9425
  }
9426
+ case "erase_pipeline_job": {
9427
+ const { project_id, job_id } = ErasePipelineJobSchema.parse(params.arguments);
9428
+ return { content: [{ type: "text", text: JSON.stringify(await erasePipelineJob(project_id, job_id)) }] };
9429
+ }
9430
+ case "wait_for_pipeline": {
9431
+ const { project_id, pipeline_id, timeout_seconds, poll_interval_seconds } = WaitForPipelineSchema.parse(params.arguments);
9432
+ const pipeline = await waitForStatus(signal => getPipeline(project_id, pipeline_id, signal), TERMINAL_PIPELINE_STATUSES, timeout_seconds, poll_interval_seconds);
9433
+ return { content: [{ type: "text", text: JSON.stringify(pipeline) }] };
9434
+ }
9435
+ case "wait_for_job": {
9436
+ const { project_id, job_id, timeout_seconds, poll_interval_seconds } = WaitForPipelineJobSchema.parse(params.arguments);
9437
+ const job = await waitForStatus(signal => getPipelineJob(project_id, job_id, signal), TERMINAL_JOB_STATUSES, timeout_seconds, poll_interval_seconds);
9438
+ return { content: [{ type: "text", text: JSON.stringify(job) }] };
9439
+ }
9141
9440
  case "list_job_artifacts": {
9142
9441
  const { project_id, job_id, ...options } = ListJobArtifactsSchema.parse(params.arguments);
9143
9442
  const artifacts = await listJobArtifacts(project_id, job_id, options);
@@ -9192,6 +9491,7 @@ async function handleToolCall(params) {
9192
9491
  };
9193
9492
  }
9194
9493
  case "list_group_merge_requests": {
9494
+ rejectIfStrictProjectScope("list_group_merge_requests");
9195
9495
  const { group_id, ...options } = ListGroupMergeRequestsSchema.parse(params.arguments);
9196
9496
  const cleanedOptions = cleanMutuallyExclusiveIdUsernameOptions(options, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS);
9197
9497
  const mergeRequests = await listGroupMergeRequests(group_id, cleanedOptions);
@@ -9311,6 +9611,7 @@ async function handleToolCall(params) {
9311
9611
  };
9312
9612
  }
9313
9613
  case "list_group_milestones": {
9614
+ rejectIfStrictProjectScope("list_group_milestones");
9314
9615
  const { group_id, ...options } = ListGroupMilestonesSchema.parse(params.arguments);
9315
9616
  const milestones = await listGroupMilestones(group_id, options);
9316
9617
  return {
@@ -9323,6 +9624,7 @@ async function handleToolCall(params) {
9323
9624
  };
9324
9625
  }
9325
9626
  case "get_group_milestone": {
9627
+ rejectIfStrictProjectScope("get_group_milestone");
9326
9628
  const { group_id, milestone_id } = GetGroupMilestoneSchema.parse(params.arguments);
9327
9629
  const milestone = await getGroupMilestone(group_id, milestone_id);
9328
9630
  return {
@@ -9335,6 +9637,7 @@ async function handleToolCall(params) {
9335
9637
  };
9336
9638
  }
9337
9639
  case "create_group_milestone": {
9640
+ rejectIfStrictProjectScope("create_group_milestone");
9338
9641
  const { group_id, ...options } = CreateGroupMilestoneSchema.parse(params.arguments);
9339
9642
  const milestone = await createGroupMilestone(group_id, options);
9340
9643
  return {
@@ -9347,6 +9650,7 @@ async function handleToolCall(params) {
9347
9650
  };
9348
9651
  }
9349
9652
  case "edit_group_milestone": {
9653
+ rejectIfStrictProjectScope("edit_group_milestone");
9350
9654
  const { group_id, milestone_id, ...options } = EditGroupMilestoneSchema.parse(params.arguments);
9351
9655
  const milestone = await editGroupMilestone(group_id, milestone_id, options);
9352
9656
  return {
@@ -9359,6 +9663,7 @@ async function handleToolCall(params) {
9359
9663
  };
9360
9664
  }
9361
9665
  case "delete_group_milestone": {
9666
+ rejectIfStrictProjectScope("delete_group_milestone");
9362
9667
  const { group_id, milestone_id } = DeleteGroupMilestoneSchema.parse(params.arguments);
9363
9668
  await deleteGroupMilestone(group_id, milestone_id);
9364
9669
  return {
@@ -9374,6 +9679,7 @@ async function handleToolCall(params) {
9374
9679
  };
9375
9680
  }
9376
9681
  case "get_group_milestone_issue": {
9682
+ rejectIfStrictProjectScope("get_group_milestone_issue");
9377
9683
  const { group_id, milestone_id, ...options } = GetGroupMilestoneIssuesSchema.parse(params.arguments);
9378
9684
  const issues = await getGroupMilestoneIssues(group_id, milestone_id, options);
9379
9685
  return {
@@ -9386,6 +9692,7 @@ async function handleToolCall(params) {
9386
9692
  };
9387
9693
  }
9388
9694
  case "get_group_milestone_merge_requests": {
9695
+ rejectIfStrictProjectScope("get_group_milestone_merge_requests");
9389
9696
  const { group_id, milestone_id, ...options } = GetGroupMilestoneMergeRequestsSchema.parse(params.arguments);
9390
9697
  const mergeRequests = await getGroupMilestoneMergeRequests(group_id, milestone_id, options);
9391
9698
  return {
@@ -9398,6 +9705,7 @@ async function handleToolCall(params) {
9398
9705
  };
9399
9706
  }
9400
9707
  case "get_group_milestone_burndown_events": {
9708
+ rejectIfStrictProjectScope("get_group_milestone_burndown_events");
9401
9709
  const { group_id, milestone_id, ...options } = GetGroupMilestoneBurndownEventsSchema.parse(params.arguments);
9402
9710
  const events = await getGroupMilestoneBurndownEvents(group_id, milestone_id, options);
9403
9711
  return {
@@ -9455,6 +9763,7 @@ async function handleToolCall(params) {
9455
9763
  };
9456
9764
  }
9457
9765
  case "list_group_iterations": {
9766
+ rejectIfStrictProjectScope("list_group_iterations");
9458
9767
  const args = ListGroupIterationsSchema.parse(params.arguments);
9459
9768
  const iterations = await listGroupIterations(args.group_id, args);
9460
9769
  return {
@@ -9653,6 +9962,7 @@ async function handleToolCall(params) {
9653
9962
  };
9654
9963
  }
9655
9964
  case "list_events": {
9965
+ rejectIfStrictProjectScope("list_events");
9656
9966
  const args = ListEventsSchema.parse(params.arguments);
9657
9967
  const events = await listEvents(args);
9658
9968
  return {
@@ -10248,7 +10558,28 @@ async function startStreamableHTTPServer() {
10248
10558
  const privateToken = req.headers["private-token"] || "";
10249
10559
  const jobToken = req.headers["job-token"] || "";
10250
10560
  const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
10561
+ const requestedProjectScope = req.headers["x-gitlab-allowed-project-ids"]?.trim();
10251
10562
  let apiUrl = GITLAB_API_URL; // Default API URL
10563
+ let allowedProjectIds;
10564
+ // Only process the requested project scope if the feature is enabled
10565
+ if (ENABLE_DYNAMIC_PROJECT_SCOPE && requestedProjectScope !== undefined) {
10566
+ const requested = requestedProjectScope
10567
+ .split(",")
10568
+ .map(id => id.trim())
10569
+ .filter(Boolean);
10570
+ if (requested.length === 0) {
10571
+ logger.warn("Empty X-GitLab-Allowed-Project-Ids provided");
10572
+ return null;
10573
+ }
10574
+ if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
10575
+ const outOfScope = requested.filter(id => !GITLAB_ALLOWED_PROJECT_IDS.includes(id));
10576
+ if (outOfScope.length > 0) {
10577
+ logger.warn(`X-GitLab-Allowed-Project-Ids requested projects outside GITLAB_ALLOWED_PROJECT_IDS: ${outOfScope.join(", ")}`);
10578
+ return null; // Reject if the header tries to widen the configured allowlist
10579
+ }
10580
+ }
10581
+ allowedProjectIds = requested;
10582
+ }
10252
10583
  // Only process dynamic URL if the feature is enabled
10253
10584
  if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
10254
10585
  try {
@@ -10284,7 +10615,7 @@ async function startStreamableHTTPServer() {
10284
10615
  }
10285
10616
  // Validate token and return AuthData object
10286
10617
  if (token && header && validateToken(token)) {
10287
- return { header, token, lastUsed: Date.now(), apiUrl };
10618
+ return { header, token, lastUsed: Date.now(), apiUrl, allowedProjectIds };
10288
10619
  }
10289
10620
  return null;
10290
10621
  };
@@ -10434,6 +10765,7 @@ async function startStreamableHTTPServer() {
10434
10765
  header: headerAuth.header,
10435
10766
  token: headerAuth.token,
10436
10767
  apiUrl: headerAuth.apiUrl,
10768
+ allowedProjectIds: headerAuth.allowedProjectIds,
10437
10769
  };
10438
10770
  freshAuthPresent = true;
10439
10771
  }
@@ -10462,7 +10794,12 @@ async function startStreamableHTTPServer() {
10462
10794
  if (looksLikeStatelessSessionId(incomingSid)) {
10463
10795
  const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
10464
10796
  if (opened) {
10465
- effective = { header: opened.h, token: opened.t, apiUrl: opened.u };
10797
+ effective = {
10798
+ header: opened.h,
10799
+ token: opened.t,
10800
+ apiUrl: opened.u,
10801
+ allowedProjectIds: opened.p,
10802
+ };
10466
10803
  metrics.statelessAuthFromSealedSid++;
10467
10804
  }
10468
10805
  else {
@@ -10508,6 +10845,7 @@ async function startStreamableHTTPServer() {
10508
10845
  header: effective.header,
10509
10846
  token: effective.token,
10510
10847
  apiUrl: effective.apiUrl,
10848
+ allowedProjectIds: effective.allowedProjectIds,
10511
10849
  });
10512
10850
  if (freshAuthPresent) {
10513
10851
  metrics.statelessSidRotated++;
@@ -10526,6 +10864,7 @@ async function startStreamableHTTPServer() {
10526
10864
  lastUsed: Date.now(),
10527
10865
  apiUrl: effective.apiUrl,
10528
10866
  publicBaseUrl: getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY),
10867
+ allowedProjectIds: effective.allowedProjectIds,
10529
10868
  };
10530
10869
  // Step 4: create a fresh transport per request.
10531
10870
  // DNS rebinding protection is enforced by requireMcpHostAndOrigin middleware;
@@ -11052,6 +11391,7 @@ async function startStreamableHTTPServer() {
11052
11391
  lastUsed: authData.lastUsed,
11053
11392
  apiUrl: authData.apiUrl,
11054
11393
  publicBaseUrl: authData.publicBaseUrl,
11394
+ allowedProjectIds: authData.allowedProjectIds,
11055
11395
  };
11056
11396
  // Run the entire request handling within AsyncLocalStorage context
11057
11397
  await sessionAuthStore.run(ctx, handleRequest);
@@ -11130,6 +11470,7 @@ async function startStreamableHTTPServer() {
11130
11470
  lastUsed: authData.lastUsed,
11131
11471
  apiUrl: authData.apiUrl,
11132
11472
  publicBaseUrl: authData.publicBaseUrl,
11473
+ allowedProjectIds: authData.allowedProjectIds,
11133
11474
  };
11134
11475
  await sessionAuthStore.run(ctx, handleGetRequest);
11135
11476
  }