@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/README.ko.md +2 -1
- package/README.md +77 -68
- package/README.zh-CN.md +2 -1
- package/build/index.js +366 -67
- package/build/schemas.js +85 -19
- package/build/scripts/generate-tool-docs.js +1 -1
- package/build/test/group-milestone-schema.test.js +45 -0
- package/build/test/mcp-oauth-tests.js +12 -0
- package/build/test/mcp-server-name.test.js +87 -0
- package/build/test/nullable-gitlab-response-fields.test.js +22 -0
- package/build/test/path-segment-encoding.test.js +11 -0
- package/build/test/streamable-http-concurrent-session.test.js +63 -0
- package/build/test/test-geteffectiveprojectid.js +214 -1
- package/build/test/test-permission-mode.js +1 -0
- package/build/test/test-protected-branches.js +22 -3
- package/build/test/test-toolset-filtering.js +2 -2
- package/build/tools/registry.js +64 -1
- package/package.json +1 -1
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
|
-
|
|
1662
|
-
|
|
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
|
|
3420
|
+
const NamespaceIdOrPathSchema = z.coerce
|
|
3355
3421
|
.string()
|
|
3356
|
-
.describe("Project ID, URL-encoded project path,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
});
|