@zereight/mcp-gitlab 2.1.19 → 2.1.21
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 +27 -18
- package/build/schemas.js +10 -4
- package/build/test/test-list-issues.js +111 -0
- package/build/test/utils/graphql-query.test.js +33 -0
- package/build/test/utils/tool-args.test.js +48 -1
- package/build/test/utils/wiki-title.test.js +21 -0
- package/build/utils/graphql-query.js +52 -0
- package/build/utils/tool-args.js +27 -0
- package/build/utils/wiki-title.js +17 -0
- package/package.json +2 -2
package/build/index.js
CHANGED
|
@@ -128,7 +128,9 @@ 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 {
|
|
131
|
+
import { graphqlQueryContainsWriteOperation } from "./utils/graphql-query.js";
|
|
132
|
+
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
133
|
+
import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
|
|
132
134
|
import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
|
|
133
135
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
134
136
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
@@ -4594,8 +4596,15 @@ async function createWikiPage(projectId, title, content, format) {
|
|
|
4594
4596
|
async function updateWikiPage(projectId, slug, title, content, format) {
|
|
4595
4597
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
4596
4598
|
const body = {};
|
|
4597
|
-
if (title)
|
|
4598
|
-
|
|
4599
|
+
if (title) {
|
|
4600
|
+
if (slug.includes("/") && !title.includes("/")) {
|
|
4601
|
+
const existing = await getWikiPage(projectId, slug);
|
|
4602
|
+
body.title = resolveNestedWikiUpdateTitle(slug, title, existing.title);
|
|
4603
|
+
}
|
|
4604
|
+
else {
|
|
4605
|
+
body.title = title;
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4599
4608
|
if (content)
|
|
4600
4609
|
body.content = content;
|
|
4601
4610
|
if (format)
|
|
@@ -4672,8 +4681,15 @@ async function createGroupWikiPage(groupId, title, content, format) {
|
|
|
4672
4681
|
async function updateGroupWikiPage(groupId, slug, title, content, format) {
|
|
4673
4682
|
groupId = decodeURIComponent(groupId); // Decode group ID
|
|
4674
4683
|
const body = {};
|
|
4675
|
-
if (title)
|
|
4676
|
-
|
|
4684
|
+
if (title) {
|
|
4685
|
+
if (slug.includes("/") && !title.includes("/")) {
|
|
4686
|
+
const existing = await getGroupWikiPage(groupId, slug);
|
|
4687
|
+
body.title = resolveNestedWikiUpdateTitle(slug, title, existing.title);
|
|
4688
|
+
}
|
|
4689
|
+
else {
|
|
4690
|
+
body.title = title;
|
|
4691
|
+
}
|
|
4692
|
+
}
|
|
4677
4693
|
if (content)
|
|
4678
4694
|
body.content = content;
|
|
4679
4695
|
if (format)
|
|
@@ -6357,6 +6373,9 @@ async function handleToolCall(params) {
|
|
|
6357
6373
|
switch (params.name) {
|
|
6358
6374
|
case "execute_graphql": {
|
|
6359
6375
|
const args = ExecuteGraphQLSchema.parse(params.arguments);
|
|
6376
|
+
if (GITLAB_READ_ONLY_MODE && graphqlQueryContainsWriteOperation(args.query)) {
|
|
6377
|
+
throw new Error("execute_graphql does not allow mutation or subscription operations in read-only mode");
|
|
6378
|
+
}
|
|
6360
6379
|
const apiUrl = new URL(getEffectiveApiUrl());
|
|
6361
6380
|
// Build GraphQL endpoint preserving any instance subpath (e.g. /gitlab)
|
|
6362
6381
|
const restPath = apiUrl.pathname || ""; // e.g. /api/v4 or /gitlab/api/v4
|
|
@@ -7074,7 +7093,8 @@ async function handleToolCall(params) {
|
|
|
7074
7093
|
case "list_issues": {
|
|
7075
7094
|
const args = ListIssuesSchema.parse(params.arguments);
|
|
7076
7095
|
const { project_id, ...options } = args;
|
|
7077
|
-
const
|
|
7096
|
+
const cleanedOptions = cleanMutuallyExclusiveIdUsernameOptions(options);
|
|
7097
|
+
const issues = await listIssues(project_id, cleanedOptions);
|
|
7078
7098
|
return {
|
|
7079
7099
|
content: [{ type: "text", text: JSON.stringify(issues, null, 2) }],
|
|
7080
7100
|
};
|
|
@@ -7743,18 +7763,7 @@ async function handleToolCall(params) {
|
|
|
7743
7763
|
}
|
|
7744
7764
|
case "list_merge_requests": {
|
|
7745
7765
|
const { project_id, ...options } = ListMergeRequestsSchema.parse(params.arguments);
|
|
7746
|
-
|
|
7747
|
-
// When both are provided, prefer _username and remove _id to avoid 400 errors.
|
|
7748
|
-
const cleanedOptions = { ...options };
|
|
7749
|
-
if (cleanedOptions.author_id && cleanedOptions.author_username) {
|
|
7750
|
-
delete cleanedOptions.author_id;
|
|
7751
|
-
}
|
|
7752
|
-
if (cleanedOptions.assignee_id && cleanedOptions.assignee_username) {
|
|
7753
|
-
delete cleanedOptions.assignee_id;
|
|
7754
|
-
}
|
|
7755
|
-
if (cleanedOptions.reviewer_id && cleanedOptions.reviewer_username) {
|
|
7756
|
-
delete cleanedOptions.reviewer_id;
|
|
7757
|
-
}
|
|
7766
|
+
const cleanedOptions = cleanMutuallyExclusiveIdUsernameOptions(options, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS);
|
|
7758
7767
|
const mergeRequests = await listMergeRequests(project_id, cleanedOptions);
|
|
7759
7768
|
return {
|
|
7760
7769
|
content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }],
|
package/build/schemas.js
CHANGED
|
@@ -1831,13 +1831,19 @@ export const ListIssuesSchema = z
|
|
|
1831
1831
|
assignee_id: z.coerce
|
|
1832
1832
|
.string()
|
|
1833
1833
|
.optional()
|
|
1834
|
-
.describe("Return issues assigned to the given user ID
|
|
1834
|
+
.describe("Return issues assigned to the given user ID (user id, none, or any). Mutually exclusive with assignee_username."),
|
|
1835
1835
|
assignee_username: z
|
|
1836
1836
|
.array(z.string())
|
|
1837
1837
|
.optional()
|
|
1838
|
-
.describe("Return issues assigned to the given username"),
|
|
1839
|
-
author_id: z.coerce
|
|
1840
|
-
|
|
1838
|
+
.describe("Return issues assigned to the given username. Mutually exclusive with assignee_id."),
|
|
1839
|
+
author_id: z.coerce
|
|
1840
|
+
.string()
|
|
1841
|
+
.optional()
|
|
1842
|
+
.describe("Return issues created by the given user ID. Mutually exclusive with author_username."),
|
|
1843
|
+
author_username: z
|
|
1844
|
+
.string()
|
|
1845
|
+
.optional()
|
|
1846
|
+
.describe("Return issues created by the given username. Mutually exclusive with author_id."),
|
|
1841
1847
|
confidential: z.coerce.boolean().optional().describe("Filter confidential or public issues"),
|
|
1842
1848
|
created_after: z.string().optional().describe("Return issues created after the given time"),
|
|
1843
1849
|
created_before: z.string().optional().describe("Return issues created before the given time"),
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, test, before, after } 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-12345";
|
|
6
|
+
const TEST_PROJECT_ID = "123";
|
|
7
|
+
async function callListIssues(args = {}, env) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
10
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
11
|
+
env: {
|
|
12
|
+
...process.env,
|
|
13
|
+
...env,
|
|
14
|
+
GITLAB_READ_ONLY_MODE: "true",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
let output = "";
|
|
18
|
+
let errorOutput = "";
|
|
19
|
+
proc.stdout?.on("data", d => (output += d));
|
|
20
|
+
proc.stderr?.on("data", d => (errorOutput += d));
|
|
21
|
+
proc.on("close", code => {
|
|
22
|
+
if (code !== 0) {
|
|
23
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
24
|
+
}
|
|
25
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
26
|
+
if (!line) {
|
|
27
|
+
return reject(new Error("No JSON output found"));
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const response = JSON.parse(line);
|
|
31
|
+
if (response.error) {
|
|
32
|
+
reject(response.error);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const content = response.result?.content?.[0]?.text;
|
|
36
|
+
if (content) {
|
|
37
|
+
try {
|
|
38
|
+
resolve(JSON.parse(content));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
resolve(response.result);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
reject(e);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
proc.stdin?.end(JSON.stringify({
|
|
54
|
+
jsonrpc: "2.0",
|
|
55
|
+
id: 1,
|
|
56
|
+
method: "tools/call",
|
|
57
|
+
params: { name: "list_issues", arguments: args },
|
|
58
|
+
}) + "\n");
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
describe("list_issues", () => {
|
|
62
|
+
let mockGitLab;
|
|
63
|
+
let mockGitLabUrl;
|
|
64
|
+
before(async () => {
|
|
65
|
+
const mockPort = await findMockServerPort(9000);
|
|
66
|
+
mockGitLab = new MockGitLabServer({
|
|
67
|
+
port: mockPort,
|
|
68
|
+
validTokens: [MOCK_TOKEN],
|
|
69
|
+
});
|
|
70
|
+
await mockGitLab.start();
|
|
71
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
72
|
+
});
|
|
73
|
+
after(async () => {
|
|
74
|
+
await mockGitLab.stop();
|
|
75
|
+
});
|
|
76
|
+
test("lists project-specific issues", async () => {
|
|
77
|
+
const issues = await callListIssues({ project_id: TEST_PROJECT_ID }, {
|
|
78
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
79
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
80
|
+
});
|
|
81
|
+
assert.ok(Array.isArray(issues), "Response should be an array");
|
|
82
|
+
assert.strictEqual(issues.length, 1, "Should return 1 mock issue");
|
|
83
|
+
const firstIssue = issues[0];
|
|
84
|
+
assert.ok(firstIssue && typeof firstIssue === "object" && "title" in firstIssue);
|
|
85
|
+
const title = Reflect.get(firstIssue, "title");
|
|
86
|
+
assert.strictEqual(title, "Test Issue 1");
|
|
87
|
+
});
|
|
88
|
+
test("prefers author_username over author_id when both are provided", async () => {
|
|
89
|
+
let capturedUrl;
|
|
90
|
+
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/issues`, (req, res) => {
|
|
91
|
+
capturedUrl = req.originalUrl;
|
|
92
|
+
res.json([]);
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
await callListIssues({
|
|
96
|
+
project_id: TEST_PROJECT_ID,
|
|
97
|
+
author_id: "42",
|
|
98
|
+
author_username: "alice",
|
|
99
|
+
}, {
|
|
100
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
101
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
102
|
+
});
|
|
103
|
+
assert.ok(capturedUrl, "Mock handler should have received a request");
|
|
104
|
+
assert.match(capturedUrl, /author_username=alice/, "Request should include author_username");
|
|
105
|
+
assert.doesNotMatch(capturedUrl, /author_id=/, "Request should not include author_id");
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
mockGitLab.clearCustomHandlers();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { graphqlQueryContainsWriteOperation } from "../../utils/graphql-query.js";
|
|
4
|
+
describe("When graphqlQueryContainsWriteOperation runs", () => {
|
|
5
|
+
describe("with read-only GraphQL documents", () => {
|
|
6
|
+
test("should allow explicit query operations", () => {
|
|
7
|
+
assert.equal(graphqlQueryContainsWriteOperation("query { project(fullPath: \"g/p\") { id } }"), false);
|
|
8
|
+
});
|
|
9
|
+
test("should allow shorthand query operations", () => {
|
|
10
|
+
assert.equal(graphqlQueryContainsWriteOperation("{ project { id } }"), false);
|
|
11
|
+
});
|
|
12
|
+
test("should ignore mutation text inside comments", () => {
|
|
13
|
+
assert.equal(graphqlQueryContainsWriteOperation("# mutation destroy\nquery { project { id } }"), false);
|
|
14
|
+
});
|
|
15
|
+
test("should ignore mutation text inside string literals", () => {
|
|
16
|
+
assert.equal(graphqlQueryContainsWriteOperation('query { search(query: "mutation") { nodes { id } } }'), false);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("with write GraphQL documents", () => {
|
|
20
|
+
test("should detect mutation operations", () => {
|
|
21
|
+
assert.equal(graphqlQueryContainsWriteOperation('mutation { destroyProject(input: { projectId: "gid://gitlab/Project/1" }) { errors } }'), true);
|
|
22
|
+
});
|
|
23
|
+
test("should detect subscription operations", () => {
|
|
24
|
+
assert.equal(graphqlQueryContainsWriteOperation("subscription { mergeRequestCreated { id } }"), true);
|
|
25
|
+
});
|
|
26
|
+
test("should detect write operations in multi-operation documents", () => {
|
|
27
|
+
assert.equal(graphqlQueryContainsWriteOperation("query A { a } mutation B { b }"), true);
|
|
28
|
+
});
|
|
29
|
+
test("should detect semicolon-separated write operations", () => {
|
|
30
|
+
assert.equal(graphqlQueryContainsWriteOperation("query A { a }; mutation B { b }"), true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import { sanitizeToolArguments } from "../../utils/tool-args.js";
|
|
3
|
+
import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "../../utils/tool-args.js";
|
|
4
4
|
describe("When sanitizeToolArguments runs", () => {
|
|
5
5
|
describe("with top-level null optionals", () => {
|
|
6
6
|
test("should omit null and undefined keys for generic tools", () => {
|
|
@@ -73,3 +73,50 @@ describe("When sanitizeToolArguments runs", () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
|
+
describe("When cleanMutuallyExclusiveIdUsernameOptions runs", () => {
|
|
77
|
+
describe("with list_issues author filters", () => {
|
|
78
|
+
test("should drop author_id when author_username is also set", () => {
|
|
79
|
+
const result = cleanMutuallyExclusiveIdUsernameOptions({
|
|
80
|
+
author_id: "42",
|
|
81
|
+
author_username: "alice",
|
|
82
|
+
state: "opened",
|
|
83
|
+
});
|
|
84
|
+
assert.deepEqual(result, {
|
|
85
|
+
author_username: "alice",
|
|
86
|
+
state: "opened",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("with list_issues assignee filters", () => {
|
|
91
|
+
test("should drop assignee_id when assignee_username is also set", () => {
|
|
92
|
+
const result = cleanMutuallyExclusiveIdUsernameOptions({
|
|
93
|
+
assignee_id: "7",
|
|
94
|
+
assignee_username: ["bob"],
|
|
95
|
+
});
|
|
96
|
+
assert.deepEqual(result, {
|
|
97
|
+
assignee_username: ["bob"],
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
test("should keep assignee_id when assignee_username is an empty array", () => {
|
|
101
|
+
const result = cleanMutuallyExclusiveIdUsernameOptions({
|
|
102
|
+
assignee_id: "7",
|
|
103
|
+
assignee_username: [],
|
|
104
|
+
});
|
|
105
|
+
assert.deepEqual(result, {
|
|
106
|
+
assignee_id: "7",
|
|
107
|
+
assignee_username: [],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe("with list_merge_requests reviewer filters", () => {
|
|
112
|
+
test("should drop reviewer_id when reviewer_username is also set", () => {
|
|
113
|
+
const result = cleanMutuallyExclusiveIdUsernameOptions({
|
|
114
|
+
reviewer_id: "3",
|
|
115
|
+
reviewer_username: "carol",
|
|
116
|
+
}, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS);
|
|
117
|
+
assert.deepEqual(result, {
|
|
118
|
+
reviewer_username: "carol",
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { resolveNestedWikiUpdateTitle } from "../../utils/wiki-title.js";
|
|
4
|
+
describe("When resolveNestedWikiUpdateTitle runs", () => {
|
|
5
|
+
describe("with nested wiki slugs", () => {
|
|
6
|
+
test("should prefix leaf titles using the existing hierarchical title", () => {
|
|
7
|
+
assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "infra servers v2", "00-map/infra servers"), "00-map/infra servers v2");
|
|
8
|
+
});
|
|
9
|
+
test("should prefix leaf titles using the slug parent when the existing title is flat", () => {
|
|
10
|
+
assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "infra servers v2", "infra servers"), "00-map/infra servers v2");
|
|
11
|
+
});
|
|
12
|
+
test("should keep full hierarchical titles unchanged", () => {
|
|
13
|
+
assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "00-map/infra servers v2", "00-map/infra servers"), "00-map/infra servers v2");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe("with flat wiki slugs", () => {
|
|
17
|
+
test("should keep leaf titles unchanged", () => {
|
|
18
|
+
assert.equal(resolveNestedWikiUpdateTitle("infra-servers", "infra servers v2", "infra servers"), "infra servers v2");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function stripGraphQLCommentsAndStrings(source) {
|
|
2
|
+
let result = "";
|
|
3
|
+
let i = 0;
|
|
4
|
+
while (i < source.length) {
|
|
5
|
+
const ch = source[i];
|
|
6
|
+
if (ch === "#") {
|
|
7
|
+
while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
|
|
8
|
+
i++;
|
|
9
|
+
}
|
|
10
|
+
result += " ";
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (ch === '"' || ch === "'") {
|
|
14
|
+
const quote = ch;
|
|
15
|
+
i++;
|
|
16
|
+
while (i < source.length) {
|
|
17
|
+
if (source[i] === "\\") {
|
|
18
|
+
i = Math.min(i + 2, source.length);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (source[i] === quote) {
|
|
22
|
+
i++;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
result += " ";
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (source.slice(i, i + 3) === '"""') {
|
|
31
|
+
i += 3;
|
|
32
|
+
while (i < source.length && source.slice(i, i + 3) !== '"""') {
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
if (i < source.length) {
|
|
36
|
+
i += 3;
|
|
37
|
+
}
|
|
38
|
+
result += " ";
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
result += ch;
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
export function graphqlQueryContainsWriteOperation(query) {
|
|
47
|
+
const normalized = stripGraphQLCommentsAndStrings(query).trim();
|
|
48
|
+
if (!normalized) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return /(?:^|[};]\s*)(mutation|subscription)\b/.test(normalized);
|
|
52
|
+
}
|
package/build/utils/tool-args.js
CHANGED
|
@@ -20,3 +20,30 @@ export function sanitizeToolArguments(toolName, args) {
|
|
|
20
20
|
}
|
|
21
21
|
return result;
|
|
22
22
|
}
|
|
23
|
+
/** Pairs where GitLab rejects sending both *_id and *_username query params. */
|
|
24
|
+
export const LIST_ISSUES_ID_USERNAME_PAIRS = [
|
|
25
|
+
["author_id", "author_username"],
|
|
26
|
+
["assignee_id", "assignee_username"],
|
|
27
|
+
];
|
|
28
|
+
export const LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS = [
|
|
29
|
+
...LIST_ISSUES_ID_USERNAME_PAIRS,
|
|
30
|
+
["reviewer_id", "reviewer_username"],
|
|
31
|
+
];
|
|
32
|
+
function hasUsernameFilterValue(value) {
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
return value.length > 0;
|
|
35
|
+
}
|
|
36
|
+
return Boolean(value);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* When both id and username filters are set, GitLab returns 400. Prefer username and drop id.
|
|
40
|
+
*/
|
|
41
|
+
export function cleanMutuallyExclusiveIdUsernameOptions(options, pairs = LIST_ISSUES_ID_USERNAME_PAIRS) {
|
|
42
|
+
const cleaned = { ...options };
|
|
43
|
+
for (const [idKey, usernameKey] of pairs) {
|
|
44
|
+
if (cleaned[idKey] && hasUsernameFilterValue(cleaned[usernameKey])) {
|
|
45
|
+
delete cleaned[idKey];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return cleaned;
|
|
49
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preserve nested wiki hierarchy when callers pass a leaf-only title on update.
|
|
3
|
+
*/
|
|
4
|
+
export function resolveNestedWikiUpdateTitle(slug, providedTitle, existingTitle) {
|
|
5
|
+
if (providedTitle.includes("/") || !slug.includes("/")) {
|
|
6
|
+
return providedTitle;
|
|
7
|
+
}
|
|
8
|
+
const titleParentIndex = existingTitle.lastIndexOf("/");
|
|
9
|
+
if (titleParentIndex >= 0) {
|
|
10
|
+
return `${existingTitle.slice(0, titleParentIndex)}/${providedTitle}`;
|
|
11
|
+
}
|
|
12
|
+
const slugParentIndex = slug.lastIndexOf("/");
|
|
13
|
+
if (slugParentIndex >= 0) {
|
|
14
|
+
return `${slug.slice(0, slugParentIndex)}/${providedTitle}`;
|
|
15
|
+
}
|
|
16
|
+
return providedTitle;
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.21",
|
|
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-protected-branches.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 && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.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 && tsx test/test-list-issues.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-protected-branches.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/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.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 && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.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",
|