@zereight/mcp-gitlab 2.1.30 → 2.1.39

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
@@ -96,13 +96,13 @@ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middlew
96
96
  import { GitLabClientPool } from "./gitlab-client-pool.js";
97
97
  import { allTools, readOnlyTools, destructiveTools, deleteTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
98
98
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
99
- CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, UnprotectBranchSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
99
+ 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,
100
100
  // Discussion Schemas
101
101
  GitLabDiscussionNoteSchema, // Added
102
102
  GitLabDiscussionSchema,
103
103
  // Draft Notes Schemas
104
- GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, 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, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
105
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, 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";
104
+ GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, 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, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
105
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, 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";
106
106
  import { randomUUID, timingSafeEqual, } from "node:crypto";
107
107
  import { pino } from "pino";
108
108
  const logger = pino({
@@ -158,6 +158,7 @@ try {
158
158
  catch {
159
159
  // Intentionally ignored: version read failure is non-critical
160
160
  }
161
+ const SERVER_NAME = process.env.MCP_SERVER_NAME?.trim() || "zereight-gitlab-mcp-server";
161
162
  /**
162
163
  * Create a new MCP Server instance with request handlers registered.
163
164
  * Each transport connection gets its own Server instance to prevent
@@ -203,7 +204,7 @@ function createServer() {
203
204
  filteredTools = filteredTools.filter(tool => !hiddenToolSet.has(tool.name));
204
205
  }
205
206
  const mcpServer = new McpServer({
206
- name: "better-gitlab-mcp-server",
207
+ name: SERVER_NAME,
207
208
  version: SERVER_VERSION,
208
209
  }, {
209
210
  capabilities: {
@@ -1598,12 +1599,10 @@ async function executeGraphQL(query, variables = {}) {
1598
1599
  return json.data;
1599
1600
  }
1600
1601
  /**
1601
- * Resolve a project path and issue IID to a work item GraphQL GID.
1602
+ * Resolve a namespace path and issue IID to a work item GraphQL GID.
1602
1603
  */
1603
1604
  async function resolveWorkItemGID(projectId, issueIid) {
1604
- // resolveProjectPath handles both project and group paths (including the
1605
- // group fallback), so work item tools work for group-level namespaces too.
1606
- const projectPath = await resolveProjectPath(projectId);
1605
+ const { path: projectPath, kind: namespaceKind } = await resolveProjectOrGroupPath(projectId);
1607
1606
  // Resolve work item GID via GraphQL
1608
1607
  const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
1609
1608
  namespace(fullPath: $path) {
@@ -1613,14 +1612,14 @@ async function resolveWorkItemGID(projectId, issueIid) {
1613
1612
  }
1614
1613
  }`, { path: projectPath, iid: String(issueIid) });
1615
1614
  if (!data.namespace?.workItem?.id) {
1616
- throw new Error(`Work item #${issueIid} not found in project ${projectPath}`);
1615
+ throw new Error(`Work item #${issueIid} not found in namespace ${projectPath}`);
1617
1616
  }
1618
- return { workItemGID: data.namespace.workItem.id, projectPath };
1617
+ return { workItemGID: data.namespace.workItem.id, projectPath, namespaceKind };
1619
1618
  }
1620
1619
  /**
1621
1620
  * Resolve label names and usernames to GitLab GIDs in a single GraphQL call.
1622
1621
  */
1623
- async function resolveNamesToIds(projectPath, labelNames, usernames) {
1622
+ async function resolveNamesToIds(projectPath, namespaceKind, labelNames, usernames) {
1624
1623
  if (!labelNames?.length && !usernames?.length) {
1625
1624
  return { labelIds: [], userIds: [] };
1626
1625
  }
@@ -1631,21 +1630,23 @@ async function resolveNamesToIds(projectPath, labelNames, usernames) {
1631
1630
  // group labels, single round trip with no pagination needed.
1632
1631
  const varDefs = labelNames.map((_, i) => `$l${i}: String!`).join(", ");
1633
1632
  const aliases = labelNames.map((_, i) => `l${i}: labels(title: $l${i}, includeAncestorGroups: true, first: 1) { nodes { id } }`).join(" ");
1634
- const { project, users } = await executeGraphQL(`query($path: ID!, $usernames: [String!]!${varDefs ? `, ${varDefs}` : ""}) {
1635
- project(fullPath: $path) { ${aliases || "__typename"} }
1633
+ const rootField = namespaceKind === "group" ? "group" : "project";
1634
+ const data = await executeGraphQL(`query($path: ID!, $usernames: [String!]!${varDefs ? `, ${varDefs}` : ""}) {
1635
+ ${rootField}(fullPath: $path) { ${aliases || "__typename"} }
1636
1636
  users(usernames: $usernames) { nodes { id username } }
1637
1637
  }`, { path: projectPath, usernames, ...labelVars });
1638
- if (!project) {
1639
- throw new Error(`Project '${projectPath}' not found or inaccessible`);
1638
+ const labelNamespace = namespaceKind === "group" ? data.group : data.project;
1639
+ if (!labelNamespace) {
1640
+ throw new Error(`Namespace '${projectPath}' not found or inaccessible`);
1640
1641
  }
1641
1642
  const labelIds = labelNames.map((name, i) => {
1642
- const nodes = project[`l${i}`]?.nodes;
1643
+ const nodes = labelNamespace[`l${i}`]?.nodes;
1643
1644
  if (!nodes?.length)
1644
- throw new Error(`Label '${name}' not found in project`);
1645
+ throw new Error(`Label '${name}' not found in namespace`);
1645
1646
  return nodes[0].id;
1646
1647
  });
1647
1648
  const userIds = usernames.map(name => {
1648
- const user = users.nodes.find(u => u.username === name);
1649
+ const user = data.users.nodes.find(u => u.username === name);
1649
1650
  if (!user)
1650
1651
  throw new Error(`User '${name}' not found`);
1651
1652
  return user.id;
@@ -2151,11 +2152,36 @@ async function updateIncidentEscalationStatus(projectPath, incidentIid, status)
2151
2152
  return data.issueSetEscalationStatus.issue;
2152
2153
  }
2153
2154
  /**
2154
- * Resolve a project ID (numeric or path) to its full path_with_namespace.
2155
+ * Resolve a project ID/path or group ID/path to its full namespace path.
2156
+ * Use group:<id-or-path> or project:<id-or-path> to disambiguate numeric IDs.
2155
2157
  */
2156
2158
  async function resolveProjectPath(projectId) {
2157
- projectId = decodeURIComponent(projectId);
2158
- const effectiveProjectId = getEffectiveProjectId(projectId);
2159
+ const { path } = await resolveProjectOrGroupPath(projectId);
2160
+ return path;
2161
+ }
2162
+ /**
2163
+ * Resolve a project or group path and identify which GraphQL root field to use.
2164
+ * Bare numeric IDs resolve as projects for backwards compatibility; use group:<id>
2165
+ * when the numeric value is a GitLab group ID.
2166
+ */
2167
+ async function resolveProjectOrGroupPath(projectId) {
2168
+ const decodedProjectId = decodeURIComponent(projectId);
2169
+ const namespaceMatch = decodedProjectId.match(/^(group|project):(.+)$/i);
2170
+ const explicitKind = namespaceMatch?.[1]?.toLowerCase();
2171
+ const requestedProjectId = namespaceMatch ? namespaceMatch[2] : decodedProjectId;
2172
+ if (explicitKind === "group" && GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
2173
+ 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");
2174
+ }
2175
+ const effectiveProjectId = getEffectiveProjectId(requestedProjectId);
2176
+ if (explicitKind === "group") {
2177
+ const groupUrl = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(effectiveProjectId)}`);
2178
+ const groupResponse = await fetch(groupUrl.toString(), {
2179
+ ...getFetchConfig(),
2180
+ });
2181
+ await handleGitLabError(groupResponse);
2182
+ const group = await groupResponse.json();
2183
+ return { path: group.full_path, kind: "group" };
2184
+ }
2159
2185
  const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2160
2186
  const projectResponse = await fetch(projectUrl.toString(), {
2161
2187
  ...getFetchConfig(),
@@ -2164,21 +2190,24 @@ async function resolveProjectPath(projectId) {
2164
2190
  // Numeric IDs must not fall back: group and project IDs share no namespace,
2165
2191
  // so a numeric project 404 should fail immediately rather than silently
2166
2192
  // resolving to an unrelated group with the same integer ID.
2167
- if (projectResponse.status === 404 && !/^\d+$/.test(effectiveProjectId)) {
2193
+ if (projectResponse.status === 404 &&
2194
+ !explicitKind &&
2195
+ GITLAB_ALLOWED_PROJECT_IDS.length === 0 &&
2196
+ !/^\d+$/.test(effectiveProjectId)) {
2168
2197
  const groupUrl = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(effectiveProjectId)}`);
2169
2198
  const groupResponse = await fetch(groupUrl.toString(), {
2170
2199
  ...getFetchConfig(),
2171
2200
  });
2172
2201
  if (groupResponse.ok) {
2173
2202
  const group = await groupResponse.json();
2174
- return group.full_path;
2203
+ return { path: group.full_path, kind: "group" };
2175
2204
  }
2176
2205
  // Surface the group error
2177
2206
  await handleGitLabError(groupResponse);
2178
2207
  }
2179
2208
  await handleGitLabError(projectResponse);
2180
2209
  const project = await projectResponse.json();
2181
- return project.path_with_namespace;
2210
+ return { path: project.path_with_namespace, kind: "project" };
2182
2211
  }
2183
2212
  /**
2184
2213
  * Get a single work item with all widget data.
@@ -2400,10 +2429,11 @@ async function getWorkItem(projectId, iid) {
2400
2429
  return result;
2401
2430
  }
2402
2431
  /**
2403
- * List work items in a project with filters.
2432
+ * List work items in a project or group namespace with filters.
2404
2433
  */
2405
2434
  async function listWorkItems(projectId, options) {
2406
- const projectPath = await resolveProjectPath(projectId);
2435
+ const namespace = await resolveProjectOrGroupPath(projectId);
2436
+ const rootField = namespace.kind === "group" ? "group" : "project";
2407
2437
  // Map type names to GraphQL enum values
2408
2438
  const typeMap = {
2409
2439
  issue: "ISSUE",
@@ -2417,7 +2447,7 @@ async function listWorkItems(projectId, options) {
2417
2447
  ticket: "TICKET",
2418
2448
  };
2419
2449
  const variables = {
2420
- path: projectPath,
2450
+ path: namespace.path,
2421
2451
  first: options.first || 20,
2422
2452
  };
2423
2453
  if (options.types && options.types.length > 0) {
@@ -2439,7 +2469,7 @@ async function listWorkItems(projectId, options) {
2439
2469
  variables.after = options.after;
2440
2470
  }
2441
2471
  const data = await executeGraphQL(`query($path: ID!, $types: [IssueType!], $state: IssuableState, $search: String, $assigneeUsernames: [String!], $labelName: [String!], $first: Int, $after: String) {
2442
- project(fullPath: $path) {
2472
+ ${rootField}(fullPath: $path) {
2443
2473
  workItems(types: $types, state: $state, search: $search, assigneeUsernames: $assigneeUsernames, labelName: $labelName, first: $first, after: $after) {
2444
2474
  nodes {
2445
2475
  id iid title state webUrl workItemType { name }
@@ -2458,8 +2488,9 @@ async function listWorkItems(projectId, options) {
2458
2488
  }
2459
2489
  }
2460
2490
  }`, variables);
2461
- const workItems = data.project?.workItems?.nodes || [];
2462
- const pageInfo = data.project?.workItems?.pageInfo || {};
2491
+ const workItemsRoot = namespace.kind === "group" ? data.group : data.project;
2492
+ const workItems = workItemsRoot?.workItems?.nodes || [];
2493
+ const pageInfo = workItemsRoot?.workItems?.pageInfo || {};
2463
2494
  // Flatten widget data for each item
2464
2495
  const items = workItems.map((wi) => {
2465
2496
  const widgets = wi.widgets || [];
@@ -2503,7 +2534,7 @@ async function listWorkItems(projectId, options) {
2503
2534
  * Create a new work item using GraphQL.
2504
2535
  */
2505
2536
  async function createWorkItem(projectId, options) {
2506
- const projectPath = await resolveProjectPath(projectId);
2537
+ const { path: projectPath, kind: namespaceKind } = await resolveProjectOrGroupPath(projectId);
2507
2538
  const typeName = options.type || "issue";
2508
2539
  const typeGID = await resolveWorkItemTypeGID(projectPath, typeName);
2509
2540
  // Build the input dynamically - only include widgets that have values
@@ -2528,7 +2559,7 @@ async function createWorkItem(projectId, options) {
2528
2559
  variables.description = options.description;
2529
2560
  }
2530
2561
  // Resolve label names and usernames to GIDs in a single GraphQL call
2531
- const { labelIds, userIds } = await resolveNamesToIds(projectPath, options.labels, options.assignee_usernames);
2562
+ const { labelIds, userIds } = await resolveNamesToIds(projectPath, namespaceKind, options.labels, options.assignee_usernames);
2532
2563
  if (labelIds.length > 0) {
2533
2564
  inputFields.push("$labelIds: [LabelID!]!");
2534
2565
  inputValues.push("labelsWidget: { labelIds: $labelIds }");
@@ -2624,7 +2655,7 @@ async function createWorkItem(projectId, options) {
2624
2655
  * weight, state, status, parent, and children operations.
2625
2656
  */
2626
2657
  async function updateWorkItem(projectId, iid, options) {
2627
- const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, iid);
2658
+ const { workItemGID, projectPath, namespaceKind } = await resolveWorkItemGID(projectId, iid);
2628
2659
  // Build the main workItemUpdate mutation dynamically
2629
2660
  const inputParts = ["id: $id"];
2630
2661
  const varDefs = ["$id: WorkItemID!"];
@@ -2643,7 +2674,7 @@ async function updateWorkItem(projectId, iid, options) {
2643
2674
  const allLabelNames = [...(options.add_labels || []), ...(options.remove_labels || [])];
2644
2675
  const needsResolve = allLabelNames.length > 0 || options.assignee_usernames?.length;
2645
2676
  const { labelIds: resolvedLabelIds, userIds } = needsResolve
2646
- ? await resolveNamesToIds(projectPath, allLabelNames.length > 0 ? allLabelNames : undefined, options.assignee_usernames)
2677
+ ? await resolveNamesToIds(projectPath, namespaceKind, allLabelNames.length > 0 ? allLabelNames : undefined, options.assignee_usernames)
2647
2678
  : { labelIds: [], userIds: [] };
2648
2679
  if (options.add_labels || options.remove_labels) {
2649
2680
  const labelParts = [];
@@ -4993,7 +5024,7 @@ async function listPipelines(projectId, options = {}) {
4993
5024
  */
4994
5025
  async function getPipeline(projectId, pipelineId) {
4995
5026
  projectId = decodeURIComponent(projectId); // Decode project ID
4996
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}`);
5027
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}`);
4997
5028
  const response = await fetch(url.toString(), {
4998
5029
  ...getFetchConfig(),
4999
5030
  });
@@ -5098,7 +5129,7 @@ async function getEnvironment(projectId, environmentId) {
5098
5129
  */
5099
5130
  async function listPipelineJobs(projectId, pipelineId, options = {}) {
5100
5131
  projectId = decodeURIComponent(projectId); // Decode project ID
5101
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs`);
5132
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/jobs`);
5102
5133
  // Add all query parameters
5103
5134
  Object.entries(options).forEach(([key, value]) => {
5104
5135
  if (value !== undefined) {
@@ -5130,7 +5161,7 @@ async function listPipelineJobs(projectId, pipelineId, options = {}) {
5130
5161
  */
5131
5162
  async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
5132
5163
  projectId = decodeURIComponent(projectId); // Decode project ID
5133
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/bridges`);
5164
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/bridges`);
5134
5165
  // Add all query parameters
5135
5166
  Object.entries(options).forEach(([key, value]) => {
5136
5167
  if (value !== undefined) {
@@ -5154,7 +5185,7 @@ async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
5154
5185
  }
5155
5186
  async function getPipelineJob(projectId, jobId) {
5156
5187
  projectId = decodeURIComponent(projectId); // Decode project ID
5157
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}`);
5188
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}`);
5158
5189
  const response = await fetch(url.toString(), {
5159
5190
  ...getFetchConfig(),
5160
5191
  });
@@ -5177,7 +5208,7 @@ async function getPipelineJob(projectId, jobId) {
5177
5208
  const MAX_JOB_TRACE_LINES = 1000;
5178
5209
  async function getPipelineJobOutput(projectId, jobId, limit, offset) {
5179
5210
  projectId = decodeURIComponent(projectId); // Decode project ID
5180
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/trace`);
5211
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/trace`);
5181
5212
  const response = await fetch(url.toString(), {
5182
5213
  ...getFetchConfig(),
5183
5214
  headers: {
@@ -5252,7 +5283,7 @@ async function validateProjectCiLint(projectId, options) {
5252
5283
  */
5253
5284
  async function listJobArtifacts(projectId, jobId, options = {}) {
5254
5285
  projectId = decodeURIComponent(projectId);
5255
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/artifacts/tree`);
5286
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/tree`);
5256
5287
  Object.entries(options).forEach(([key, value]) => {
5257
5288
  if (value !== undefined) {
5258
5289
  if (typeof value === "boolean") {
@@ -5284,7 +5315,7 @@ async function listJobArtifacts(projectId, jobId, options = {}) {
5284
5315
  async function downloadJobArtifacts(projectId, jobId, localPath) {
5285
5316
  projectId = decodeURIComponent(projectId);
5286
5317
  const effectiveProjectId = getEffectiveProjectId(projectId);
5287
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts`);
5318
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts`);
5288
5319
  const response = await fetch(url.toString(), {
5289
5320
  ...getFetchConfig(),
5290
5321
  });
@@ -5316,7 +5347,7 @@ async function getJobArtifactFile(projectId, jobId, artifactPath) {
5316
5347
  .split("/")
5317
5348
  .map(segment => encodeURIComponent(segment))
5318
5349
  .join("/");
5319
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts/${encodedArtifactPath}`);
5350
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`);
5320
5351
  const response = await fetch(url.toString(), {
5321
5352
  ...getFetchConfig(),
5322
5353
  });
@@ -5363,7 +5394,7 @@ async function createPipeline(projectId, ref, variables, inputs) {
5363
5394
  */
5364
5395
  async function retryPipeline(projectId, pipelineId) {
5365
5396
  projectId = decodeURIComponent(projectId); // Decode project ID
5366
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
5397
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/retry`);
5367
5398
  const response = await fetch(url.toString(), {
5368
5399
  ...getFetchConfig(),
5369
5400
  method: "POST",
@@ -5381,7 +5412,7 @@ async function retryPipeline(projectId, pipelineId) {
5381
5412
  */
5382
5413
  async function cancelPipeline(projectId, pipelineId) {
5383
5414
  projectId = decodeURIComponent(projectId); // Decode project ID
5384
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
5415
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/cancel`);
5385
5416
  const response = await fetch(url.toString(), {
5386
5417
  ...getFetchConfig(),
5387
5418
  method: "POST",
@@ -5400,7 +5431,7 @@ async function cancelPipeline(projectId, pipelineId) {
5400
5431
  */
5401
5432
  async function playPipelineJob(projectId, jobId, variables) {
5402
5433
  projectId = decodeURIComponent(projectId);
5403
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/play`);
5434
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/play`);
5404
5435
  const body = {};
5405
5436
  if (variables && variables.length > 0) {
5406
5437
  body.job_variables_attributes = variables;
@@ -5423,7 +5454,7 @@ async function playPipelineJob(projectId, jobId, variables) {
5423
5454
  */
5424
5455
  async function retryPipelineJob(projectId, jobId) {
5425
5456
  projectId = decodeURIComponent(projectId);
5426
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/retry`);
5457
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/retry`);
5427
5458
  const response = await fetch(url.toString(), {
5428
5459
  ...getFetchConfig(),
5429
5460
  method: "POST",
@@ -5442,7 +5473,7 @@ async function retryPipelineJob(projectId, jobId) {
5442
5473
  */
5443
5474
  async function cancelPipelineJob(projectId, jobId, force) {
5444
5475
  projectId = decodeURIComponent(projectId);
5445
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/cancel`);
5476
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/cancel`);
5446
5477
  if (force !== undefined) {
5447
5478
  url.searchParams.append("force", force.toString());
5448
5479
  }
@@ -5524,7 +5555,7 @@ async function listProjectMilestones(projectId, options) {
5524
5555
  */
5525
5556
  async function getProjectMilestone(projectId, milestoneId) {
5526
5557
  projectId = decodeURIComponent(projectId);
5527
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}`);
5558
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${encodeGitLabPathSegment(milestoneId)}`);
5528
5559
  const response = await fetch(url.toString(), {
5529
5560
  ...getFetchConfig(),
5530
5561
  });
@@ -5559,7 +5590,7 @@ async function createProjectMilestone(projectId, options) {
5559
5590
  */
5560
5591
  async function editProjectMilestone(projectId, milestoneId, options) {
5561
5592
  projectId = decodeURIComponent(projectId);
5562
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}`);
5593
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${encodeGitLabPathSegment(milestoneId)}`);
5563
5594
  const response = await fetch(url.toString(), {
5564
5595
  ...getFetchConfig(),
5565
5596
  method: "PUT",
@@ -5577,7 +5608,7 @@ async function editProjectMilestone(projectId, milestoneId, options) {
5577
5608
  */
5578
5609
  async function deleteProjectMilestone(projectId, milestoneId) {
5579
5610
  projectId = decodeURIComponent(projectId);
5580
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}`);
5611
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${encodeGitLabPathSegment(milestoneId)}`);
5581
5612
  const response = await fetch(url.toString(), {
5582
5613
  ...getFetchConfig(),
5583
5614
  method: "DELETE",
@@ -5590,9 +5621,14 @@ async function deleteProjectMilestone(projectId, milestoneId) {
5590
5621
  * @param {number} milestoneId - The ID of the milestone
5591
5622
  * @returns {Promise<GitLabIssue[]>} List of issues
5592
5623
  */
5593
- async function getMilestoneIssues(projectId, milestoneId) {
5624
+ async function getMilestoneIssues(projectId, milestoneId, options = {}) {
5594
5625
  projectId = decodeURIComponent(projectId);
5595
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues`);
5626
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${encodeGitLabPathSegment(milestoneId)}/issues`);
5627
+ Object.entries(options).forEach(([key, value]) => {
5628
+ if (value !== undefined) {
5629
+ url.searchParams.append(key, String(value));
5630
+ }
5631
+ });
5596
5632
  const response = await fetch(url.toString(), {
5597
5633
  ...getFetchConfig(),
5598
5634
  });
@@ -5606,9 +5642,14 @@ async function getMilestoneIssues(projectId, milestoneId) {
5606
5642
  * @param {number} milestoneId - The ID of the milestone
5607
5643
  * @returns {Promise<GitLabMergeRequest[]>} List of merge requests
5608
5644
  */
5609
- async function getMilestoneMergeRequests(projectId, milestoneId) {
5645
+ async function getMilestoneMergeRequests(projectId, milestoneId, options = {}) {
5610
5646
  projectId = decodeURIComponent(projectId);
5611
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/merge_requests`);
5647
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${encodeGitLabPathSegment(milestoneId)}/merge_requests`);
5648
+ Object.entries(options).forEach(([key, value]) => {
5649
+ if (value !== undefined) {
5650
+ url.searchParams.append(key, String(value));
5651
+ }
5652
+ });
5612
5653
  const response = await fetch(url.toString(), {
5613
5654
  ...getFetchConfig(),
5614
5655
  });
@@ -5620,18 +5661,18 @@ async function getMilestoneMergeRequests(projectId, milestoneId) {
5620
5661
  * Promote a project milestone to a group milestone
5621
5662
  * @param {string} projectId - The ID or URL-encoded path of the project
5622
5663
  * @param {number} milestoneId - The ID of the milestone
5623
- * @returns {Promise<GitLabMilestones>} Promoted milestone
5664
+ * @returns {Promise<GitLabGroupMilestones>} Promoted milestone
5624
5665
  */
5625
5666
  async function promoteProjectMilestone(projectId, milestoneId) {
5626
5667
  projectId = decodeURIComponent(projectId);
5627
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/promote`);
5668
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${encodeGitLabPathSegment(milestoneId)}/promote`);
5628
5669
  const response = await fetch(url.toString(), {
5629
5670
  ...getFetchConfig(),
5630
5671
  method: "POST",
5631
5672
  });
5632
5673
  await handleGitLabError(response);
5633
5674
  const data = await response.json();
5634
- return GitLabMilestonesSchema.parse(data);
5675
+ return GitLabGroupMilestonesSchema.parse(data);
5635
5676
  }
5636
5677
  /**
5637
5678
  * Get all burndown chart events for a single milestone
@@ -5639,9 +5680,148 @@ async function promoteProjectMilestone(projectId, milestoneId) {
5639
5680
  * @param {number} milestoneId - The ID of the milestone
5640
5681
  * @returns {Promise<any[]>} Burndown chart events
5641
5682
  */
5642
- async function getMilestoneBurndownEvents(projectId, milestoneId) {
5683
+ async function getMilestoneBurndownEvents(projectId, milestoneId, options = {}) {
5643
5684
  projectId = decodeURIComponent(projectId);
5644
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/burndown_events`);
5685
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${encodeGitLabPathSegment(milestoneId)}/burndown_events`);
5686
+ Object.entries(options).forEach(([key, value]) => {
5687
+ if (value !== undefined) {
5688
+ url.searchParams.append(key, String(value));
5689
+ }
5690
+ });
5691
+ const response = await fetch(url.toString(), {
5692
+ ...getFetchConfig(),
5693
+ });
5694
+ await handleGitLabError(response);
5695
+ const data = await response.json();
5696
+ return data;
5697
+ }
5698
+ /**
5699
+ * List milestones in a GitLab group
5700
+ */
5701
+ async function listGroupMilestones(groupId, options) {
5702
+ groupId = decodeURIComponent(groupId);
5703
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/milestones`);
5704
+ Object.entries(options).forEach(([key, value]) => {
5705
+ if (value !== undefined) {
5706
+ if (key === "iids" && Array.isArray(value) && value.length > 0) {
5707
+ value.forEach(iid => {
5708
+ url.searchParams.append("iids[]", iid.toString());
5709
+ });
5710
+ }
5711
+ else {
5712
+ url.searchParams.append(key, value.toString());
5713
+ }
5714
+ }
5715
+ });
5716
+ const response = await fetch(url.toString(), {
5717
+ ...getFetchConfig(),
5718
+ });
5719
+ await handleGitLabError(response);
5720
+ const data = await response.json();
5721
+ return z.array(GitLabGroupMilestonesSchema).parse(data);
5722
+ }
5723
+ /**
5724
+ * Get a single milestone in a GitLab group
5725
+ */
5726
+ async function getGroupMilestone(groupId, milestoneId) {
5727
+ groupId = decodeURIComponent(groupId);
5728
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/milestones/${encodeGitLabPathSegment(milestoneId)}`);
5729
+ const response = await fetch(url.toString(), {
5730
+ ...getFetchConfig(),
5731
+ });
5732
+ await handleGitLabError(response);
5733
+ const data = await response.json();
5734
+ return GitLabGroupMilestonesSchema.parse(data);
5735
+ }
5736
+ /**
5737
+ * Create a new milestone in a GitLab group
5738
+ */
5739
+ async function createGroupMilestone(groupId, options) {
5740
+ groupId = decodeURIComponent(groupId);
5741
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/milestones`);
5742
+ const response = await fetch(url.toString(), {
5743
+ ...getFetchConfig(),
5744
+ method: "POST",
5745
+ body: JSON.stringify(options),
5746
+ });
5747
+ await handleGitLabError(response);
5748
+ const data = await response.json();
5749
+ return GitLabGroupMilestonesSchema.parse(data);
5750
+ }
5751
+ /**
5752
+ * Edit an existing milestone in a GitLab group
5753
+ */
5754
+ async function editGroupMilestone(groupId, milestoneId, options) {
5755
+ groupId = decodeURIComponent(groupId);
5756
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/milestones/${encodeGitLabPathSegment(milestoneId)}`);
5757
+ const response = await fetch(url.toString(), {
5758
+ ...getFetchConfig(),
5759
+ method: "PUT",
5760
+ body: JSON.stringify(options),
5761
+ });
5762
+ await handleGitLabError(response);
5763
+ const data = await response.json();
5764
+ return GitLabGroupMilestonesSchema.parse(data);
5765
+ }
5766
+ /**
5767
+ * Delete a milestone from a GitLab group
5768
+ */
5769
+ async function deleteGroupMilestone(groupId, milestoneId) {
5770
+ groupId = decodeURIComponent(groupId);
5771
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/milestones/${encodeGitLabPathSegment(milestoneId)}`);
5772
+ const response = await fetch(url.toString(), {
5773
+ ...getFetchConfig(),
5774
+ method: "DELETE",
5775
+ });
5776
+ await handleGitLabError(response);
5777
+ }
5778
+ /**
5779
+ * Get all issues assigned to a single group milestone
5780
+ */
5781
+ async function getGroupMilestoneIssues(groupId, milestoneId, options = {}) {
5782
+ groupId = decodeURIComponent(groupId);
5783
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/milestones/${encodeGitLabPathSegment(milestoneId)}/issues`);
5784
+ Object.entries(options).forEach(([key, value]) => {
5785
+ if (value !== undefined) {
5786
+ url.searchParams.append(key, String(value));
5787
+ }
5788
+ });
5789
+ const response = await fetch(url.toString(), {
5790
+ ...getFetchConfig(),
5791
+ });
5792
+ await handleGitLabError(response);
5793
+ const data = await response.json();
5794
+ return z.array(GitLabIssueSchema).parse(data);
5795
+ }
5796
+ /**
5797
+ * Get all merge requests assigned to a single group milestone
5798
+ */
5799
+ async function getGroupMilestoneMergeRequests(groupId, milestoneId, options = {}) {
5800
+ groupId = decodeURIComponent(groupId);
5801
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/milestones/${encodeGitLabPathSegment(milestoneId)}/merge_requests`);
5802
+ Object.entries(options).forEach(([key, value]) => {
5803
+ if (value !== undefined) {
5804
+ url.searchParams.append(key, String(value));
5805
+ }
5806
+ });
5807
+ const response = await fetch(url.toString(), {
5808
+ ...getFetchConfig(),
5809
+ });
5810
+ await handleGitLabError(response);
5811
+ const data = await response.json();
5812
+ return z.array(GitLabMergeRequestSchema).parse(data);
5813
+ }
5814
+ /**
5815
+ * Get burndown chart events for a group milestone
5816
+ */
5817
+ async function getGroupMilestoneBurndownEvents(groupId, milestoneId, options = {}) {
5818
+ groupId = decodeURIComponent(groupId);
5819
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/milestones/${encodeGitLabPathSegment(milestoneId)}/burndown_events`);
5820
+ Object.entries(options).forEach(([key, value]) => {
5821
+ if (value !== undefined) {
5822
+ url.searchParams.append(key, String(value));
5823
+ }
5824
+ });
5645
5825
  const response = await fetch(url.toString(), {
5646
5826
  ...getFetchConfig(),
5647
5827
  });
@@ -8327,8 +8507,8 @@ async function handleToolCall(params) {
8327
8507
  };
8328
8508
  }
8329
8509
  case "get_milestone_issue": {
8330
- const { project_id, milestone_id } = GetMilestoneIssuesSchema.parse(params.arguments);
8331
- const issues = await getMilestoneIssues(project_id, milestone_id);
8510
+ const { project_id, milestone_id, ...options } = GetMilestoneIssuesSchema.parse(params.arguments);
8511
+ const issues = await getMilestoneIssues(project_id, milestone_id, options);
8332
8512
  return {
8333
8513
  content: [
8334
8514
  {
@@ -8339,8 +8519,8 @@ async function handleToolCall(params) {
8339
8519
  };
8340
8520
  }
8341
8521
  case "get_milestone_merge_requests": {
8342
- const { project_id, milestone_id } = GetMilestoneMergeRequestsSchema.parse(params.arguments);
8343
- const mergeRequests = await getMilestoneMergeRequests(project_id, milestone_id);
8522
+ const { project_id, milestone_id, ...options } = GetMilestoneMergeRequestsSchema.parse(params.arguments);
8523
+ const mergeRequests = await getMilestoneMergeRequests(project_id, milestone_id, options);
8344
8524
  return {
8345
8525
  content: [
8346
8526
  {
@@ -8363,8 +8543,107 @@ async function handleToolCall(params) {
8363
8543
  };
8364
8544
  }
8365
8545
  case "get_milestone_burndown_events": {
8366
- const { project_id, milestone_id } = GetMilestoneBurndownEventsSchema.parse(params.arguments);
8367
- const events = await getMilestoneBurndownEvents(project_id, milestone_id);
8546
+ const { project_id, milestone_id, ...options } = GetMilestoneBurndownEventsSchema.parse(params.arguments);
8547
+ const events = await getMilestoneBurndownEvents(project_id, milestone_id, options);
8548
+ return {
8549
+ content: [
8550
+ {
8551
+ type: "text",
8552
+ text: JSON.stringify(events),
8553
+ },
8554
+ ],
8555
+ };
8556
+ }
8557
+ case "list_group_milestones": {
8558
+ const { group_id, ...options } = ListGroupMilestonesSchema.parse(params.arguments);
8559
+ const milestones = await listGroupMilestones(group_id, options);
8560
+ return {
8561
+ content: [
8562
+ {
8563
+ type: "text",
8564
+ text: JSON.stringify(milestones),
8565
+ },
8566
+ ],
8567
+ };
8568
+ }
8569
+ case "get_group_milestone": {
8570
+ const { group_id, milestone_id } = GetGroupMilestoneSchema.parse(params.arguments);
8571
+ const milestone = await getGroupMilestone(group_id, milestone_id);
8572
+ return {
8573
+ content: [
8574
+ {
8575
+ type: "text",
8576
+ text: JSON.stringify(milestone),
8577
+ },
8578
+ ],
8579
+ };
8580
+ }
8581
+ case "create_group_milestone": {
8582
+ const { group_id, ...options } = CreateGroupMilestoneSchema.parse(params.arguments);
8583
+ const milestone = await createGroupMilestone(group_id, options);
8584
+ return {
8585
+ content: [
8586
+ {
8587
+ type: "text",
8588
+ text: JSON.stringify(milestone),
8589
+ },
8590
+ ],
8591
+ };
8592
+ }
8593
+ case "edit_group_milestone": {
8594
+ const { group_id, milestone_id, ...options } = EditGroupMilestoneSchema.parse(params.arguments);
8595
+ const milestone = await editGroupMilestone(group_id, milestone_id, options);
8596
+ return {
8597
+ content: [
8598
+ {
8599
+ type: "text",
8600
+ text: JSON.stringify(milestone),
8601
+ },
8602
+ ],
8603
+ };
8604
+ }
8605
+ case "delete_group_milestone": {
8606
+ const { group_id, milestone_id } = DeleteGroupMilestoneSchema.parse(params.arguments);
8607
+ await deleteGroupMilestone(group_id, milestone_id);
8608
+ return {
8609
+ content: [
8610
+ {
8611
+ type: "text",
8612
+ text: JSON.stringify({
8613
+ status: "success",
8614
+ message: "Group milestone deleted successfully",
8615
+ }, null, 2),
8616
+ },
8617
+ ],
8618
+ };
8619
+ }
8620
+ case "get_group_milestone_issue": {
8621
+ const { group_id, milestone_id, ...options } = GetGroupMilestoneIssuesSchema.parse(params.arguments);
8622
+ const issues = await getGroupMilestoneIssues(group_id, milestone_id, options);
8623
+ return {
8624
+ content: [
8625
+ {
8626
+ type: "text",
8627
+ text: JSON.stringify(issues),
8628
+ },
8629
+ ],
8630
+ };
8631
+ }
8632
+ case "get_group_milestone_merge_requests": {
8633
+ const { group_id, milestone_id, ...options } = GetGroupMilestoneMergeRequestsSchema.parse(params.arguments);
8634
+ const mergeRequests = await getGroupMilestoneMergeRequests(group_id, milestone_id, options);
8635
+ return {
8636
+ content: [
8637
+ {
8638
+ type: "text",
8639
+ text: JSON.stringify(mergeRequests),
8640
+ },
8641
+ ],
8642
+ };
8643
+ }
8644
+ case "get_group_milestone_burndown_events": {
8645
+ const { group_id, milestone_id, ...options } = GetGroupMilestoneBurndownEventsSchema.parse(params.arguments);
8646
+ const events = await getGroupMilestoneBurndownEvents(group_id, milestone_id, options);
8368
8647
  return {
8369
8648
  content: [
8370
8649
  {
@@ -9565,6 +9844,22 @@ async function startStreamableHTTPServer() {
9565
9844
  });
9566
9845
  logger.info({ issuerPath }, "Serving path-aware OAuth metadata for reverse-proxy deployments");
9567
9846
  }
9847
+ // RFC 9728 path-suffixed discovery: clients connecting to /mcp will
9848
+ // request /.well-known/oauth-protected-resource/mcp to discover the
9849
+ // resource identifier. Register this route so the resource field matches
9850
+ // the actual transport URL the client connects to - regardless of
9851
+ // whether the server is deployed behind a path-stripping reverse proxy.
9852
+ if (!issuerPath) {
9853
+ const mcpResourceUrl = `${issuerUrl.origin}/mcp`;
9854
+ app.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => {
9855
+ res.json({
9856
+ resource: mcpResourceUrl,
9857
+ authorization_servers: [issuerUrl.href],
9858
+ scopes_supported: scopesSupported,
9859
+ resource_name: "GitLab MCP Server",
9860
+ });
9861
+ });
9862
+ }
9568
9863
  // Mounts /.well-known/oauth-authorization-server (shadowed above when basePath set),
9569
9864
  // /.well-known/oauth-protected-resource, /authorize, /token, /register, /revoke
9570
9865
  // Some proxies include the port in X-Forwarded-For (e.g. "1.2.3.4:5678" or
@@ -9964,10 +10259,14 @@ async function startStreamableHTTPServer() {
9964
10259
  });
9965
10260
  // Health check endpoint
9966
10261
  app.get("/health", (_req, res) => {
9967
- const isHealthy = Object.keys(streamableTransports).length < MAX_SESSIONS;
10262
+ const activeSessions = Object.keys(streamableTransports).length;
10263
+ const isHealthy = activeSessions < MAX_SESSIONS;
10264
+ if (!isHealthy) {
10265
+ logger.warn({ activeSessions, maxSessions: MAX_SESSIONS }, "Health check degraded: active session capacity reached");
10266
+ }
9968
10267
  res.status(isHealthy ? 200 : 503).json({
9969
10268
  status: isHealthy ? "healthy" : "degraded",
9970
- activeSessions: Object.keys(streamableTransports).length,
10269
+ activeSessions,
9971
10270
  maxSessions: MAX_SESSIONS,
9972
10271
  uptime: process.uptime(),
9973
10272
  });