@zereight/mcp-gitlab 2.0.30 → 2.0.32

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.
@@ -0,0 +1,171 @@
1
+ import { after, before, describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { spawn } from "child_process";
4
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
5
+ const MOCK_TOKEN = "glpat-mock-token-approval";
6
+ const TEST_PROJECT_ID = "123";
7
+ const TEST_MR_IID_WITH_FALLBACK = "88";
8
+ const TEST_MR_IID_WITH_APPROVAL_STATE = "89";
9
+ async function callTool(toolName, args, env) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn("node", ["build/index.js"], {
12
+ stdio: ["pipe", "pipe", "pipe"],
13
+ env: {
14
+ ...process.env,
15
+ ...env,
16
+ GITLAB_READ_ONLY_MODE: "true",
17
+ USE_PIPELINE: "true",
18
+ },
19
+ });
20
+ let output = "";
21
+ let errorOutput = "";
22
+ proc.stdout?.on("data", (d) => (output += d));
23
+ proc.stderr?.on("data", (d) => (errorOutput += d));
24
+ proc.on("close", (code) => {
25
+ if (code !== 0) {
26
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
27
+ }
28
+ const line = output.split("\n").find(l => l.startsWith("{"));
29
+ if (!line) {
30
+ return reject(new Error("No JSON output found"));
31
+ }
32
+ try {
33
+ const response = JSON.parse(line);
34
+ if (response.error) {
35
+ reject(response.error);
36
+ }
37
+ else {
38
+ const content = response.result?.content?.[0]?.text;
39
+ if (content) {
40
+ try {
41
+ resolve(JSON.parse(content));
42
+ }
43
+ catch {
44
+ resolve(content);
45
+ }
46
+ }
47
+ else {
48
+ resolve(response.result);
49
+ }
50
+ }
51
+ }
52
+ catch (error) {
53
+ reject(error);
54
+ }
55
+ });
56
+ proc.stdin?.end(JSON.stringify({
57
+ jsonrpc: "2.0",
58
+ id: 1,
59
+ method: "tools/call",
60
+ params: { name: toolName, arguments: args },
61
+ }) + "\n");
62
+ });
63
+ }
64
+ describe("merge request approval state tools", () => {
65
+ let mockGitLab;
66
+ let mockGitLabUrl;
67
+ before(async () => {
68
+ const mockPort = await findMockServerPort(9400);
69
+ mockGitLab = new MockGitLabServer({
70
+ port: mockPort,
71
+ validTokens: [MOCK_TOKEN],
72
+ });
73
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITH_FALLBACK}/approval_state`, (_req, res) => {
74
+ res.status(404).json({ error: "404 Not Found" });
75
+ });
76
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITH_FALLBACK}/approvals`, (_req, res) => {
77
+ res.json({
78
+ approved: true,
79
+ user_has_approved: false,
80
+ user_can_approve: true,
81
+ approved_by: [
82
+ {
83
+ user: {
84
+ id: "35",
85
+ username: "sergey.kravchenya",
86
+ name: "Sergey Kravchenya",
87
+ state: "active",
88
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
89
+ web_url: "https://gitlab.mock/sergey.kravchenya",
90
+ },
91
+ },
92
+ ],
93
+ });
94
+ });
95
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITH_APPROVAL_STATE}/approval_state`, (_req, res) => {
96
+ res.json({
97
+ approval_rules_overwritten: false,
98
+ rules: [
99
+ {
100
+ id: "101",
101
+ name: "Default rule",
102
+ rule_type: "regular",
103
+ approvals_required: 1,
104
+ approved: true,
105
+ approved_by: [
106
+ {
107
+ id: "35",
108
+ username: "sergey.kravchenya",
109
+ name: "Sergey Kravchenya",
110
+ state: "active",
111
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
112
+ web_url: "https://gitlab.mock/sergey.kravchenya",
113
+ },
114
+ ],
115
+ },
116
+ {
117
+ id: "102",
118
+ name: "Code owners",
119
+ rule_type: "code_owner",
120
+ approvals_required: 1,
121
+ approved: true,
122
+ approved_by: [
123
+ {
124
+ id: "35",
125
+ username: "sergey.kravchenya",
126
+ name: "Sergey Kravchenya",
127
+ state: "active",
128
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
129
+ web_url: "https://gitlab.mock/sergey.kravchenya",
130
+ },
131
+ ],
132
+ },
133
+ ],
134
+ });
135
+ });
136
+ await mockGitLab.start();
137
+ mockGitLabUrl = mockGitLab.getUrl();
138
+ });
139
+ after(async () => {
140
+ await mockGitLab.stop();
141
+ });
142
+ test("falls back to approvals endpoint when approval_state is unavailable", async () => {
143
+ const result = await callTool("get_merge_request_approval_state", {
144
+ project_id: TEST_PROJECT_ID,
145
+ merge_request_iid: TEST_MR_IID_WITH_FALLBACK,
146
+ }, {
147
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
148
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
149
+ });
150
+ assert.strictEqual(result.source_endpoint, "approvals");
151
+ assert.strictEqual(result.approved, true);
152
+ assert.deepStrictEqual(result.approved_by_usernames, ["sergey.kravchenya"]);
153
+ assert.ok(Array.isArray(result.approved_by));
154
+ assert.strictEqual(result.approved_by[0].username, "sergey.kravchenya");
155
+ });
156
+ test("returns deduplicated approvers from approval_state rules", async () => {
157
+ const result = await callTool("get_merge_request_approval_state", {
158
+ project_id: TEST_PROJECT_ID,
159
+ merge_request_iid: TEST_MR_IID_WITH_APPROVAL_STATE,
160
+ }, {
161
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
162
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
163
+ });
164
+ assert.strictEqual(result.source_endpoint, "approval_state");
165
+ assert.strictEqual(result.rules.length, 2);
166
+ assert.deepStrictEqual(result.approved_by_usernames, ["sergey.kravchenya"]);
167
+ assert.ok(Array.isArray(result.approved_by));
168
+ assert.strictEqual(result.approved_by.length, 1);
169
+ assert.strictEqual(result.approved_by[0].username, "sergey.kravchenya");
170
+ });
171
+ });
@@ -35,10 +35,12 @@ const DEFAULT_TOOLSETS = [
35
35
  "branches",
36
36
  "projects",
37
37
  "labels",
38
+ "pipelines",
39
+ "milestones",
40
+ "wiki",
38
41
  "releases",
39
42
  "users",
40
43
  ];
41
- const NON_DEFAULT_TOOLSETS = ["pipelines", "milestones", "wiki"];
42
44
  const DEFAULT_TOOL_COUNT = DEFAULT_TOOLSETS.reduce((sum, id) => sum + TOOLSET_TOOL_COUNTS[id], 0);
43
45
  const ALL_TOOLSET_TOOL_COUNT = Object.values(TOOLSET_TOOL_COUNTS).reduce((sum, c) => sum + c, 0);
44
46
  // Representative tools per toolset for spot-checking
@@ -128,10 +130,9 @@ describe("Toolset Filtering", () => {
128
130
  assertContainsAll(tools, TOOLSET_SAMPLE_TOOLS[id], id);
129
131
  }
130
132
  });
131
- test("excludes non-default toolsets (pipelines, milestones, wiki)", () => {
132
- for (const id of NON_DEFAULT_TOOLSETS) {
133
- assertContainsNone(tools, TOOLSET_SAMPLE_TOOLS[id], id);
134
- }
133
+ test("includes all toolsets by default (no non-default toolsets)", () => {
134
+ // All toolsets are now default, so default count equals all toolset count
135
+ assert.strictEqual(tools.length, ALL_TOOLSET_TOOL_COUNT);
135
136
  });
136
137
  test("excludes execute_graphql (not in any toolset)", () => {
137
138
  assertContainsNone(tools, ["execute_graphql"], "unassigned");
@@ -175,7 +176,7 @@ describe("Toolset Filtering", () => {
175
176
  assert.strictEqual(tools.length, ALL_TOOLSET_TOOL_COUNT);
176
177
  });
177
178
  test("includes pipelines, milestones, and wiki", () => {
178
- for (const id of NON_DEFAULT_TOOLSETS) {
179
+ for (const id of ["pipelines", "milestones", "wiki"]) {
179
180
  assertContainsAll(tools, TOOLSET_SAMPLE_TOOLS[id], id);
180
181
  }
181
182
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.0.30",
3
+ "version": "2.0.32",
4
4
  "description": "MCP server for using the GitLab API",
5
5
  "license": "MIT",
6
6
  "author": "zereight",
@@ -29,9 +29,10 @@
29
29
  "changelog": "auto-changelog -p",
30
30
  "test": "npm run test:all",
31
31
  "test:all": "npm run build && npm run test:mock && npm run test:live",
32
- "test:mock": "npx tsx --test test/remote-auth-simple-test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts",
32
+ "test:mock": "npx tsx --test test/remote-auth-simple-test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && tsx --test test/test-job-artifacts.ts && tsx --test test/test-deployment-tools.ts && tsx --test test/test-merge-request-approval-state-tools.ts",
33
33
  "test:live": "node test/validate-api.js",
34
34
  "test:remote-auth": "npm run build && npx tsx --test test/remote-auth-simple-test.ts",
35
+ "test:schema": "tsx test/schema-tests.ts",
35
36
  "test:oauth": "tsx test/oauth-tests.ts",
36
37
  "test:list-merge-requests": "npm run build && tsx test/test-list-merge-requests.ts",
37
38
  "test:approvals": "npm run build && tsx test/test-merge-request-approvals.ts",