@zereight/mcp-gitlab 2.1.41 → 2.1.42

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 CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
82
82
 
83
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.40`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
83
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.41`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
84
84
 
85
85
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
86
86
 
package/README.md CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
82
82
 
83
- No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.40`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
83
+ No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.41`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
84
84
 
85
85
  #### Using CLI Arguments (for clients with env var issues)
86
86
 
package/README.zh-CN.md CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
82
82
 
83
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.40`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
83
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.41`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
84
84
 
85
85
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
86
86
 
package/build/index.js CHANGED
@@ -90,6 +90,8 @@ import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation
90
90
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
91
91
  import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
92
92
  import { checkForNewVersion } from "./utils/version-check.js";
93
+ import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
94
+ import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./utils/bulk-publish-options.js";
93
95
  import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
94
96
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
95
97
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
@@ -4424,19 +4426,49 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
4424
4426
  };
4425
4427
  }
4426
4428
  }
4429
+ async function fetchGitLabInstanceVersion() {
4430
+ try {
4431
+ const response = await fetch(`${getEffectiveApiUrl()}/version`, {
4432
+ ...getFetchConfig(),
4433
+ });
4434
+ if (!response.ok)
4435
+ return null;
4436
+ const data = await response.json();
4437
+ if (typeof data === "object" &&
4438
+ data !== null &&
4439
+ "version" in data &&
4440
+ typeof data.version === "string") {
4441
+ return data.version;
4442
+ }
4443
+ return null;
4444
+ }
4445
+ catch {
4446
+ return null;
4447
+ }
4448
+ }
4427
4449
  /**
4428
4450
  * Publish all draft notes for a merge request
4429
4451
  * @param {string} projectId - The ID or URL-encoded path of the project
4430
4452
  * @param {number|string} mergeRequestIid - The internal ID of the merge request
4453
+ * @param {BulkPublishDraftNotesBody} options - Optional GitLab 19.2+ bulk_publish body fields
4431
4454
  * @returns {Promise<GitLabDiscussionNote[]>} Array of published notes
4432
4455
  */
