@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/README.ko.md +3 -1
- package/README.md +78 -68
- package/README.zh-CN.md +3 -1
- package/build/index.js +366 -67
- package/build/schemas.js +88 -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/nullish-tool-arguments-schema.test.js +20 -1
- 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(),
|
|
@@ -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
|
|
3423
|
+
const NamespaceIdOrPathSchema = z.coerce
|
|
3355
3424
|
.string()
|
|
3356
|
-
.describe("Project ID, URL-encoded project path,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
});
|