@zereight/mcp-gitlab 2.1.14 → 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 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: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
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: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
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: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
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({
@@ -2580,7 +2585,7 @@ export const GitLabProjectMemberSchema = z.object({
2580
2585
  });
2581
2586
  // Markdown upload schemas
2582
2587
  export const GitLabMarkdownUploadSchema = z.object({
2583
- id: z.coerce.number(),
2588
+ id: z.preprocess((val) => (val == null ? undefined : val), z.coerce.number().optional()),
2584
2589
  alt: z.string(),
2585
2590
  url: z.string(),
2586
2591
  full_path: z.string(),
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ts-node
2
- import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema } from '../schemas.js';
2
+ import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema, GitLabMarkdownUploadSchema, } from '../schemas.js';
3
3
  function runGetFileContentsSchemaTests() {
4
4
  console.log('🧪 Testing GetFileContentsSchema...');
5
5
  const cases = [
@@ -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 = [
@@ -1122,6 +1235,75 @@ function runGetRepositoryTreeSchemaTests() {
1122
1235
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
1123
1236
  return { passed, failed };
1124
1237
  }
1238
+ function runGitLabMarkdownUploadSchemaTests() {
1239
+ console.log('\n=== GitLabMarkdownUpload Schema Tests ===');
1240
+ const idlessUpload = {
1241
+ alt: 'report.md',
1242
+ url: '/uploads/c617e74a47dfb1a6dd59d419619b725d/report.md',
1243
+ full_path: '/group/project/uploads/c617e74a47dfb1a6dd59d419619b725d/report.md',
1244
+ markdown: '[report.md](/uploads/c617e74a47dfb1a6dd59d419619b725d/report.md)',
1245
+ };
1246
+ const cases = [
1247
+ {
1248
+ name: 'schema:markdown_upload:accepts-idless-response',
1249
+ input: idlessUpload,
1250
+ expectedId: 'absent',
1251
+ },
1252
+ {
1253
+ name: 'schema:markdown_upload:accepts-numeric-id',
1254
+ input: { ...idlessUpload, id: 42 },
1255
+ expectedId: 42,
1256
+ },
1257
+ {
1258
+ name: 'schema:markdown_upload:coerces-string-id',
1259
+ input: { ...idlessUpload, id: '99' },
1260
+ expectedId: 99,
1261
+ },
1262
+ {
1263
+ name: 'schema:markdown_upload:treats-null-id-as-absent',
1264
+ input: { ...idlessUpload, id: null },
1265
+ expectedId: 'absent',
1266
+ },
1267
+ {
1268
+ name: 'schema:markdown_upload:rejects-invalid-id',
1269
+ input: { ...idlessUpload, id: 'not-a-number' },
1270
+ shouldFail: true,
1271
+ },
1272
+ ];
1273
+ let passed = 0;
1274
+ let failed = 0;
1275
+ cases.forEach(testCase => {
1276
+ const result = { name: testCase.name, status: 'failed' };
1277
+ const parsed = GitLabMarkdownUploadSchema.safeParse(testCase.input);
1278
+ if (testCase.shouldFail) {
1279
+ result.status = parsed.success ? 'failed' : 'passed';
1280
+ if (parsed.success)
1281
+ result.error = 'Expected schema validation to fail';
1282
+ }
1283
+ else if (!parsed.success) {
1284
+ result.error = parsed.error?.message || 'Schema validation failed';
1285
+ }
1286
+ else if (testCase.expectedId === 'absent' && parsed.data.id !== undefined) {
1287
+ result.error = `Expected id undefined, got ${parsed.data.id}`;
1288
+ }
1289
+ else if (typeof testCase.expectedId === 'number' && parsed.data.id !== testCase.expectedId) {
1290
+ result.error = `Expected id ${testCase.expectedId}, got ${parsed.data.id}`;
1291
+ }
1292
+ else {
1293
+ result.status = 'passed';
1294
+ }
1295
+ if (result.status === 'passed') {
1296
+ passed++;
1297
+ console.log(`✅ ${result.name}`);
1298
+ }
1299
+ else {
1300
+ failed++;
1301
+ console.log(`❌ ${result.name}: ${result.error}`);
1302
+ }
1303
+ });
1304
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
1305
+ return { passed, failed };
1306
+ }
1125
1307
  function runGitLabUserFullSchemaTests() {
1126
1308
  console.log('🧪 Testing GitLabUserFullSchema...');
1127
1309
  const adminResponse = {
@@ -1215,12 +1397,14 @@ if (import.meta.url === `file://${process.argv[1]}`) {
1215
1397
  const emojiReactionResult = runEmojiReactionSchemaTests();
1216
1398
  const repositorySchemaResult = runGitLabRepositorySchemaTests();
1217
1399
  const labelsCoercionResult = runLabelsCoercionSchemaTests();
1400
+ const approvedByUsernamesResult = runApprovedByUsernamesSchemaTests();
1218
1401
  const listLabelsResult = runListLabelsSchemaTests();
1219
1402
  const treeItemResult = runGitLabTreeItemSchemaTests();
1220
1403
  const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
1221
1404
  const gitLabUserFullResult = runGitLabUserFullSchemaTests();
1222
- 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;
1223
- 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;
1405
+ const gitLabMarkdownUploadResult = runGitLabMarkdownUploadSchemaTests();
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;
1224
1408
  console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
1225
1409
  if (totalFailed > 0) {
1226
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
  });
@@ -1,4 +1,4 @@
1
- import { describe, test, before, after } from 'node:test';
1
+ import { describe, test, before, after, beforeEach } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { spawn } from 'node:child_process';
4
4
  import fs from 'node:fs';
@@ -55,12 +55,18 @@ const MOCK_UPLOAD_RESPONSE = {
55
55
  full_path: '/test-group/test-project/uploads/abc123secret/test-file.txt',
56
56
  markdown: '[test-file.txt](/uploads/abc123secret/test-file.txt)',
57
57
  };
58
+ let uploadResponse = MOCK_UPLOAD_RESPONSE;
58
59
  describe('upload_markdown', () => {
59
60
  let mockGitLab;
60
61
  let env;
61
62
  // Captured per-request state, reset before each invocation via the handler
62
63
  let lastContentType;
63
64
  let lastRawBody;
65
+ beforeEach(() => {
66
+ uploadResponse = MOCK_UPLOAD_RESPONSE;
67
+ lastContentType = undefined;
68
+ lastRawBody = undefined;
69
+ });
64
70
  before(async () => {
65
71
  const port = await findMockServerPort(9200);
66
72
  mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
@@ -75,7 +81,7 @@ describe('upload_markdown', () => {
75
81
  req.on('data', (chunk) => chunks.push(chunk));
76
82
  req.on('end', () => {
77
83
  lastRawBody = Buffer.concat(chunks).toString('binary');
78
- res.status(201).json(MOCK_UPLOAD_RESPONSE);
84
+ res.status(201).json(uploadResponse);
79
85
  });
80
86
  });
81
87
  });
@@ -139,6 +145,27 @@ describe('upload_markdown', () => {
139
145
  fs.unlinkSync(tmpFile);
140
146
  }
141
147
  });
148
+ test('accepts upload responses without id from older self-hosted GitLab', async () => {
149
+ const { id: _id, ...idlessUploadResponse } = MOCK_UPLOAD_RESPONSE;
150
+ uploadResponse = idlessUploadResponse;
151
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-idless-response-test.txt');
152
+ fs.writeFileSync(tmpFile, 'idless response field test');
153
+ try {
154
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
155
+ assert.ok(!raw.error, `Unexpected RPC error: ${raw.error?.message}`);
156
+ const text = raw.result?.content?.[0]?.text;
157
+ assert.ok(text, 'Result should contain a text content block');
158
+ const parsed = JSON.parse(text);
159
+ assert.strictEqual(parsed.id, undefined);
160
+ assert.strictEqual(parsed.markdown, MOCK_UPLOAD_RESPONSE.markdown);
161
+ assert.strictEqual(parsed.url, MOCK_UPLOAD_RESPONSE.url);
162
+ assert.strictEqual(parsed.alt, MOCK_UPLOAD_RESPONSE.alt);
163
+ assert.strictEqual(parsed.full_path, MOCK_UPLOAD_RESPONSE.full_path);
164
+ }
165
+ finally {
166
+ fs.unlinkSync(tmpFile);
167
+ }
168
+ });
142
169
  test('returns an error when the file does not exist', async () => {
143
170
  const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '/nonexistent/no-such-file.txt' }, env);
144
171
  const hasError = typeof raw.error?.message === 'string' ||
@@ -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.14",
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-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",