@zereight/mcp-gitlab 2.1.15 → 2.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/index.js +10 -0
- package/build/schemas.js +9 -4
- package/build/test/nullish-tool-arguments-schema.test.js +99 -0
- package/build/test/schema-tests.js +116 -2
- package/build/test/test-list-merge-requests.js +19 -0
- package/build/test/utils/merge-request-position.test.js +57 -0
- package/build/test/utils/tool-args.test.js +75 -0
- package/build/utils/merge-request-position.js +29 -0
- package/build/utils/tool-args.js +22 -0
- package/package.json +2 -2
package/build/index.js
CHANGED
|
@@ -128,6 +128,7 @@ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
|
|
|
128
128
|
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
129
129
|
import { normalizeGitLabApiUrl } from "./utils/url.js";
|
|
130
130
|
import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents } from "./utils/helpers.js";
|
|
131
|
+
import { sanitizeToolArguments } from "./utils/tool-args.js";
|
|
131
132
|
import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
|
|
132
133
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
133
134
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
@@ -1275,6 +1276,12 @@ async function listMergeRequests(projectId, options = {}) {
|
|
|
1275
1276
|
// Handle array of labels
|
|
1276
1277
|
url.searchParams.append(key, value.join(","));
|
|
1277
1278
|
}
|
|
1279
|
+
else if (key === "approved_by_usernames" && Array.isArray(value)) {
|
|
1280
|
+
// GitLab expects array-bracket form: approved_by_usernames[]=alice&approved_by_usernames[]=bob
|
|
1281
|
+
for (const v of value) {
|
|
1282
|
+
url.searchParams.append(`${key}[]`, String(v));
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1278
1285
|
else {
|
|
1279
1286
|
url.searchParams.append(key, String(value));
|
|
1280
1287
|
}
|
|
@@ -6126,6 +6133,9 @@ async function handleToolCall(params) {
|
|
|
6126
6133
|
args.iid = args.work_item_iid;
|
|
6127
6134
|
delete args.work_item_iid;
|
|
6128
6135
|
}
|
|
6136
|
+
if (!Array.isArray(args)) {
|
|
6137
|
+
params.arguments = sanitizeToolArguments(params.name, args);
|
|
6138
|
+
}
|
|
6129
6139
|
}
|
|
6130
6140
|
// Centralized read-only guard: reject write tools even if client bypasses list_tools filtering
|
|
6131
6141
|
if (GITLAB_READ_ONLY_MODE && !readOnlyTools.has(params.name)) {
|
package/build/schemas.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { omitIncompleteMergeRequestPosition } from "./utils/merge-request-position.js";
|
|
2
3
|
// Helper: coerce a JSON-stringified array to an actual array.
|
|
3
4
|
// LLMs sometimes send '["a", "b"]' (string) instead of ["a", "b"] (array).
|
|
4
5
|
const coerceStringArray = z.preprocess((val) => {
|
|
@@ -1110,7 +1111,7 @@ export const GitLabMergeRequestSchema = z.object({
|
|
|
1110
1111
|
.describe("Whether rebase is currently in progress for this merge request"),
|
|
1111
1112
|
merge_when_pipeline_succeeds: z.coerce.boolean().optional(),
|
|
1112
1113
|
squash: z.coerce.boolean().optional(),
|
|
1113
|
-
labels: z.array(z.string()).optional(),
|
|
1114
|
+
labels: z.array(GitLabLabelSchema).or(z.array(z.string())).optional(), // Support both label objects and strings
|
|
1114
1115
|
});
|
|
1115
1116
|
export const LineRangeSchema = z
|
|
1116
1117
|
.object({
|
|
@@ -1823,6 +1824,9 @@ export const ListMergeRequestsSchema = z
|
|
|
1823
1824
|
.string()
|
|
1824
1825
|
.optional()
|
|
1825
1826
|
.describe("Returns merge requests which have the user as a reviewer by username. Mutually exclusive with reviewer_id."),
|
|
1827
|
+
approved_by_usernames: coerceStringArray
|
|
1828
|
+
.optional()
|
|
1829
|
+
.describe("Returns merge requests approved by the given usernames (array)."),
|
|
1826
1830
|
created_after: z
|
|
1827
1831
|
.string()
|
|
1828
1832
|
.optional()
|
|
@@ -2254,6 +2258,7 @@ export const MergeRequestThreadPositionSchema = z.object({
|
|
|
2254
2258
|
.optional()
|
|
2255
2259
|
.describe("IMAGE DIFFS ONLY: Y coordinate on the image (for position_type='image')."),
|
|
2256
2260
|
});
|
|
2261
|
+
const optionalMergeRequestThreadPosition = z.preprocess(omitIncompleteMergeRequestPosition, MergeRequestThreadPositionSchema.optional());
|
|
2257
2262
|
// Draft Notes API schemas
|
|
2258
2263
|
export const GitLabDraftNoteSchema = z
|
|
2259
2264
|
.object({
|
|
@@ -2295,7 +2300,7 @@ export const CreateDraftNoteSchema = ProjectParamsSchema.extend({
|
|
|
2295
2300
|
.string()
|
|
2296
2301
|
.optional()
|
|
2297
2302
|
.describe("The ID of a discussion the draft note replies to"),
|
|
2298
|
-
position:
|
|
2303
|
+
position: optionalMergeRequestThreadPosition.describe("Position when creating a diff note"),
|
|
2299
2304
|
resolve_discussion: z
|
|
2300
2305
|
.coerce.boolean()
|
|
2301
2306
|
.optional()
|
|
@@ -2306,7 +2311,7 @@ export const UpdateDraftNoteSchema = ProjectParamsSchema.extend({
|
|
|
2306
2311
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
2307
2312
|
draft_note_id: z.coerce.string().describe("The ID of the draft note"),
|
|
2308
2313
|
body: z.string().optional().describe("The content of the draft note"),
|
|
2309
|
-
position:
|
|
2314
|
+
position: optionalMergeRequestThreadPosition.describe("Position when creating a diff note"),
|
|
2310
2315
|
resolve_discussion: z
|
|
2311
2316
|
.coerce.boolean()
|
|
2312
2317
|
.optional()
|
|
@@ -2330,7 +2335,7 @@ export const BulkPublishDraftNotesSchema = ProjectParamsSchema.extend({
|
|
|
2330
2335
|
export const CreateMergeRequestThreadSchema = ProjectParamsSchema.extend({
|
|
2331
2336
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
2332
2337
|
body: z.string().describe("The content of the thread"),
|
|
2333
|
-
position:
|
|
2338
|
+
position: optionalMergeRequestThreadPosition.describe("Position when creating a diff note"),
|
|
2334
2339
|
created_at: z.string().optional().describe("Date the thread was created at (ISO 8601 format)"),
|
|
2335
2340
|
});
|
|
2336
2341
|
export const ResolveMergeRequestThreadSchema = ProjectParamsSchema.extend({
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { CreateDraftNoteSchema, CreateLabelSchema, ExecuteGraphQLSchema, GetBranchDiffsSchema, UpdateMergeRequestDiscussionNoteSchema, } from "../schemas.js";
|
|
4
|
+
import { sanitizeToolArguments } from "../utils/tool-args.js";
|
|
5
|
+
const PROJECT_ID = "group/project";
|
|
6
|
+
const MERGE_REQUEST_IID = "1";
|
|
7
|
+
function parseAfterSanitize(toolName, raw, parse) {
|
|
8
|
+
return parse(sanitizeToolArguments(toolName, raw));
|
|
9
|
+
}
|
|
10
|
+
describe("When validating tool arguments after sanitization", () => {
|
|
11
|
+
describe("with create_draft_note", () => {
|
|
12
|
+
test("should accept position with null SHAs after sanitize and schema preprocess", () => {
|
|
13
|
+
const parsed = parseAfterSanitize("create_draft_note", {
|
|
14
|
+
project_id: PROJECT_ID,
|
|
15
|
+
merge_request_iid: MERGE_REQUEST_IID,
|
|
16
|
+
body: "Test note",
|
|
17
|
+
position: {
|
|
18
|
+
base_sha: null,
|
|
19
|
+
head_sha: null,
|
|
20
|
+
start_sha: null,
|
|
21
|
+
position_type: "text",
|
|
22
|
+
},
|
|
23
|
+
}, value => CreateDraftNoteSchema.parse(value));
|
|
24
|
+
assert.equal(parsed.position, undefined);
|
|
25
|
+
});
|
|
26
|
+
test("should accept position with nullable old_line after sanitize", () => {
|
|
27
|
+
const parsed = parseAfterSanitize("create_draft_note", {
|
|
28
|
+
project_id: PROJECT_ID,
|
|
29
|
+
merge_request_iid: MERGE_REQUEST_IID,
|
|
30
|
+
body: "Test note",
|
|
31
|
+
position: {
|
|
32
|
+
base_sha: "base",
|
|
33
|
+
head_sha: "head",
|
|
34
|
+
start_sha: "start",
|
|
35
|
+
position_type: "text",
|
|
36
|
+
new_path: "file.ts",
|
|
37
|
+
old_line: null,
|
|
38
|
+
new_line: 10,
|
|
39
|
+
},
|
|
40
|
+
}, value => CreateDraftNoteSchema.parse(value));
|
|
41
|
+
assert.equal(parsed.position?.old_line, null);
|
|
42
|
+
assert.equal(parsed.position?.new_line, 10);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("with update_merge_request_discussion_note", () => {
|
|
46
|
+
test("should accept body plus resolved null after sanitize", () => {
|
|
47
|
+
const parsed = parseAfterSanitize("update_merge_request_discussion_note", {
|
|
48
|
+
project_id: PROJECT_ID,
|
|
49
|
+
merge_request_iid: MERGE_REQUEST_IID,
|
|
50
|
+
discussion_id: "d1",
|
|
51
|
+
note_id: "n1",
|
|
52
|
+
body: "updated",
|
|
53
|
+
resolved: null,
|
|
54
|
+
}, value => UpdateMergeRequestDiscussionNoteSchema.parse(value));
|
|
55
|
+
assert.equal(parsed.body, "updated");
|
|
56
|
+
assert.equal(parsed.resolved, undefined);
|
|
57
|
+
});
|
|
58
|
+
test("should reject when only resolved null remains after sanitize", () => {
|
|
59
|
+
assert.throws(() => parseAfterSanitize("update_merge_request_discussion_note", {
|
|
60
|
+
project_id: PROJECT_ID,
|
|
61
|
+
merge_request_iid: MERGE_REQUEST_IID,
|
|
62
|
+
discussion_id: "d1",
|
|
63
|
+
note_id: "n1",
|
|
64
|
+
resolved: null,
|
|
65
|
+
}, value => UpdateMergeRequestDiscussionNoteSchema.parse(value)), /At least one of 'body' or 'resolved'/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("with get_branch_diffs", () => {
|
|
69
|
+
test("should ignore unknown commit null field from older clients", () => {
|
|
70
|
+
const parsed = parseAfterSanitize("get_branch_diffs", {
|
|
71
|
+
project_id: PROJECT_ID,
|
|
72
|
+
from: "main",
|
|
73
|
+
to: "dev",
|
|
74
|
+
commit: null,
|
|
75
|
+
}, value => GetBranchDiffsSchema.parse(value));
|
|
76
|
+
assert.equal(Object.hasOwn(parsed, "commit"), false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe("with execute_graphql", () => {
|
|
80
|
+
test("should preserve explicit null variable values after sanitize", () => {
|
|
81
|
+
const parsed = parseAfterSanitize("execute_graphql", {
|
|
82
|
+
query: "mutation($id: ID) { updateProject(input: { id: $id }) { project { id } } }",
|
|
83
|
+
variables: { id: null },
|
|
84
|
+
}, value => ExecuteGraphQLSchema.parse(value));
|
|
85
|
+
assert.equal(parsed.variables?.id, null);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("with create_label", () => {
|
|
89
|
+
test("should preserve explicit null priority after sanitize", () => {
|
|
90
|
+
const parsed = parseAfterSanitize("create_label", {
|
|
91
|
+
project_id: PROJECT_ID,
|
|
92
|
+
name: "priority-null",
|
|
93
|
+
color: "#112233",
|
|
94
|
+
priority: null,
|
|
95
|
+
}, value => CreateLabelSchema.parse(value));
|
|
96
|
+
assert.equal(parsed.priority, null);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -723,6 +723,54 @@ function runGitLabMergeRequestSchemaTests() {
|
|
|
723
723
|
},
|
|
724
724
|
validate: (data) => data.milestone === undefined,
|
|
725
725
|
},
|
|
726
|
+
{
|
|
727
|
+
name: 'schema:gitlab_merge_request:labels-string-array',
|
|
728
|
+
input: {
|
|
729
|
+
...baseMergeRequest,
|
|
730
|
+
labels: ['bug', 'enhancement'],
|
|
731
|
+
},
|
|
732
|
+
validate: (data) => Array.isArray(data.labels) &&
|
|
733
|
+
data.labels.length === 2 &&
|
|
734
|
+
data.labels[0] === 'bug' &&
|
|
735
|
+
data.labels[1] === 'enhancement',
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
name: 'schema:gitlab_merge_request:labels-object-array',
|
|
739
|
+
input: {
|
|
740
|
+
...baseMergeRequest,
|
|
741
|
+
labels: [
|
|
742
|
+
{
|
|
743
|
+
id: 1,
|
|
744
|
+
name: 'bug',
|
|
745
|
+
color: '#ff0000',
|
|
746
|
+
text_color: '#ffffff',
|
|
747
|
+
description: null,
|
|
748
|
+
description_html: null,
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: 2,
|
|
752
|
+
name: 'enhancement',
|
|
753
|
+
color: '#00ff00',
|
|
754
|
+
text_color: '#000000',
|
|
755
|
+
description: 'Improvements',
|
|
756
|
+
description_html: '<p>Improvements</p>',
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
},
|
|
760
|
+
validate: (data) => Array.isArray(data.labels) &&
|
|
761
|
+
data.labels.length === 2 &&
|
|
762
|
+
data.labels[0].name === 'bug' &&
|
|
763
|
+
data.labels[0].id === '1' &&
|
|
764
|
+
data.labels[1].name === 'enhancement' &&
|
|
765
|
+
data.labels[1].id === '2',
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
name: 'schema:gitlab_merge_request:labels-omitted',
|
|
769
|
+
input: {
|
|
770
|
+
...baseMergeRequest,
|
|
771
|
+
},
|
|
772
|
+
validate: (data) => data.labels === undefined,
|
|
773
|
+
},
|
|
726
774
|
];
|
|
727
775
|
let passed = 0;
|
|
728
776
|
let failed = 0;
|
|
@@ -955,6 +1003,71 @@ function runLabelsCoercionSchemaTests() {
|
|
|
955
1003
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
956
1004
|
return { passed, failed };
|
|
957
1005
|
}
|
|
1006
|
+
function runApprovedByUsernamesSchemaTests() {
|
|
1007
|
+
console.log('\n=== ListMergeRequestsSchema approved_by_usernames Tests ===');
|
|
1008
|
+
const cases = [
|
|
1009
|
+
{
|
|
1010
|
+
name: 'schema:list_merge_requests:approved_by_usernames-native-array',
|
|
1011
|
+
input: { approved_by_usernames: ['alice', 'bob'] },
|
|
1012
|
+
expected: ['alice', 'bob'],
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
name: 'schema:list_merge_requests:approved_by_usernames-stringified-array',
|
|
1016
|
+
input: { approved_by_usernames: '["alice","bob"]' },
|
|
1017
|
+
expected: ['alice', 'bob'],
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
name: 'schema:list_merge_requests:approved_by_usernames-omitted',
|
|
1021
|
+
input: {},
|
|
1022
|
+
expected: undefined,
|
|
1023
|
+
},
|
|
1024
|
+
];
|
|
1025
|
+
let passed = 0;
|
|
1026
|
+
let failed = 0;
|
|
1027
|
+
cases.forEach(testCase => {
|
|
1028
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
1029
|
+
const parsed = ListMergeRequestsSchema.safeParse(testCase.input);
|
|
1030
|
+
if (testCase.shouldFail) {
|
|
1031
|
+
if (parsed.success) {
|
|
1032
|
+
result.error = 'Expected schema validation to fail';
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
result.status = 'passed';
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
else if (!parsed.success) {
|
|
1039
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
const actual = parsed.data['approved_by_usernames'];
|
|
1043
|
+
if (testCase.expected === undefined) {
|
|
1044
|
+
result.status = actual === undefined ? 'passed' : 'failed';
|
|
1045
|
+
if (actual !== undefined) {
|
|
1046
|
+
result.error = `Expected approved_by_usernames to be undefined, got ${JSON.stringify(actual)}`;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
const match = Array.isArray(actual) &&
|
|
1051
|
+
actual.length === testCase.expected.length &&
|
|
1052
|
+
testCase.expected.every((v, i) => actual[i] === v);
|
|
1053
|
+
result.status = match ? 'passed' : 'failed';
|
|
1054
|
+
if (!match) {
|
|
1055
|
+
result.error = `Expected ${JSON.stringify(testCase.expected)}, got ${JSON.stringify(actual)}`;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (result.status === 'passed') {
|
|
1060
|
+
passed++;
|
|
1061
|
+
console.log(`✅ ${result.name}`);
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
failed++;
|
|
1065
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
1069
|
+
return { passed, failed };
|
|
1070
|
+
}
|
|
958
1071
|
function runListLabelsSchemaTests() {
|
|
959
1072
|
console.log('\n=== List Labels Schema Tests ===');
|
|
960
1073
|
const cases = [
|
|
@@ -1284,13 +1397,14 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
1284
1397
|
const emojiReactionResult = runEmojiReactionSchemaTests();
|
|
1285
1398
|
const repositorySchemaResult = runGitLabRepositorySchemaTests();
|
|
1286
1399
|
const labelsCoercionResult = runLabelsCoercionSchemaTests();
|
|
1400
|
+
const approvedByUsernamesResult = runApprovedByUsernamesSchemaTests();
|
|
1287
1401
|
const listLabelsResult = runListLabelsSchemaTests();
|
|
1288
1402
|
const treeItemResult = runGitLabTreeItemSchemaTests();
|
|
1289
1403
|
const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
|
|
1290
1404
|
const gitLabUserFullResult = runGitLabUserFullSchemaTests();
|
|
1291
1405
|
const gitLabMarkdownUploadResult = runGitLabMarkdownUploadSchemaTests();
|
|
1292
|
-
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed + gitLabMarkdownUploadResult.passed;
|
|
1293
|
-
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed + gitLabMarkdownUploadResult.failed;
|
|
1406
|
+
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + approvedByUsernamesResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed + gitLabMarkdownUploadResult.passed;
|
|
1407
|
+
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + approvedByUsernamesResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed + gitLabMarkdownUploadResult.failed;
|
|
1294
1408
|
console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
|
|
1295
1409
|
if (totalFailed > 0) {
|
|
1296
1410
|
process.exit(1);
|
|
@@ -103,4 +103,23 @@ describe('list_merge_requests', () => {
|
|
|
103
103
|
assert.ok(Array.isArray(mrs), 'Response should be an array');
|
|
104
104
|
assert.strictEqual(mrs.length, 2, 'Should return 2 mock MRs');
|
|
105
105
|
});
|
|
106
|
+
test('forwards approved_by_usernames in bracket-array form', async () => {
|
|
107
|
+
let capturedUrl;
|
|
108
|
+
mockGitLab.addMockHandler('get', '/merge_requests', (req, res) => {
|
|
109
|
+
capturedUrl = req.originalUrl;
|
|
110
|
+
res.json([]);
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
await callListMergeRequests({ approved_by_usernames: ['alice', 'bob'] }, {
|
|
114
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
115
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
116
|
+
});
|
|
117
|
+
assert.ok(capturedUrl, 'Mock handler should have received a request');
|
|
118
|
+
assert.match(capturedUrl, /approved_by_usernames%5B%5D=alice/, 'Request URL should contain approved_by_usernames[]=alice (URL-encoded)');
|
|
119
|
+
assert.match(capturedUrl, /approved_by_usernames%5B%5D=bob/, 'Request URL should contain approved_by_usernames[]=bob (URL-encoded)');
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
mockGitLab.clearCustomHandlers();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
106
125
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { CreateDraftNoteSchema } from "../../schemas.js";
|
|
4
|
+
import { omitIncompleteMergeRequestPosition } from "../../utils/merge-request-position.js";
|
|
5
|
+
describe("When omitIncompleteMergeRequestPosition runs", () => {
|
|
6
|
+
describe("with MCP null-injected SHAs", () => {
|
|
7
|
+
test("should return undefined when all commit SHAs are nullish", () => {
|
|
8
|
+
assert.equal(omitIncompleteMergeRequestPosition({
|
|
9
|
+
base_sha: null,
|
|
10
|
+
head_sha: null,
|
|
11
|
+
start_sha: null,
|
|
12
|
+
position_type: "text",
|
|
13
|
+
}), undefined);
|
|
14
|
+
});
|
|
15
|
+
test("should return undefined for null and undefined", () => {
|
|
16
|
+
assert.equal(omitIncompleteMergeRequestPosition(null), undefined);
|
|
17
|
+
assert.equal(omitIncompleteMergeRequestPosition(undefined), undefined);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("with complete position input", () => {
|
|
21
|
+
test("should return position when all required SHAs are strings", () => {
|
|
22
|
+
const position = {
|
|
23
|
+
base_sha: "abc",
|
|
24
|
+
head_sha: "def",
|
|
25
|
+
start_sha: "ghi",
|
|
26
|
+
position_type: "text",
|
|
27
|
+
old_line: null,
|
|
28
|
+
new_line: 4,
|
|
29
|
+
};
|
|
30
|
+
assert.deepEqual(omitIncompleteMergeRequestPosition(position), position);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("with partially invalid position input", () => {
|
|
34
|
+
test("should pass position through for schema validation to reject", () => {
|
|
35
|
+
const position = {
|
|
36
|
+
base_sha: "abc",
|
|
37
|
+
head_sha: null,
|
|
38
|
+
start_sha: "ghi",
|
|
39
|
+
position_type: "text",
|
|
40
|
+
};
|
|
41
|
+
assert.deepEqual(omitIncompleteMergeRequestPosition(position), position);
|
|
42
|
+
});
|
|
43
|
+
test("should fail schema parse when a required SHA is missing", () => {
|
|
44
|
+
assert.throws(() => CreateDraftNoteSchema.parse({
|
|
45
|
+
project_id: "g/p",
|
|
46
|
+
merge_request_iid: "1",
|
|
47
|
+
body: "note",
|
|
48
|
+
position: omitIncompleteMergeRequestPosition({
|
|
49
|
+
base_sha: "abc",
|
|
50
|
+
head_sha: null,
|
|
51
|
+
start_sha: "ghi",
|
|
52
|
+
position_type: "text",
|
|
53
|
+
}),
|
|
54
|
+
}), /Expected string, received null/);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { sanitizeToolArguments } from "../../utils/tool-args.js";
|
|
4
|
+
describe("When sanitizeToolArguments runs", () => {
|
|
5
|
+
describe("with top-level null optionals", () => {
|
|
6
|
+
test("should omit null and undefined keys for generic tools", () => {
|
|
7
|
+
const result = sanitizeToolArguments("create_draft_note", {
|
|
8
|
+
project_id: "g/p",
|
|
9
|
+
merge_request_iid: "1",
|
|
10
|
+
body: "note",
|
|
11
|
+
position: null,
|
|
12
|
+
resolve_discussion: undefined,
|
|
13
|
+
});
|
|
14
|
+
assert.deepEqual(result, {
|
|
15
|
+
project_id: "g/p",
|
|
16
|
+
merge_request_iid: "1",
|
|
17
|
+
body: "note",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe("with nested objects", () => {
|
|
22
|
+
test("should not strip nulls inside nested position objects", () => {
|
|
23
|
+
const position = {
|
|
24
|
+
base_sha: "abc",
|
|
25
|
+
head_sha: "def",
|
|
26
|
+
start_sha: "ghi",
|
|
27
|
+
position_type: "text",
|
|
28
|
+
old_line: null,
|
|
29
|
+
new_line: 12,
|
|
30
|
+
};
|
|
31
|
+
const result = sanitizeToolArguments("create_draft_note", {
|
|
32
|
+
project_id: "g/p",
|
|
33
|
+
merge_request_iid: "1",
|
|
34
|
+
body: "note",
|
|
35
|
+
position,
|
|
36
|
+
});
|
|
37
|
+
assert.deepEqual(result.position, position);
|
|
38
|
+
});
|
|
39
|
+
test("should preserve null entries inside execute_graphql variables", () => {
|
|
40
|
+
const variables = { milestoneId: null, title: "x" };
|
|
41
|
+
const result = sanitizeToolArguments("execute_graphql", {
|
|
42
|
+
query: "query { project { id } }",
|
|
43
|
+
variables,
|
|
44
|
+
});
|
|
45
|
+
assert.deepEqual(result.variables, variables);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe("with meaningful falsy values", () => {
|
|
49
|
+
test("should preserve false and zero", () => {
|
|
50
|
+
const result = sanitizeToolArguments("update_merge_request_discussion_note", {
|
|
51
|
+
resolved: false,
|
|
52
|
+
straight: false,
|
|
53
|
+
count: 0,
|
|
54
|
+
label: "",
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(result, {
|
|
57
|
+
resolved: false,
|
|
58
|
+
straight: false,
|
|
59
|
+
count: 0,
|
|
60
|
+
label: "",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("with create_label", () => {
|
|
65
|
+
test("should preserve explicit null priority", () => {
|
|
66
|
+
const result = sanitizeToolArguments("create_label", {
|
|
67
|
+
project_id: "g/p",
|
|
68
|
+
name: "bug",
|
|
69
|
+
color: "#ff0000",
|
|
70
|
+
priority: null,
|
|
71
|
+
});
|
|
72
|
+
assert.equal(result.priority, null);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function isShaNullish(value) {
|
|
2
|
+
return value === null || value === undefined;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Normalize optional merge request thread position input before Zod validation.
|
|
6
|
+
* Omits position only when it was not provided or all commit SHAs are nullish (MCP null injection).
|
|
7
|
+
* Partial or invalid SHA sets are passed through so schema validation can reject them.
|
|
8
|
+
*/
|
|
9
|
+
export function omitIncompleteMergeRequestPosition(value) {
|
|
10
|
+
if (value === null || value === undefined) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
const record = value;
|
|
17
|
+
const { base_sha, head_sha, start_sha } = record;
|
|
18
|
+
const hasAllRequiredShas = typeof base_sha === "string" &&
|
|
19
|
+
typeof head_sha === "string" &&
|
|
20
|
+
typeof start_sha === "string";
|
|
21
|
+
if (hasAllRequiredShas) {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
const allShasNullish = isShaNullish(base_sha) && isShaNullish(head_sha) && isShaNullish(start_sha);
|
|
25
|
+
if (allShasNullish) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const TOOL_PRESERVE_TOP_LEVEL_NULL_KEYS = {
|
|
2
|
+
create_label: ["priority"],
|
|
3
|
+
update_label: ["priority"],
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Remove omitted optional fields injected as top-level null/undefined by MCP clients.
|
|
7
|
+
* Does not recurse into nested objects so intentional nulls (diff line numbers, GraphQL variables) stay intact.
|
|
8
|
+
*/
|
|
9
|
+
export function sanitizeToolArguments(toolName, args) {
|
|
10
|
+
const preserveNullKeys = new Set(TOOL_PRESERVE_TOP_LEVEL_NULL_KEYS[toolName] ?? []);
|
|
11
|
+
const result = {};
|
|
12
|
+
for (const [key, value] of Object.entries(args)) {
|
|
13
|
+
if (value === undefined) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (value === null && !preserveNullKeys.has(key)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
result[key] = value;
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.16",
|
|
4
4
|
"mcpName": "io.github.zereight/gitlab-mcp",
|
|
5
5
|
"description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"changelog": "auto-changelog -p",
|
|
52
52
|
"test": "npm run test:all",
|
|
53
53
|
"test:all": "npm run build && npm run test:mock && npm run test:live",
|
|
54
|
-
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
|
|
54
|
+
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts",
|
|
55
55
|
"test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
|
|
56
56
|
"test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
|
|
57
57
|
"test:live": "node test/validate-api.js",
|