4433
- async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
4456
+ async function bulkPublishDraftNotes(projectId, mergeRequestIid, options = {}) {
4434
4457
  projectId = decodeURIComponent(projectId);
4435
4458
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}/draft_notes/bulk_publish`);
4459
+ const body = buildBulkPublishDraftNotesBody(options);
4460
+ if (needsGitLab19_2BulkPublish(body)) {
4461
+ await assertGitLabVersionAtLeast({
4462
+ major: 19,
4463
+ minor: 2,
4464
+ feature: "reviewer_state, note, and internal on bulk_publish_draft_notes",
4465
+ retryHint: "Omit reviewer_state, note, and internal, then retry.",
4466
+ }, fetchGitLabInstanceVersion);
4467
+ }
4436
4468
  const response = await fetch(url.toString(), {
4437
4469
  ...getFetchConfig(),
4438
4470
  method: "POST", // Changed from PUT to POST
4439
- body: JSON.stringify({}), // Send empty body for POST request
4471
+ body: JSON.stringify(body),
4440
4472
  });
4441
4473
  if (!response.ok) {
4442
4474
  const errorText = await response.text();
@@ -7612,8 +7644,8 @@ async function handleToolCall(params) {
7612
7644
  }
7613
7645
  case "bulk_publish_draft_notes": {
7614
7646
  const args = BulkPublishDraftNotesSchema.parse(params.arguments);
7615
- const { project_id, merge_request_iid } = args;
7616
- const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid);
7647
+ const { project_id, merge_request_iid, ...options } = args;
7648
+ const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid, options);
7617
7649
  return {
7618
7650
  content: [{ type: "text", text: JSON.stringify(publishedNotes) }],
7619
7651
  };
package/build/schemas.js CHANGED
@@ -2547,26 +2547,31 @@ export const MergeRequestThreadPositionSchema = z.object({
2547
2547
  });
2548
2548
  const optionalMergeRequestThreadPosition = z.preprocess(omitIncompleteMergeRequestPosition, MergeRequestThreadPositionSchema.optional());
2549
2549
  // Draft Notes API schemas
2550
+ // Response shape follows Draft Notes API (not Notes API): note, author_id,
2551
+ // line_code, merge_request_id, commit_id — no created_at/updated_at.
2550
2552
  export const GitLabDraftNoteSchema = z
2551
2553
  .object({
2552
2554
  id: z.coerce.string(),
2555
+ author_id: z.coerce.number().nullable().optional(),
2553
2556
  author: GitLabUserSchema.optional(),
2554
2557
  body: z.string().optional(),
2555
- note: z.string().optional(), // Some APIs might use 'note' instead of 'body'
2556
- created_at: z.string().optional(),
2557
- updated_at: z.string().optional(),
2558
+ note: z.string().optional(), // Draft Notes API uses 'note'; keep 'body' for callers
2559
+ line_code: z.string().nullable().optional(),
2560
+ merge_request_id: z.coerce.number().nullable().optional(),
2561
+ commit_id: z.string().nullable().optional(),
2558
2562
  discussion_id: z.string().nullable().optional(),
2559
2563
  position: z.record(z.unknown()).nullable().optional(),
2560
2564
  resolve_discussion: z.coerce.boolean().optional(),
2561
2565
  })
2562
2566
  .transform(data => ({
2563
- // Normalize the response to always have consistent field names
2564
2567
  id: data.id,
2568
+ author_id: data.author_id ?? null,
2565
2569
  author: data.author,
2566
2570
  body: data.body || data.note || "",
2567
- created_at: data.created_at || "",
2568
- updated_at: data.updated_at || "",
2569
- discussion_id: data.discussion_id || null,
2571
+ line_code: data.line_code ?? null,
2572
+ merge_request_id: data.merge_request_id ?? null,
2573
+ commit_id: data.commit_id ?? null,
2574
+ discussion_id: data.discussion_id ?? null,
2570
2575
  position: data.position,
2571
2576
  resolve_discussion: data.resolve_discussion,
2572
2577
  }));
@@ -2617,6 +2622,18 @@ export const PublishDraftNoteSchema = ProjectParamsSchema.extend({
2617
2622
  // Bulk publish draft notes schema
2618
2623
  export const BulkPublishDraftNotesSchema = ProjectParamsSchema.extend({
2619
2624
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2625
+ reviewer_state: z
2626
+ .enum(["requested_changes", "reviewed"])
2627
+ .optional()
2628
+ .describe("Set reviewer review state after publishing (GitLab 19.2+). Does not record a formal approval. Works even with no draft notes."),
2629
+ note: z
2630
+ .string()
2631
+ .optional()
2632
+ .describe("Summary note body to post on the merge request (GitLab 19.2+)"),
2633
+ internal: z.coerce
2634
+ .boolean()
2635
+ .optional()
2636
+ .describe("If true, the summary note is internal (GitLab 19.2+, default false)"),
2620
2637
  });
2621
2638
  // Schema for creating a new merge request thread
2622
2639
  export const CreateMergeRequestThreadSchema = ProjectParamsSchema.extend({
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { GitLabDraftNoteSchema } from "../schemas.js";
4
+ test("draft note schema keeps Draft Notes API fields and drops fake timestamps", () => {
5
+ const parsed = GitLabDraftNoteSchema.parse({
6
+ id: 42,
7
+ author_id: 7,
8
+ note: "looks wrong on this line",
9
+ line_code: "a1b2c3d4e5f6_12_17",
10
+ merge_request_id: 99,
11
+ commit_id: "abc123",
12
+ discussion_id: null,
13
+ position: { new_path: "src/app.ts", new_line: 17 },
14
+ resolve_discussion: false,
15
+ // Notes-API leftovers that Draft Notes API never returns — must be stripped
16
+ created_at: "2024-01-01T00:00:00Z",
17
+ updated_at: "2024-01-02T00:00:00Z",
18
+ });
19
+ assert.equal(parsed.id, "42");
20
+ assert.equal(parsed.author_id, 7);
21
+ assert.equal(parsed.body, "looks wrong on this line");
22
+ assert.equal(parsed.line_code, "a1b2c3d4e5f6_12_17");
23
+ assert.equal(parsed.merge_request_id, 99);
24
+ assert.equal(parsed.commit_id, "abc123");
25
+ assert.equal(parsed.discussion_id, null);
26
+ assert.equal(parsed.resolve_discussion, false);
27
+ assert.equal("created_at" in parsed, false);
28
+ assert.equal("updated_at" in parsed, false);
29
+ });
30
+ test("draft note schema preserves null line_code for unresolved positions", () => {
31
+ const parsed = GitLabDraftNoteSchema.parse({
32
+ id: "43",
33
+ author_id: 7,
34
+ note: "orphan draft",
35
+ line_code: null,
36
+ merge_request_id: 99,
37
+ commit_id: null,
38
+ discussion_id: null,
39
+ position: null,
40
+ resolve_discussion: false,
41
+ });
42
+ assert.equal(parsed.line_code, null);
43
+ assert.equal(parsed.commit_id, null);
44
+ });
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "../../utils/bulk-publish-options.js";
4
+ describe("When buildBulkPublishDraftNotesBody runs", () => {
5
+ describe("with no-op defaults", () => {
6
+ test("should omit internal false and empty note", () => {
7
+ assert.deepEqual(buildBulkPublishDraftNotesBody({ internal: false, note: "" }), {});
8
+ });
9
+ });
10
+ describe("with meaningful 19.2 fields", () => {
11
+ test("should keep reviewer_state, note, and internal true", () => {
12
+ assert.deepEqual(buildBulkPublishDraftNotesBody({
13
+ reviewer_state: "reviewed",
14
+ note: "LGTM",
15
+ internal: true,
16
+ }), { reviewer_state: "reviewed", note: "LGTM", internal: true });
17
+ });
18
+ });
19
+ });
20
+ describe("When needsGitLab19_2BulkPublish runs", () => {
21
+ describe("with an empty body", () => {
22
+ test("should return false", () => {
23
+ assert.equal(needsGitLab19_2BulkPublish({}), false);
24
+ assert.equal(needsGitLab19_2BulkPublish(buildBulkPublishDraftNotesBody({ internal: false })), false);
25
+ });
26
+ });
27
+ describe("with a non-empty body", () => {
28
+ test("should return true", () => {
29
+ assert.equal(needsGitLab19_2BulkPublish({ reviewer_state: "reviewed" }), true);
30
+ });
31
+ });
32
+ });
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { assertGitLabVersionAtLeast, isGitLabVersionAtLeast, parseGitLabVersion, } from "../../utils/gitlab-version-gate.js";
4
+ function versionFetcher(version) {
5
+ return async () => version;
6
+ }
7
+ function bulkPublishRequirement() {
8
+ return {
9
+ major: 19,
10
+ minor: 2,
11
+ feature: "reviewer_state, note, and internal on bulk_publish_draft_notes",
12
+ retryHint: "Omit reviewer_state, note, and internal, then retry.",
13
+ };
14
+ }
15
+ describe("When parseGitLabVersion runs", () => {
16
+ describe("with a release suffix", () => {
17
+ test("should parse major.minor.patch from ee builds", () => {
18
+ assert.deepEqual(parseGitLabVersion("19.2.0-ee"), {
19
+ major: 19,
20
+ minor: 2,
21
+ patch: 0,
22
+ });
23
+ });
24
+ });
25
+ describe("with a malformed string", () => {
26
+ test("should return null", () => {
27
+ assert.equal(parseGitLabVersion("unknown"), null);
28
+ assert.equal(parseGitLabVersion(""), null);
29
+ });
30
+ });
31
+ });
32
+ describe("When isGitLabVersionAtLeast compares versions", () => {
33
+ describe("with a version at or above the floor", () => {
34
+ test("should return true", () => {
35
+ assert.equal(isGitLabVersionAtLeast("19.2.0-ee", 19, 2), true);
36
+ assert.equal(isGitLabVersionAtLeast("19.3.1", 19, 2), true);
37
+ assert.equal(isGitLabVersionAtLeast("20.0.0", 19, 2), true);
38
+ });
39
+ });
40
+ describe("with a version below the floor", () => {
41
+ test("should return false", () => {
42
+ assert.equal(isGitLabVersionAtLeast("19.1.9-ee", 19, 2), false);
43
+ assert.equal(isGitLabVersionAtLeast("17.5.0", 19, 2), false);
44
+ });
45
+ });
46
+ describe("with an unparseable version", () => {
47
+ test("should return null", () => {
48
+ assert.equal(isGitLabVersionAtLeast("not-a-version", 19, 2), null);
49
+ });
50
+ });
51
+ });
52
+ describe("When assertGitLabVersionAtLeast runs", () => {
53
+ describe("with an older instance version", () => {
54
+ test("should throw a retryable error", async () => {
55
+ await assert.rejects(() => assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher("17.5.0-ee")), (error) => {
56
+ assert.ok(error instanceof Error);
57
+ assert.match(error.message, /GitLab 19\.2\+/);
58
+ assert.match(error.message, /17\.5\.0-ee/);
59
+ assert.match(error.message, /Omit reviewer_state/);
60
+ return true;
61
+ });
62
+ });
63
+ });
64
+ describe("with a new enough instance version", () => {
65
+ test("should resolve without throwing", async () => {
66
+ await assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher("19.2.0-ee"));
67
+ });
68
+ });
69
+ describe("with an unknown instance version", () => {
70
+ test("should fail open and resolve", async () => {
71
+ await assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher(null));
72
+ });
73
+ });
74
+ });
@@ -267,7 +267,7 @@ export const allTools = [
267
267
  },
268
268
  {
269
269
  name: "bulk_publish_draft_notes",
270
- description: "Publish all draft notes for a merge request",
270
+ description: "Publish all draft notes for a merge request. Optionally sets reviewer_state and posts a summary note (GitLab 19.2+). Can set reviewer_state even with no drafts.",
271
271
  inputSchema: toJSONSchema(BulkPublishDraftNotesSchema),
272
272
  },
273
273
  // --- Merge request emoji reaction tools ---
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Build the POST body. Omit no-op defaults (`internal: false`, empty `note`)
3
+ * so older GitLab instances are not sent unknown fields.
4
+ */
5
+ export function buildBulkPublishDraftNotesBody(options) {
6
+ const body = {};
7
+ if (options.reviewer_state !== undefined) {
8
+ body.reviewer_state = options.reviewer_state;
9
+ }
10
+ if (options.note) {
11
+ body.note = options.note;
12
+ }
13
+ if (options.internal === true) {
14
+ body.internal = true;
15
+ }
16
+ return body;
17
+ }
18
+ /** True when the body needs GitLab 19.2+ bulk_publish fields. */
19
+ export function needsGitLab19_2BulkPublish(body) {
20
+ return Object.keys(body).length > 0;
21
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Call-time GitLab instance version gate.
3
+ *
4
+ * Use when a tool exposes optional params that only exist on newer GitLab.
5
+ * Schemas stay static; check before the API call and throw a retryable error.
6
+ * Fail-open when the instance version cannot be fetched or parsed.
7
+ */
8
+ const VERSION_CORE = /^(\d+)\.(\d+)(?:\.(\d+))?/;
9
+ /** Parse strings like "19.2.0-ee" / "17.5.3". Returns null if unparseable. */
10
+ export function parseGitLabVersion(version) {
11
+ const match = VERSION_CORE.exec(version.trim());
12
+ if (!match)
13
+ return null;
14
+ const major = Number.parseInt(match[1], 10);
15
+ const minor = Number.parseInt(match[2], 10);
16
+ const patch = match[3] ? Number.parseInt(match[3], 10) : 0;
17
+ if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
18
+ return null;
19
+ }
20
+ return { major, minor, patch };
21
+ }
22
+ /**
23
+ * Compare a GitLab version string against a major.minor floor.
24
+ * Returns null when the version string cannot be parsed.
25
+ */
26
+ export function isGitLabVersionAtLeast(version, major, minor) {
27
+ const parsed = parseGitLabVersion(version);
28
+ if (!parsed)
29
+ return null;
30
+ if (parsed.major !== major)
31
+ return parsed.major > major;
32
+ return parsed.minor >= minor;
33
+ }
34
+ /**
35
+ * Throws when the instance is known to be older than `requirement`.
36
+ * Does nothing when the version is unknown (fail-open).
37
+ */
38
+ export async function assertGitLabVersionAtLeast(requirement, fetchVersion) {
39
+ const current = await fetchVersion();
40
+ if (current === null)
41
+ return;
42
+ const ok = isGitLabVersionAtLeast(current, requirement.major, requirement.minor);
43
+ if (ok === null || ok)
44
+ return;
45
+ throw new Error(`GitLab ${requirement.major}.${requirement.minor}+ required for ${requirement.feature} ` +
46
+ `(instance reports ${current}). ${requirement.retryHint}`);
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.41",
3
+ "version": "2.1.42",
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": [