@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/schemas.js CHANGED
@@ -939,6 +939,12 @@ export const GitLabMilestonesSchema = z.object({
939
939
  expired: z.coerce.boolean(),
940
940
  web_url: z.string().optional(),
941
941
  });
942
+ // Group milestones return group_id (not project_id)
943
+ export const GitLabGroupMilestonesSchema = GitLabMilestonesSchema.omit({
944
+ project_id: true,
945
+ }).extend({
946
+ group_id: z.coerce.string(),
947
+ });
942
948
  // Input schemas for operations
943
949
  export const CreateRepositoryOptionsSchema = z.object({
944
950
  name: z.string(),
@@ -1026,6 +1032,7 @@ export const CreateBranchOptionsSchema = z.object({
1026
1032
  ref: z.string(), // The source branch/commit for the new branch
1027
1033
  });
1028
1034
  export const GitLabCompareResultSchema = z.object({
1035
+ // GitLab may return null commit for empty/no-op compare ranges
1029
1036
  commit: z
1030
1037
  .object({
1031
1038
  id: z.string().optional(),
@@ -1035,6 +1042,7 @@ export const GitLabCompareResultSchema = z.object({
1035
1042
  author_email: z.string().optional(),
1036
1043
  created_at: z.string().optional(),
1037
1044
  })
1045
+ .nullable()
1038
1046
  .optional(),
1039
1047
  commits: z.array(GitLabCommitSchema),
1040
1048
  diffs: z.array(GitLabDiffSchema),
@@ -1658,8 +1666,9 @@ export const UpdateDefaultBranchSchema = ProjectParamsSchema.extend({
1658
1666
  export const GitLabProtectedBranchAccessLevelSchema = z.object({
1659
1667
  access_level: z.number().nullable().optional(),
1660
1668
  access_level_description: z.string().optional(),
1661
- user_id: z.number().optional(),
1662
- group_id: z.number().optional(),
1669
+ // GitLab returns null for role-based access levels (not user-/group-specific)
1670
+ user_id: z.number().nullable().optional(),
1671
+ group_id: z.number().nullable().optional(),
1663
1672
  });
1664
1673
  export const GitLabProtectedBranchSchema = z.object({
1665
1674
  id: z.number().optional(),
@@ -1756,6 +1765,9 @@ export const UpdateMergeRequestSchema = MergeRequestParamsSchema.extend({
1756
1765
  .optional()
1757
1766
  .describe("Squash commits into a single commit when merging"),
1758
1767
  draft: z.coerce.boolean().optional().describe("Work in progress merge request"),
1768
+ milestone_id: z
1769
+ .preprocess(val => (val === null ? undefined : val), z.coerce.string().optional())
1770
+ .describe("Milestone ID to assign. Set to 0 to unassign. Null is treated as omitted."),
1759
1771
  });
1760
1772
  export const MergeMergeRequestSchema = ProjectParamsSchema.extend({
1761
1773
  merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
@@ -2672,13 +2684,70 @@ export const EditProjectMilestoneSchema = GetProjectMilestoneSchema.extend({
2672
2684
  // Schema for deleting a milestone
2673
2685
  export const DeleteProjectMilestoneSchema = GetProjectMilestoneSchema;
2674
2686
  // Schema for getting issues assigned to a milestone
2675
- export const GetMilestoneIssuesSchema = GetProjectMilestoneSchema;
2687
+ export const GetMilestoneIssuesSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2676
2688
  // Schema for getting merge requests assigned to a milestone
2677
2689
  export const GetMilestoneMergeRequestsSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2678
2690
  // Schema for promoting a project milestone to a group milestone
2679
2691
  export const PromoteProjectMilestoneSchema = GetProjectMilestoneSchema;
2680
2692
  // Schema for getting burndown chart events for a milestone
2681
2693
  export const GetMilestoneBurndownEventsSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2694
+ // Group milestone schemas (mirror project milestones against /groups/:id/milestones)
2695
+ export const ListGroupMilestonesSchema = z
2696
+ .object({
2697
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2698
+ iids: z
2699
+ .array(z.coerce.number())
2700
+ .optional()
2701
+ .describe("Return only the milestones having the given iid"),
2702
+ state: z
2703
+ .enum(["active", "closed"])
2704
+ .optional()
2705
+ .describe("Return only active or closed milestones"),
2706
+ title: z
2707
+ .string()
2708
+ .optional()
2709
+ .describe("Return only milestones with a title matching the provided string"),
2710
+ search: z
2711
+ .string()
2712
+ .optional()
2713
+ .describe("Return only milestones with a title or description matching the provided string"),
2714
+ include_ancestors: z.coerce.boolean().optional().describe("Include ancestor groups"),
2715
+ include_descendants: z.coerce.boolean().optional().describe("Include descendant groups"),
2716
+ updated_before: z
2717
+ .string()
2718
+ .optional()
2719
+ .describe("Return milestones updated before the specified date (ISO 8601 format)"),
2720
+ updated_after: z
2721
+ .string()
2722
+ .optional()
2723
+ .describe("Return milestones updated after the specified date (ISO 8601 format)"),
2724
+ })
2725
+ .merge(PaginationOptionsSchema);
2726
+ export const GetGroupMilestoneSchema = z.object({
2727
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2728
+ milestone_id: z.coerce.string().describe("The ID of a group milestone"),
2729
+ });
2730
+ export const CreateGroupMilestoneSchema = z.object({
2731
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2732
+ title: z.string().describe("The title of the milestone"),
2733
+ description: z.string().optional().describe("The description of the milestone"),
2734
+ due_date: z.string().optional().describe("The due date of the milestone (YYYY-MM-DD)"),
2735
+ start_date: z.string().optional().describe("The start date of the milestone (YYYY-MM-DD)"),
2736
+ });
2737
+ export const EditGroupMilestoneSchema = GetGroupMilestoneSchema.extend({
2738
+ title: z.string().optional().describe("The title of the milestone"),
2739
+ description: z.string().optional().describe("The description of the milestone"),
2740
+ due_date: z.string().optional().describe("The due date of the milestone (YYYY-MM-DD)"),
2741
+ start_date: z.string().optional().describe("The start date of the milestone (YYYY-MM-DD)"),
2742
+ state_event: z
2743
+ .enum(["close", "activate"])
2744
+ .optional()
2745
+ .describe("The state event of the milestone"),
2746
+ });
2747
+ export const DeleteGroupMilestoneSchema = GetGroupMilestoneSchema;
2748
+ export const GetGroupMilestoneIssuesSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2749
+ export const GetGroupMilestoneMergeRequestsSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2750
+ export const GetGroupMilestoneBurndownEventsSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2682
2751
  // Add schemas for commit operations
2683
2752
  export const ListCommitsSchema = z.object({
2684
2753
  project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
@@ -3351,17 +3420,17 @@ const workItemTypeEnum = z
3351
3420
  "requirement",
3352
3421
  "ticket",
3353
3422
  ]));
3354
- const ProjectIdOrPathSchema = z.coerce
3423
+ const NamespaceIdOrPathSchema = z.coerce
3355
3424
  .string()
3356
- .describe("Project ID, URL-encoded project path, or group path (e.g. 'group/subgroup' for group-level work items)");
3425
+ .describe("Project ID, URL-encoded project path, group path, or explicit namespace prefix for ambiguous numeric IDs (e.g. 'group/subgroup', 'group:123', or 'project:123')");
3357
3426
  // Common params for work item tools
3358
3427
  const WorkItemParamsSchema = z.object({
3359
- project_id: ProjectIdOrPathSchema,
3428
+ project_id: NamespaceIdOrPathSchema,
3360
3429
  iid: z.coerce.number().describe("The internal ID (IID) of the work item"),
3361
3430
  });
3362
3431
  export const GetWorkItemSchema = WorkItemParamsSchema;
3363
3432
  export const ListWorkItemsSchema = z.object({
3364
- project_id: ProjectIdOrPathSchema,
3433
+ project_id: NamespaceIdOrPathSchema,
3365
3434
  types: z
3366
3435
  .array(workItemTypeEnum)
3367
3436
  .optional()
@@ -3381,7 +3450,7 @@ export const ListWorkItemsSchema = z.object({
3381
3450
  .describe("Cursor for pagination (from previous response's endCursor)"),
3382
3451
  });
3383
3452
  export const CreateWorkItemSchema = z.object({
3384
- project_id: ProjectIdOrPathSchema,
3453
+ project_id: NamespaceIdOrPathSchema,
3385
3454
  title: z.string().describe("Title of the work item"),
3386
3455
  type: workItemTypeEnum
3387
3456
  .optional()
@@ -3522,19 +3591,19 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
3522
3591
  .describe("Incident only: set escalation status"),
3523
3592
  });
3524
3593
  export const ConvertWorkItemTypeSchema = z.object({
3525
- project_id: ProjectIdOrPathSchema,
3594
+ project_id: NamespaceIdOrPathSchema,
3526
3595
  iid: z.coerce.number().describe("The internal ID of the work item"),
3527
3596
  new_type: workItemTypeEnum.describe("The target work item type to convert to"),
3528
3597
  });
3529
3598
  export const ListWorkItemStatusesSchema = z.object({
3530
- project_id: ProjectIdOrPathSchema,
3599
+ project_id: NamespaceIdOrPathSchema,
3531
3600
  work_item_type: workItemTypeEnum
3532
3601
  .optional()
3533
3602
  .default("issue")
3534
3603
  .describe("The work item type to list available statuses for. Defaults to 'issue'."),
3535
3604
  });
3536
3605
  export const ListWorkItemNotesSchema = z.object({
3537
- project_id: ProjectIdOrPathSchema,
3606
+ project_id: NamespaceIdOrPathSchema,
3538
3607
  iid: z.coerce.number().describe("The internal ID of the work item"),
3539
3608
  page_size: z.coerce
3540
3609
  .number()
@@ -3549,7 +3618,7 @@ export const ListWorkItemNotesSchema = z.object({
3549
3618
  .describe("Sort order for discussions"),
3550
3619
  });
3551
3620
  export const CreateWorkItemNoteSchema = z.object({
3552
- project_id: ProjectIdOrPathSchema,
3621
+ project_id: NamespaceIdOrPathSchema,
3553
3622
  iid: z.coerce.number().describe("The internal ID of the work item"),
3554
3623
  body: z.string().describe("Note body (Markdown supported)"),
3555
3624
  internal: z.coerce
@@ -3572,7 +3641,7 @@ export const MoveWorkItemSchema = z.object({
3572
3641
  .describe("Project ID, URL-encoded project path, or group path of the target namespace"),
3573
3642
  });
3574
3643
  export const ListCustomFieldDefinitionsSchema = z.object({
3575
- project_id: ProjectIdOrPathSchema,
3644
+ project_id: NamespaceIdOrPathSchema,
3576
3645
  work_item_type: workItemTypeEnum
3577
3646
  .optional()
3578
3647
  .default("issue")
@@ -3629,17 +3698,17 @@ export const DeleteIssueNoteEmojiReactionSchema = ProjectParamsSchema.extend({
3629
3698
  });
3630
3699
  // --- Emoji Reaction schemas (GraphQL: Work Items) ---
3631
3700
  export const CreateWorkItemEmojiReactionSchema = z.object({
3632
- project_id: ProjectIdOrPathSchema,
3701
+ project_id: NamespaceIdOrPathSchema,
3633
3702
  iid: z.coerce.number().describe("The internal ID of the work item"),
3634
3703
  name: emojiNameField,
3635
3704
  });
3636
3705
  export const DeleteWorkItemEmojiReactionSchema = z.object({
3637
- project_id: ProjectIdOrPathSchema,
3706
+ project_id: NamespaceIdOrPathSchema,
3638
3707
  iid: z.coerce.number().describe("The internal ID of the work item"),
3639
3708
  name: emojiNameField,
3640
3709
  });
3641
3710
  export const CreateWorkItemNoteEmojiReactionSchema = z.object({
3642
- project_id: ProjectIdOrPathSchema,
3711
+ project_id: NamespaceIdOrPathSchema,
3643
3712
  iid: z.coerce.number().describe("The internal ID of the work item"),
3644
3713
  note_id: z
3645
3714
  .string()
@@ -3647,7 +3716,7 @@ export const CreateWorkItemNoteEmojiReactionSchema = z.object({
3647
3716
  name: emojiNameField,
3648
3717
  });
3649
3718
  export const DeleteWorkItemNoteEmojiReactionSchema = z.object({
3650
- project_id: ProjectIdOrPathSchema,
3719
+ project_id: NamespaceIdOrPathSchema,
3651
3720
  iid: z.coerce.number().describe("The internal ID of the work item"),
3652
3721
  note_id: z
3653
3722
  .string()
@@ -3671,11 +3740,11 @@ export const ListIssueNoteEmojiReactionsSchema = ProjectParamsSchema.extend({
3671
3740
  discussion_id: noteEmojiDiscussionField,
3672
3741
  });
3673
3742
  export const ListWorkItemEmojiReactionsSchema = z.object({
3674
- project_id: ProjectIdOrPathSchema,
3743
+ project_id: NamespaceIdOrPathSchema,
3675
3744
  iid: z.coerce.number().describe("The internal ID of the work item"),
3676
3745
  });
3677
3746
  export const ListWorkItemNoteEmojiReactionsSchema = z.object({
3678
- project_id: ProjectIdOrPathSchema,
3747
+ project_id: NamespaceIdOrPathSchema,
3679
3748
  iid: z.coerce.number().describe("The internal ID of the work item"),
3680
3749
  note_id: z
3681
3750
  .string()
@@ -77,7 +77,7 @@ const GROUP_META = {
77
77
  },
78
78
  milestones: {
79
79
  title: "Milestones",
80
- blurb: "Project milestone CRUD plus associated issues/MRs and burndown events.",
80
+ blurb: "Project and group milestone CRUD plus associated issues/MRs and burndown events.",
81
81
  },
82
82
  wiki: {
83
83
  title: "Wiki",
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { GitLabGroupMilestonesSchema, GitLabMilestonesSchema, GetGroupMilestoneIssuesSchema, } from "../schemas.js";
4
+ test("group milestone response keeps group_id and rejects missing group_id", () => {
5
+ const parsed = GitLabGroupMilestonesSchema.parse({
6
+ id: 12,
7
+ iid: 3,
8
+ group_id: 16,
9
+ title: "10.0",
10
+ description: null,
11
+ due_date: null,
12
+ start_date: null,
13
+ state: "active",
14
+ updated_at: "2013-10-02T09:24:18Z",
15
+ created_at: "2013-10-02T09:24:18Z",
16
+ expired: false,
17
+ });
18
+ assert.equal(parsed.group_id, "16");
19
+ assert.equal("project_id" in parsed, false);
20
+ // Project schema coerces missing project_id to "undefined" — why group schema exists
21
+ const coerced = GitLabMilestonesSchema.parse({
22
+ id: 12,
23
+ iid: 3,
24
+ group_id: 16,
25
+ title: "10.0",
26
+ description: null,
27
+ due_date: null,
28
+ start_date: null,
29
+ state: "active",
30
+ updated_at: "2013-10-02T09:24:18Z",
31
+ created_at: "2013-10-02T09:24:18Z",
32
+ expired: false,
33
+ });
34
+ assert.equal(coerced.project_id, "undefined");
35
+ });
36
+ test("group milestone issues schema accepts page and per_page", () => {
37
+ const parsed = GetGroupMilestoneIssuesSchema.parse({
38
+ group_id: "16",
39
+ milestone_id: "12",
40
+ page: 2,
41
+ per_page: 50,
42
+ });
43
+ assert.equal(parsed.page, 2);
44
+ assert.equal(parsed.per_page, 50);
45
+ });
@@ -137,6 +137,18 @@ describe("MCP OAuth — Discovery Endpoints", () => {
137
137
  assert.ok(body.resource, "Should have resource field");
138
138
  console.log(" ✓ Protected resource metadata returned");
139
139
  });
140
+ test("unprefixed MCP_SERVER_URL serves /mcp resource discovery at /.well-known/oauth-protected-resource/mcp", async () => {
141
+ // When MCP_SERVER_URL has no path, the server registers a dedicated route
142
+ // at /.well-known/oauth-protected-resource/mcp (RFC 9728 path-suffixed
143
+ // discovery) so clients connecting to /mcp can discover the resource.
144
+ const issuerUrl = new URL(mcpBaseUrl);
145
+ const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource/mcp`);
146
+ assert.strictEqual(res.status, 200, "Should return 200");
147
+ const body = (await res.json());
148
+ assert.strictEqual(body.resource, `${issuerUrl.origin}/mcp`, "resource should be ${origin}/mcp");
149
+ assert.deepStrictEqual(body.authorization_servers, [issuerUrl.href], "authorization_servers should contain the issuer URL");
150
+ console.log(" ✓ Unprefixed MCP_SERVER_URL: /mcp resource discovery returned correct metadata");
151
+ });
140
152
  test("path-prefixed MCP_SERVER_URL serves path-aware discovery metadata", async () => {
141
153
  const mockPort = await findMockServerPort();
142
154
  const prefixedMockGitLab = new MockGitLabServer({
@@ -0,0 +1,87 @@
1
+ import { after, describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
4
+ async function rawMcpRequest(url, body, headers = {}) {
5
+ const response = await fetch(url, {
6
+ method: "POST",
7
+ headers: {
8
+ "Content-Type": "application/json",
9
+ Accept: "application/json, text/event-stream",
10
+ ...headers,
11
+ },
12
+ body: JSON.stringify(body),
13
+ });
14
+ const text = await response.text();
15
+ const contentType = response.headers.get("content-type") ?? "";
16
+ if (contentType.includes("text/event-stream")) {
17
+ const dataLines = text
18
+ .split("\n")
19
+ .filter(line => line.startsWith("data: "))
20
+ .map(line => line.slice(6));
21
+ return {
22
+ status: response.status,
23
+ data: dataLines.length > 0 ? JSON.parse(dataLines.at(-1)) : null,
24
+ text,
25
+ };
26
+ }
27
+ return {
28
+ status: response.status,
29
+ data: text ? JSON.parse(text) : null,
30
+ text,
31
+ };
32
+ }
33
+ const initializeBody = {
34
+ jsonrpc: "2.0",
35
+ id: 1,
36
+ method: "initialize",
37
+ params: {
38
+ protocolVersion: "2025-03-26",
39
+ capabilities: {},
40
+ clientInfo: { name: "server-name-test", version: "1.0.0" },
41
+ },
42
+ };
43
+ let portOffset = 0;
44
+ async function launchWithServerName(mcpServerName) {
45
+ const port = await findAvailablePort(3700 + portOffset++ * 10);
46
+ const server = await launchServer({
47
+ mode: TransportMode.STREAMABLE_HTTP,
48
+ port,
49
+ timeout: 10_000,
50
+ env: {
51
+ STREAMABLE_HTTP: "true",
52
+ REMOTE_AUTHORIZATION: "true",
53
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
54
+ GITLAB_API_URL: "https://gitlab.example.com/api/v4",
55
+ ...(mcpServerName !== undefined ? { MCP_SERVER_NAME: mcpServerName } : {}),
56
+ },
57
+ });
58
+ return { server, mcpUrl: `http://${HOST}:${port}/mcp` };
59
+ }
60
+ describe("MCP_SERVER_NAME", { timeout: 20_000 }, () => {
61
+ let servers = [];
62
+ after(() => {
63
+ cleanupServers(servers);
64
+ servers = [];
65
+ });
66
+ test("defaults to zereight-gitlab-mcp-server when unset", async () => {
67
+ const { server, mcpUrl } = await launchWithServerName();
68
+ servers.push(server);
69
+ const response = await rawMcpRequest(mcpUrl, initializeBody);
70
+ assert.strictEqual(response.status, 200, response.text);
71
+ assert.strictEqual(response.data?.result?.serverInfo?.name, "zereight-gitlab-mcp-server");
72
+ });
73
+ test("reports the overridden name when MCP_SERVER_NAME is set", async () => {
74
+ const { server, mcpUrl } = await launchWithServerName("gitlab-selfhosted-readonly");
75
+ servers.push(server);
76
+ const response = await rawMcpRequest(mcpUrl, initializeBody);
77
+ assert.strictEqual(response.status, 200, response.text);
78
+ assert.strictEqual(response.data?.result?.serverInfo?.name, "gitlab-selfhosted-readonly");
79
+ });
80
+ test("treats a whitespace-only MCP_SERVER_NAME as unset", async () => {
81
+ const { server, mcpUrl } = await launchWithServerName(" ");
82
+ servers.push(server);
83
+ const response = await rawMcpRequest(mcpUrl, initializeBody);
84
+ assert.strictEqual(response.status, 200, response.text);
85
+ assert.strictEqual(response.data?.result?.serverInfo?.name, "zereight-gitlab-mcp-server");
86
+ });
87
+ });
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { GitLabCompareResultSchema, GitLabProtectedBranchAccessLevelSchema, } from "../schemas.js";
4
+ test("role-based protected-branch access levels accept null user_id/group_id", () => {
5
+ // #575: GitLab returns null for role-based levels, not omitted fields
6
+ const parsed = GitLabProtectedBranchAccessLevelSchema.parse({
7
+ access_level: 40,
8
+ access_level_description: "Maintainers",
9
+ user_id: null,
10
+ group_id: null,
11
+ });
12
+ assert.equal(parsed.user_id, null);
13
+ assert.equal(parsed.group_id, null);
14
+ });
15
+ test("branch compare accepts null commit", () => {
16
+ const parsed = GitLabCompareResultSchema.parse({
17
+ commit: null,
18
+ commits: [],
19
+ diffs: [],
20
+ });
21
+ assert.equal(parsed.commit, null);
22
+ });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
- import { CreateDraftNoteSchema, CreateLabelSchema, ExecuteGraphQLSchema, GetBranchDiffsSchema, UpdateMergeRequestDiscussionNoteSchema, } from "../schemas.js";
3
+ import { CreateDraftNoteSchema, CreateLabelSchema, ExecuteGraphQLSchema, GetBranchDiffsSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, } from "../schemas.js";
4
4
  import { sanitizeToolArguments } from "../utils/tool-args.js";
5
5
  const PROJECT_ID = "group/project";
6
6
  const MERGE_REQUEST_IID = "1";
@@ -42,6 +42,25 @@ describe("When validating tool arguments after sanitization", () => {
42
42
  assert.equal(parsed.position?.new_line, 10);
43
43
  });
44
44
  });
45
+ describe("with update_merge_request", () => {
46
+ test("should omit null milestone_id instead of coercing to string null", () => {
47
+ const parsed = UpdateMergeRequestSchema.parse({
48
+ project_id: PROJECT_ID,
49
+ merge_request_iid: MERGE_REQUEST_IID,
50
+ title: "keep",
51
+ milestone_id: null,
52
+ });
53
+ assert.equal(parsed.milestone_id, undefined);
54
+ });
55
+ test("should keep 0 as unassign sentinel", () => {
56
+ const parsed = UpdateMergeRequestSchema.parse({
57
+ project_id: PROJECT_ID,
58
+ merge_request_iid: MERGE_REQUEST_IID,
59
+ milestone_id: 0,
60
+ });
61
+ assert.equal(parsed.milestone_id, "0");
62
+ });
63
+ });
45
64
  describe("with update_merge_request_discussion_note", () => {
46
65
  test("should accept body plus resolved null after sanitize", () => {
47
66
  const parsed = parseAfterSanitize("update_merge_request_discussion_note", {
@@ -11,6 +11,9 @@ const rawPathSegmentPatterns = [
11
11
  "/draft_notes/${draftNoteId}",
12
12
  "/versions/${versionId}",
13
13
  "/groups/${groupId}",
14
+ "/jobs/${jobId}",
15
+ "/pipelines/${pipelineId}",
16
+ "/milestones/${milestoneId}",
14
17
  ];
15
18
  test("GitLab URL path IDs are encoded before interpolation", () => {
16
19
  for (const pattern of rawPathSegmentPatterns) {
@@ -22,6 +25,14 @@ test("encoding keeps path traversal payloads inside one URL segment", () => {
22
25
  const url = new URL(`https://gitlab.com/api/v4/projects/42/issues/${encodeURIComponent(payload)}/notes`);
23
26
  assert.equal(url.pathname, "/api/v4/projects/42/issues/1%2F..%2F..%2F..%2F..%2Fadmin%2Fci%2Fvariables/notes");
24
27
  });
28
+ test("job_id path traversal stays under /jobs/", () => {
29
+ // GHSA-7c3w-fxgh-frc7 / #587: unencoded job_id with ../ escaped to /api/v4/user
30
+ const payload = "../../../user";
31
+ const url = new URL(`https://gitlab.com/api/v4/projects/1/jobs/${encodeURIComponent(payload)}/trace`);
32
+ assert.equal(url.pathname, "/api/v4/projects/1/jobs/..%2F..%2F..%2Fuser/trace");
33
+ assert.match(url.pathname, /\/jobs\/[^/]+\/trace$/);
34
+ assert.equal(url.pathname.includes("/api/v4/user"), false);
35
+ });
25
36
  test("malformed percent-encoded segments can still be encoded", () => {
26
37
  assert.equal(encodeURIComponent("bad%zz/path"), "bad%25zz%2Fpath");
27
38
  });
@@ -90,3 +90,66 @@ describe("Streamable HTTP concurrent session requests", { timeout: 20_000 }, ()
90
90
  }
91
91
  });
92
92
  });
93
+ describe("Streamable HTTP health check capacity logging", { timeout: 20_000 }, () => {
94
+ let mockGitLab;
95
+ let server;
96
+ let mcpUrl;
97
+ let baseUrl;
98
+ let output = "";
99
+ before(async () => {
100
+ const mockPort = await findMockServerPort();
101
+ mockGitLab = new MockGitLabServer({
102
+ port: mockPort,
103
+ validTokens: [MOCK_TOKEN],
104
+ });
105
+ await mockGitLab.start();
106
+ const port = await findAvailablePort(3470);
107
+ server = await launchServer({
108
+ mode: TransportMode.STREAMABLE_HTTP,
109
+ port,
110
+ timeout: 10_000,
111
+ env: {
112
+ STREAMABLE_HTTP: "true",
113
+ REMOTE_AUTHORIZATION: "true",
114
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
115
+ MAX_SESSIONS: "1",
116
+ },
117
+ });
118
+ server.process.stdout?.on("data", data => {
119
+ output += data.toString();
120
+ });
121
+ server.process.stderr?.on("data", data => {
122
+ output += data.toString();
123
+ });
124
+ baseUrl = `http://${HOST}:${port}`;
125
+ mcpUrl = `${baseUrl}/mcp`;
126
+ });
127
+ after(async () => {
128
+ cleanupServers([server]);
129
+ if (mockGitLab)
130
+ await mockGitLab.stop();
131
+ });
132
+ test("logs active session capacity when health check is degraded", async () => {
133
+ const client = new CustomHeaderClient({ Authorization: `Bearer ${MOCK_TOKEN}` });
134
+ await client.connect(mcpUrl);
135
+ try {
136
+ const response = await fetch(`${baseUrl}/health`);
137
+ const body = (await response.json());
138
+ assert.strictEqual(response.status, 503);
139
+ assert.strictEqual(body.status, "degraded");
140
+ assert.strictEqual(body.activeSessions, 1);
141
+ assert.strictEqual(body.maxSessions, 1);
142
+ // ponytail: poll stdout; fixed 100ms sleep flakes under CI jitter
143
+ const deadline = Date.now() + 2000;
144
+ while (Date.now() < deadline && !/Health check degraded/.test(output)) {
145
+ await new Promise(resolve => setTimeout(resolve, 50));
146
+ }
147
+ assert.match(output, /Health check degraded/);
148
+ assert.match(output, /activeSessions.*1/);
149
+ assert.match(output, /maxSessions.*1/);
150
+ }
151
+ finally {
152
+ await client.disconnect();
153
+ }
154
+ });
155
+ });