@zereight/mcp-gitlab 2.1.30 → 2.1.38

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(),
@@ -2672,13 +2681,70 @@ export const EditProjectMilestoneSchema = GetProjectMilestoneSchema.extend({
2672
2681
  // Schema for deleting a milestone
2673
2682
  export const DeleteProjectMilestoneSchema = GetProjectMilestoneSchema;
2674
2683
  // Schema for getting issues assigned to a milestone
2675
- export const GetMilestoneIssuesSchema = GetProjectMilestoneSchema;
2684
+ export const GetMilestoneIssuesSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2676
2685
  // Schema for getting merge requests assigned to a milestone
2677
2686
  export const GetMilestoneMergeRequestsSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2678
2687
  // Schema for promoting a project milestone to a group milestone
2679
2688
  export const PromoteProjectMilestoneSchema = GetProjectMilestoneSchema;
2680
2689
  // Schema for getting burndown chart events for a milestone
2681
2690
  export const GetMilestoneBurndownEventsSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2691
+ // Group milestone schemas (mirror project milestones against /groups/:id/milestones)
2692
+ export const ListGroupMilestonesSchema = z
2693
+ .object({
2694
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2695
+ iids: z
2696
+ .array(z.coerce.number())
2697
+ .optional()
2698
+ .describe("Return only the milestones having the given iid"),
2699
+ state: z
2700
+ .enum(["active", "closed"])
2701
+ .optional()
2702
+ .describe("Return only active or closed milestones"),
2703
+ title: z
2704
+ .string()
2705
+ .optional()
2706
+ .describe("Return only milestones with a title matching the provided string"),
2707
+ search: z
2708
+ .string()
2709
+ .optional()
2710
+ .describe("Return only milestones with a title or description matching the provided string"),
2711
+ include_ancestors: z.coerce.boolean().optional().describe("Include ancestor groups"),
2712
+ include_descendants: z.coerce.boolean().optional().describe("Include descendant groups"),
2713
+ updated_before: z
2714
+ .string()
2715
+ .optional()
2716
+ .describe("Return milestones updated before the specified date (ISO 8601 format)"),
2717
+ updated_after: z
2718
+ .string()
2719
+ .optional()
2720
+ .describe("Return milestones updated after the specified date (ISO 8601 format)"),
2721
+ })
2722
+ .merge(PaginationOptionsSchema);
2723
+ export const GetGroupMilestoneSchema = z.object({
2724
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2725
+ milestone_id: z.coerce.string().describe("The ID of a group milestone"),
2726
+ });
2727
+ export const CreateGroupMilestoneSchema = z.object({
2728
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2729
+ title: z.string().describe("The title of the milestone"),
2730
+ description: z.string().optional().describe("The description of the milestone"),
2731
+ due_date: z.string().optional().describe("The due date of the milestone (YYYY-MM-DD)"),
2732
+ start_date: z.string().optional().describe("The start date of the milestone (YYYY-MM-DD)"),
2733
+ });
2734
+ export const EditGroupMilestoneSchema = GetGroupMilestoneSchema.extend({
2735
+ title: z.string().optional().describe("The title of the milestone"),
2736
+ description: z.string().optional().describe("The description of the milestone"),
2737
+ due_date: z.string().optional().describe("The due date of the milestone (YYYY-MM-DD)"),
2738
+ start_date: z.string().optional().describe("The start date of the milestone (YYYY-MM-DD)"),
2739
+ state_event: z
2740
+ .enum(["close", "activate"])
2741
+ .optional()
2742
+ .describe("The state event of the milestone"),
2743
+ });
2744
+ export const DeleteGroupMilestoneSchema = GetGroupMilestoneSchema;
2745
+ export const GetGroupMilestoneIssuesSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2746
+ export const GetGroupMilestoneMergeRequestsSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2747
+ export const GetGroupMilestoneBurndownEventsSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2682
2748
  // Add schemas for commit operations
2683
2749
  export const ListCommitsSchema = z.object({
2684
2750
  project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
@@ -3351,17 +3417,17 @@ const workItemTypeEnum = z
3351
3417
  "requirement",
3352
3418
  "ticket",
3353
3419
  ]));
3354
- const ProjectIdOrPathSchema = z.coerce
3420
+ const NamespaceIdOrPathSchema = z.coerce
3355
3421
  .string()
3356
- .describe("Project ID, URL-encoded project path, or group path (e.g. 'group/subgroup' for group-level work items)");
3422
+ .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
3423
  // Common params for work item tools
3358
3424
  const WorkItemParamsSchema = z.object({
3359
- project_id: ProjectIdOrPathSchema,
3425
+ project_id: NamespaceIdOrPathSchema,
3360
3426
  iid: z.coerce.number().describe("The internal ID (IID) of the work item"),
3361
3427
  });
3362
3428
  export const GetWorkItemSchema = WorkItemParamsSchema;
3363
3429
  export const ListWorkItemsSchema = z.object({
3364
- project_id: ProjectIdOrPathSchema,
3430
+ project_id: NamespaceIdOrPathSchema,
3365
3431
  types: z
3366
3432
  .array(workItemTypeEnum)
3367
3433
  .optional()
@@ -3381,7 +3447,7 @@ export const ListWorkItemsSchema = z.object({
3381
3447
  .describe("Cursor for pagination (from previous response's endCursor)"),
3382
3448
  });
3383
3449
  export const CreateWorkItemSchema = z.object({
3384
- project_id: ProjectIdOrPathSchema,
3450
+ project_id: NamespaceIdOrPathSchema,
3385
3451
  title: z.string().describe("Title of the work item"),
3386
3452
  type: workItemTypeEnum
3387
3453
  .optional()
@@ -3522,19 +3588,19 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
3522
3588
  .describe("Incident only: set escalation status"),
3523
3589
  });
3524
3590
  export const ConvertWorkItemTypeSchema = z.object({
3525
- project_id: ProjectIdOrPathSchema,
3591
+ project_id: NamespaceIdOrPathSchema,
3526
3592
  iid: z.coerce.number().describe("The internal ID of the work item"),
3527
3593
  new_type: workItemTypeEnum.describe("The target work item type to convert to"),
3528
3594
  });
3529
3595
  export const ListWorkItemStatusesSchema = z.object({
3530
- project_id: ProjectIdOrPathSchema,
3596
+ project_id: NamespaceIdOrPathSchema,
3531
3597
  work_item_type: workItemTypeEnum
3532
3598
  .optional()
3533
3599
  .default("issue")
3534
3600
  .describe("The work item type to list available statuses for. Defaults to 'issue'."),
3535
3601
  });
3536
3602
  export const ListWorkItemNotesSchema = z.object({
3537
- project_id: ProjectIdOrPathSchema,
3603
+ project_id: NamespaceIdOrPathSchema,
3538
3604
  iid: z.coerce.number().describe("The internal ID of the work item"),
3539
3605
  page_size: z.coerce
3540
3606
  .number()
@@ -3549,7 +3615,7 @@ export const ListWorkItemNotesSchema = z.object({
3549
3615
  .describe("Sort order for discussions"),
3550
3616
  });
3551
3617
  export const CreateWorkItemNoteSchema = z.object({
3552
- project_id: ProjectIdOrPathSchema,
3618
+ project_id: NamespaceIdOrPathSchema,
3553
3619
  iid: z.coerce.number().describe("The internal ID of the work item"),
3554
3620
  body: z.string().describe("Note body (Markdown supported)"),
3555
3621
  internal: z.coerce
@@ -3572,7 +3638,7 @@ export const MoveWorkItemSchema = z.object({
3572
3638
  .describe("Project ID, URL-encoded project path, or group path of the target namespace"),
3573
3639
  });
3574
3640
  export const ListCustomFieldDefinitionsSchema = z.object({
3575
- project_id: ProjectIdOrPathSchema,
3641
+ project_id: NamespaceIdOrPathSchema,
3576
3642
  work_item_type: workItemTypeEnum
3577
3643
  .optional()
3578
3644
  .default("issue")
@@ -3629,17 +3695,17 @@ export const DeleteIssueNoteEmojiReactionSchema = ProjectParamsSchema.extend({
3629
3695
  });
3630
3696
  // --- Emoji Reaction schemas (GraphQL: Work Items) ---
3631
3697
  export const CreateWorkItemEmojiReactionSchema = z.object({
3632
- project_id: ProjectIdOrPathSchema,
3698
+ project_id: NamespaceIdOrPathSchema,
3633
3699
  iid: z.coerce.number().describe("The internal ID of the work item"),
3634
3700
  name: emojiNameField,
3635
3701
  });
3636
3702
  export const DeleteWorkItemEmojiReactionSchema = z.object({
3637
- project_id: ProjectIdOrPathSchema,
3703
+ project_id: NamespaceIdOrPathSchema,
3638
3704
  iid: z.coerce.number().describe("The internal ID of the work item"),
3639
3705
  name: emojiNameField,
3640
3706
  });
3641
3707
  export const CreateWorkItemNoteEmojiReactionSchema = z.object({
3642
- project_id: ProjectIdOrPathSchema,
3708
+ project_id: NamespaceIdOrPathSchema,
3643
3709
  iid: z.coerce.number().describe("The internal ID of the work item"),
3644
3710
  note_id: z
3645
3711
  .string()
@@ -3647,7 +3713,7 @@ export const CreateWorkItemNoteEmojiReactionSchema = z.object({
3647
3713
  name: emojiNameField,
3648
3714
  });
3649
3715
  export const DeleteWorkItemNoteEmojiReactionSchema = z.object({
3650
- project_id: ProjectIdOrPathSchema,
3716
+ project_id: NamespaceIdOrPathSchema,
3651
3717
  iid: z.coerce.number().describe("The internal ID of the work item"),
3652
3718
  note_id: z
3653
3719
  .string()
@@ -3671,11 +3737,11 @@ export const ListIssueNoteEmojiReactionsSchema = ProjectParamsSchema.extend({
3671
3737
  discussion_id: noteEmojiDiscussionField,
3672
3738
  });
3673
3739
  export const ListWorkItemEmojiReactionsSchema = z.object({
3674
- project_id: ProjectIdOrPathSchema,
3740
+ project_id: NamespaceIdOrPathSchema,
3675
3741
  iid: z.coerce.number().describe("The internal ID of the work item"),
3676
3742
  });
3677
3743
  export const ListWorkItemNoteEmojiReactionsSchema = z.object({
3678
- project_id: ProjectIdOrPathSchema,
3744
+ project_id: NamespaceIdOrPathSchema,
3679
3745
  iid: z.coerce.number().describe("The internal ID of the work item"),
3680
3746
  note_id: z
3681
3747
  .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
+ });
@@ -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
+ });