@zereight/mcp-gitlab 2.0.24 → 2.0.28
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 +188 -117
- package/build/schemas.js +10 -1
- package/build/test-resolve-issue-note.js +127 -0
- package/package.json +4 -2
package/build/index.js
CHANGED
|
@@ -4,12 +4,12 @@ const args = process.argv.slice(2);
|
|
|
4
4
|
const cliArgs = {};
|
|
5
5
|
for (let i = 0; i < args.length; i++) {
|
|
6
6
|
const arg = args[i];
|
|
7
|
-
if (arg.startsWith(
|
|
8
|
-
const [key, value] = arg.slice(2).split(
|
|
7
|
+
if (arg.startsWith("--")) {
|
|
8
|
+
const [key, value] = arg.slice(2).split("=");
|
|
9
9
|
if (value) {
|
|
10
10
|
cliArgs[key] = value;
|
|
11
11
|
}
|
|
12
|
-
else if (i + 1 < args.length && !args[i + 1].startsWith(
|
|
12
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
13
13
|
cliArgs[key] = args[++i];
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -89,14 +89,77 @@ try {
|
|
|
89
89
|
catch {
|
|
90
90
|
// Intentionally ignored: version read failure is non-critical
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Create a new MCP Server instance with request handlers registered.
|
|
94
|
+
* Each transport connection gets its own Server instance to prevent
|
|
95
|
+
* cross-client data leakage (GHSA-345p-7cg4-v4c7).
|
|
96
|
+
*/
|
|
97
|
+
function createServer() {
|
|
98
|
+
const serverInstance = new Server({
|
|
99
|
+
name: "better-gitlab-mcp-server",
|
|
100
|
+
version: SERVER_VERSION,
|
|
101
|
+
}, {
|
|
102
|
+
capabilities: {
|
|
103
|
+
tools: {},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
serverInstance.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
107
|
+
// Apply read-only filter first
|
|
108
|
+
const tools0 = GITLAB_READ_ONLY_MODE
|
|
109
|
+
? allTools.filter(tool => readOnlyTools.has(tool.name))
|
|
110
|
+
: allTools;
|
|
111
|
+
// Toggle wiki tools by USE_GITLAB_WIKI flag
|
|
112
|
+
const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
|
|
113
|
+
// Toggle milestone tools by USE_MILESTONE flag
|
|
114
|
+
const tools2 = USE_MILESTONE
|
|
115
|
+
? tools1
|
|
116
|
+
: tools1.filter(tool => !milestoneToolNames.has(tool.name));
|
|
117
|
+
// Toggle pipeline tools by USE_PIPELINE flag
|
|
118
|
+
let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
|
|
119
|
+
tools = GITLAB_DENIED_TOOLS_REGEX
|
|
120
|
+
? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
|
|
121
|
+
: tools;
|
|
122
|
+
// <<< START: Gemini 호환성을 위해 $schema 제거 >>>
|
|
123
|
+
tools = tools.map(tool => {
|
|
124
|
+
// inputSchema가 존재하고 객체인지 확인
|
|
125
|
+
if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
|
|
126
|
+
// $schema 키가 존재하면 삭제
|
|
127
|
+
if ("$schema" in tool.inputSchema) {
|
|
128
|
+
// 불변성을 위해 새로운 객체 생성 (선택적이지만 권장)
|
|
129
|
+
const modifiedSchema = { ...tool.inputSchema };
|
|
130
|
+
delete modifiedSchema.$schema;
|
|
131
|
+
return { ...tool, inputSchema: modifiedSchema };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// 변경이 필요 없으면 그대로 반환
|
|
135
|
+
return tool;
|
|
136
|
+
});
|
|
137
|
+
// <<< END: Gemini 호환성을 위해 $schema 제거 >>>
|
|
138
|
+
return {
|
|
139
|
+
tools, // $schema가 제거된 도구 목록 반환
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
serverInstance.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
143
|
+
// Manually retrieve the session context using the session ID passed in the request.
|
|
144
|
+
// This is a robust workaround for AsyncLocalStorage context loss.
|
|
145
|
+
const sessionId = request.params.sessionId;
|
|
146
|
+
if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
|
|
147
|
+
const authData = authBySession[sessionId];
|
|
148
|
+
const sessionContext = {
|
|
149
|
+
sessionId,
|
|
150
|
+
header: authData.header,
|
|
151
|
+
token: authData.token,
|
|
152
|
+
lastUsed: authData.lastUsed,
|
|
153
|
+
apiUrl: authData.apiUrl,
|
|
154
|
+
};
|
|
155
|
+
// Run the handler within the retrieved context
|
|
156
|
+
return await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
|
|
157
|
+
}
|
|
158
|
+
// Fallback for non-remote-auth mode or if session is not found
|
|
159
|
+
return handleToolCall(request.params);
|
|
160
|
+
});
|
|
161
|
+
return serverInstance;
|
|
162
|
+
}
|
|
100
163
|
/**
|
|
101
164
|
* Validate configuration at startup
|
|
102
165
|
*/
|
|
@@ -131,7 +194,7 @@ function validateConfiguration() {
|
|
|
131
194
|
}
|
|
132
195
|
}
|
|
133
196
|
// Validate PORT
|
|
134
|
-
const portStr = getConfig(
|
|
197
|
+
const portStr = getConfig("port", "PORT");
|
|
135
198
|
if (portStr) {
|
|
136
199
|
const port = Number.parseInt(portStr, 10);
|
|
137
200
|
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
@@ -139,7 +202,7 @@ function validateConfiguration() {
|
|
|
139
202
|
}
|
|
140
203
|
}
|
|
141
204
|
// Validate GITLAB_API_URL format
|
|
142
|
-
const apiUrls = getConfig(
|
|
205
|
+
const apiUrls = getConfig("api-url", "GITLAB_API_URL")?.split(",") || [];
|
|
143
206
|
if (apiUrls.length > 0) {
|
|
144
207
|
for (const url of apiUrls) {
|
|
145
208
|
try {
|
|
@@ -151,14 +214,14 @@ function validateConfiguration() {
|
|
|
151
214
|
}
|
|
152
215
|
}
|
|
153
216
|
// Validate auth configuration
|
|
154
|
-
const remoteAuth = getConfig(
|
|
155
|
-
const useOAuth = getConfig(
|
|
156
|
-
const hasToken = !!getConfig(
|
|
157
|
-
const hasCookie = !!getConfig(
|
|
217
|
+
const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
218
|
+
const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
|
|
219
|
+
const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
|
|
220
|
+
const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
|
|
158
221
|
if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
|
|
159
|
-
errors.push(
|
|
222
|
+
errors.push("Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)");
|
|
160
223
|
}
|
|
161
|
-
const enableDynamicApiUrl = getConfig(
|
|
224
|
+
const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
162
225
|
if (enableDynamicApiUrl && !remoteAuth) {
|
|
163
226
|
errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
|
|
164
227
|
}
|
|
@@ -169,32 +232,57 @@ function validateConfiguration() {
|
|
|
169
232
|
}
|
|
170
233
|
logger.info("Configuration validation passed");
|
|
171
234
|
}
|
|
172
|
-
const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig(
|
|
235
|
+
const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
|
|
173
236
|
let OAUTH_ACCESS_TOKEN = null;
|
|
174
|
-
const GITLAB_AUTH_COOKIE_PATH = getConfig(
|
|
175
|
-
const USE_OAUTH = getConfig(
|
|
176
|
-
const IS_OLD = getConfig(
|
|
177
|
-
const GITLAB_READ_ONLY_MODE = getConfig(
|
|
178
|
-
const GITLAB_DENIED_TOOLS_REGEX =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
237
|
+
const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
|
|
238
|
+
const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
|
|
239
|
+
const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
|
|
240
|
+
const GITLAB_READ_ONLY_MODE = getConfig("read-only", "GITLAB_READ_ONLY_MODE") === "true";
|
|
241
|
+
const GITLAB_DENIED_TOOLS_REGEX = (() => {
|
|
242
|
+
const pattern = getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX");
|
|
243
|
+
if (!pattern)
|
|
244
|
+
return undefined;
|
|
245
|
+
// Reject patterns that are too long (potential ReDoS vector)
|
|
246
|
+
const MAX_PATTERN_LENGTH = 200;
|
|
247
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
248
|
+
logger.error(`GITLAB_DENIED_TOOLS_REGEX pattern exceeds ${MAX_PATTERN_LENGTH} chars. Ignoring.`);
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
// Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
|
|
252
|
+
// e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
|
|
253
|
+
const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]|\(\?[^:)]/;
|
|
254
|
+
if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
|
|
255
|
+
logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const regex = new RegExp(pattern);
|
|
260
|
+
// Dry-run against a sample string to catch immediate issues
|
|
261
|
+
regex.test("sample_tool_name");
|
|
262
|
+
return regex;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
logger.error(`Invalid GITLAB_DENIED_TOOLS_REGEX pattern: "${pattern}". Ignoring.`);
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
|
|
270
|
+
const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
|
|
271
|
+
const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
|
|
272
|
+
const SSE = getConfig("sse", "SSE") === "true";
|
|
273
|
+
const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
|
|
274
|
+
const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
275
|
+
const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
276
|
+
const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig("session-timeout", "SESSION_TIMEOUT_SECONDS", "3600"), 10);
|
|
277
|
+
const HOST = getConfig("host", "HOST") || "127.0.0.1";
|
|
278
|
+
const PORT = Number.parseInt(getConfig("port", "PORT", "3002"), 10);
|
|
191
279
|
// Add proxy configuration
|
|
192
|
-
const HTTP_PROXY = getConfig(
|
|
193
|
-
const HTTPS_PROXY = getConfig(
|
|
194
|
-
const NODE_TLS_REJECT_UNAUTHORIZED = getConfig(
|
|
195
|
-
const GITLAB_CA_CERT_PATH = getConfig(
|
|
196
|
-
const GITLAB_POOL_MAX_SIZE = getConfig(
|
|
197
|
-
? Number.parseInt(getConfig(
|
|
280
|
+
const HTTP_PROXY = getConfig("http-proxy", "HTTP_PROXY");
|
|
281
|
+
const HTTPS_PROXY = getConfig("https-proxy", "HTTPS_PROXY");
|
|
282
|
+
const NODE_TLS_REJECT_UNAUTHORIZED = getConfig("tls-reject-unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED");
|
|
283
|
+
const GITLAB_CA_CERT_PATH = getConfig("ca-cert-path", "GITLAB_CA_CERT_PATH");
|
|
284
|
+
const GITLAB_POOL_MAX_SIZE = getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE")
|
|
285
|
+
? Number.parseInt(getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE"), 10)
|
|
198
286
|
: 100;
|
|
199
287
|
let sslOptions = undefined;
|
|
200
288
|
if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
|
|
@@ -415,10 +503,10 @@ const getFetchConfig = () => {
|
|
|
415
503
|
};
|
|
416
504
|
};
|
|
417
505
|
const toJSONSchema = (schema) => {
|
|
418
|
-
const jsonSchema = zodToJsonSchema(schema, { $refStrategy:
|
|
506
|
+
const jsonSchema = zodToJsonSchema(schema, { $refStrategy: "none" });
|
|
419
507
|
// Post-process to fix nullable/optional fields that should truly be optional
|
|
420
508
|
function fixNullableOptional(obj) {
|
|
421
|
-
if (obj && typeof obj ===
|
|
509
|
+
if (obj && typeof obj === "object") {
|
|
422
510
|
// If this object has properties, process them
|
|
423
511
|
if (obj.properties) {
|
|
424
512
|
const requiredSet = new Set(obj.required || []);
|
|
@@ -426,10 +514,10 @@ const toJSONSchema = (schema) => {
|
|
|
426
514
|
const prop = obj.properties[key];
|
|
427
515
|
// Handle fields that can be null or omitted
|
|
428
516
|
// If a property has type: ["object", "null"] or anyOf with null, it should not be required
|
|
429
|
-
if (prop.anyOf && prop.anyOf.some((t) => t.type ===
|
|
517
|
+
if (prop.anyOf && prop.anyOf.some((t) => t.type === "null")) {
|
|
430
518
|
requiredSet.delete(key);
|
|
431
519
|
}
|
|
432
|
-
else if (Array.isArray(prop.type) && prop.type.includes(
|
|
520
|
+
else if (Array.isArray(prop.type) && prop.type.includes("null")) {
|
|
433
521
|
requiredSet.delete(key);
|
|
434
522
|
}
|
|
435
523
|
// Recursively process nested objects
|
|
@@ -439,12 +527,12 @@ const toJSONSchema = (schema) => {
|
|
|
439
527
|
if (requiredSet.size > 0) {
|
|
440
528
|
obj.required = Array.from(requiredSet);
|
|
441
529
|
}
|
|
442
|
-
else if (Object.prototype.hasOwnProperty.call(obj,
|
|
530
|
+
else if (Object.prototype.hasOwnProperty.call(obj, "required")) {
|
|
443
531
|
delete obj.required;
|
|
444
532
|
}
|
|
445
533
|
}
|
|
446
534
|
// Process anyOf/allOf/oneOf
|
|
447
|
-
[
|
|
535
|
+
["anyOf", "allOf", "oneOf"].forEach(combiner => {
|
|
448
536
|
if (obj[combiner]) {
|
|
449
537
|
obj[combiner] = obj[combiner].map(fixNullableOptional);
|
|
450
538
|
}
|
|
@@ -1703,17 +1791,41 @@ async function updateMergeRequestDiscussionNote(projectId, mergeRequestIid, disc
|
|
|
1703
1791
|
}
|
|
1704
1792
|
/**
|
|
1705
1793
|
* Update an issue discussion note
|
|
1794
|
+
*
|
|
1795
|
+
* Note: Only one of `body` or `resolved` can be provided per GitLab API requirements.
|
|
1796
|
+
* At least one parameter must be provided.
|
|
1797
|
+
*
|
|
1706
1798
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
1707
|
-
* @param {number} issueIid - The IID of an issue
|
|
1799
|
+
* @param {number|string} issueIid - The IID of an issue
|
|
1708
1800
|
* @param {string} discussionId - The ID of a thread
|
|
1709
|
-
* @param {number} noteId - The ID of a thread note
|
|
1710
|
-
* @param {string} body - The new content of the note
|
|
1801
|
+
* @param {number|string} noteId - The ID of a thread note
|
|
1802
|
+
* @param {string} [body] - The new content of the note (optional, mutually exclusive with resolved)
|
|
1803
|
+
* @param {boolean} [resolved] - Resolve (true) or unresolve (false) the thread (optional, mutually exclusive with body)
|
|
1711
1804
|
* @returns {Promise<GitLabDiscussionNote>} The updated note
|
|
1805
|
+
*
|
|
1806
|
+
* @example
|
|
1807
|
+
* // Resolve a thread
|
|
1808
|
+
* await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, undefined, true);
|
|
1809
|
+
*
|
|
1810
|
+
* @example
|
|
1811
|
+
* // Unresolve a thread
|
|
1812
|
+
* await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, undefined, false);
|
|
1813
|
+
*
|
|
1814
|
+
* @example
|
|
1815
|
+
* // Update note body
|
|
1816
|
+
* await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, 'Updated content');
|
|
1712
1817
|
*/
|
|
1713
|
-
async function updateIssueNote(projectId, issueIid, discussionId, noteId, body) {
|
|
1818
|
+
async function updateIssueNote(projectId, issueIid, discussionId, noteId, body, resolved) {
|
|
1714
1819
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
1715
1820
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}`);
|
|
1716
|
-
|
|
1821
|
+
// Only one of body or resolved can be sent according to GitLab API
|
|
1822
|
+
const payload = {};
|
|
1823
|
+
if (body !== undefined) {
|
|
1824
|
+
payload.body = body;
|
|
1825
|
+
}
|
|
1826
|
+
else if (resolved !== undefined) {
|
|
1827
|
+
payload.resolved = resolved;
|
|
1828
|
+
}
|
|
1717
1829
|
const response = await fetch(url.toString(), {
|
|
1718
1830
|
...getFetchConfig(),
|
|
1719
1831
|
method: "PUT",
|
|
@@ -1815,7 +1927,7 @@ async function getMergeRequestNote(projectId, mergeRequestIid, noteId) {
|
|
|
1815
1927
|
const data = await response.json();
|
|
1816
1928
|
return GitLabDiscussionNoteSchema.parse(data);
|
|
1817
1929
|
}
|
|
1818
|
-
async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by) {
|
|
1930
|
+
async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by, per_page, page) {
|
|
1819
1931
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
1820
1932
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes`);
|
|
1821
1933
|
if (sort) {
|
|
@@ -1824,6 +1936,12 @@ async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by)
|
|
|
1824
1936
|
if (order_by) {
|
|
1825
1937
|
url.searchParams.append("order_by", order_by);
|
|
1826
1938
|
}
|
|
1939
|
+
if (per_page) {
|
|
1940
|
+
url.searchParams.append("per_page", per_page.toString());
|
|
1941
|
+
}
|
|
1942
|
+
if (page) {
|
|
1943
|
+
url.searchParams.append("page", page.toString());
|
|
1944
|
+
}
|
|
1827
1945
|
const response = await fetch(url.toString(), {
|
|
1828
1946
|
...getFetchConfig(),
|
|
1829
1947
|
method: "GET",
|
|
@@ -3987,59 +4105,8 @@ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
|
|
|
3987
4105
|
await handleGitLabError(response);
|
|
3988
4106
|
return await response.text();
|
|
3989
4107
|
}
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
const tools0 = GITLAB_READ_ONLY_MODE
|
|
3993
|
-
? allTools.filter(tool => readOnlyTools.has(tool.name))
|
|
3994
|
-
: allTools;
|
|
3995
|
-
// Toggle wiki tools by USE_GITLAB_WIKI flag
|
|
3996
|
-
const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
|
|
3997
|
-
// Toggle milestone tools by USE_MILESTONE flag
|
|
3998
|
-
const tools2 = USE_MILESTONE ? tools1 : tools1.filter(tool => !milestoneToolNames.has(tool.name));
|
|
3999
|
-
// Toggle pipeline tools by USE_PIPELINE flag
|
|
4000
|
-
let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
|
|
4001
|
-
tools = GITLAB_DENIED_TOOLS_REGEX
|
|
4002
|
-
? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
|
|
4003
|
-
: tools;
|
|
4004
|
-
// <<< START: Gemini 호환성을 위해 $schema 제거 >>>
|
|
4005
|
-
tools = tools.map(tool => {
|
|
4006
|
-
// inputSchema가 존재하고 객체인지 확인
|
|
4007
|
-
if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
|
|
4008
|
-
// $schema 키가 존재하면 삭제
|
|
4009
|
-
if ("$schema" in tool.inputSchema) {
|
|
4010
|
-
// 불변성을 위해 새로운 객체 생성 (선택적이지만 권장)
|
|
4011
|
-
const modifiedSchema = { ...tool.inputSchema };
|
|
4012
|
-
delete modifiedSchema.$schema;
|
|
4013
|
-
return { ...tool, inputSchema: modifiedSchema };
|
|
4014
|
-
}
|
|
4015
|
-
}
|
|
4016
|
-
// 변경이 필요 없으면 그대로 반환
|
|
4017
|
-
return tool;
|
|
4018
|
-
});
|
|
4019
|
-
// <<< END: Gemini 호환성을 위해 $schema 제거 >>>
|
|
4020
|
-
return {
|
|
4021
|
-
tools, // $schema가 제거된 도구 목록 반환
|
|
4022
|
-
};
|
|
4023
|
-
});
|
|
4024
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4025
|
-
// Manually retrieve the session context using the session ID passed in the request.
|
|
4026
|
-
// This is a robust workaround for AsyncLocalStorage context loss.
|
|
4027
|
-
const sessionId = request.params.sessionId;
|
|
4028
|
-
if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
|
|
4029
|
-
const authData = authBySession[sessionId];
|
|
4030
|
-
const sessionContext = {
|
|
4031
|
-
sessionId,
|
|
4032
|
-
header: authData.header,
|
|
4033
|
-
token: authData.token,
|
|
4034
|
-
lastUsed: authData.lastUsed,
|
|
4035
|
-
apiUrl: authData.apiUrl,
|
|
4036
|
-
};
|
|
4037
|
-
// Run the handler within the retrieved context
|
|
4038
|
-
return await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
|
|
4039
|
-
}
|
|
4040
|
-
// Fallback for non-remote-auth mode or if session is not found
|
|
4041
|
-
return handleToolCall(request.params);
|
|
4042
|
-
});
|
|
4108
|
+
// Request handlers are now registered inside createServer() factory function
|
|
4109
|
+
// to ensure each transport connection gets its own Server instance (GHSA-345p-7cg4-v4c7).
|
|
4043
4110
|
/**
|
|
4044
4111
|
* Filter diffs by excluded file patterns
|
|
4045
4112
|
* Safely handles invalid regex patterns by logging and ignoring them
|
|
@@ -4052,7 +4119,7 @@ function filterDiffsByPatterns(diffs, excludedFilePatterns) {
|
|
|
4052
4119
|
if (!excludedFilePatterns?.length)
|
|
4053
4120
|
return diffs;
|
|
4054
4121
|
const regexPatterns = excludedFilePatterns
|
|
4055
|
-
.map(
|
|
4122
|
+
.map(pattern => {
|
|
4056
4123
|
try {
|
|
4057
4124
|
return new RegExp(pattern);
|
|
4058
4125
|
}
|
|
@@ -4067,9 +4134,9 @@ function filterDiffsByPatterns(diffs, excludedFilePatterns) {
|
|
|
4067
4134
|
const matchesAnyPattern = (path) => {
|
|
4068
4135
|
if (!path)
|
|
4069
4136
|
return false;
|
|
4070
|
-
return regexPatterns.some(
|
|
4137
|
+
return regexPatterns.some(regex => regex.test(path));
|
|
4071
4138
|
};
|
|
4072
|
-
return diffs.filter(
|
|
4139
|
+
return diffs.filter(diff => !matchesAnyPattern(diff.new_path));
|
|
4073
4140
|
}
|
|
4074
4141
|
async function handleToolCall(params) {
|
|
4075
4142
|
try {
|
|
@@ -4279,7 +4346,7 @@ async function handleToolCall(params) {
|
|
|
4279
4346
|
}
|
|
4280
4347
|
case "get_merge_request_notes": {
|
|
4281
4348
|
const args = GetMergeRequestNotesSchema.parse(params.arguments);
|
|
4282
|
-
const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
|
|
4349
|
+
const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by, args.per_page, args.page);
|
|
4283
4350
|
return {
|
|
4284
4351
|
content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
|
|
4285
4352
|
};
|
|
@@ -4293,7 +4360,7 @@ async function handleToolCall(params) {
|
|
|
4293
4360
|
}
|
|
4294
4361
|
case "update_issue_note": {
|
|
4295
4362
|
const args = UpdateIssueNoteSchema.parse(params.arguments);
|
|
4296
|
-
const note = await updateIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.note_id, args.body);
|
|
4363
|
+
const note = await updateIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.note_id, args.body, args.resolved);
|
|
4297
4364
|
return {
|
|
4298
4365
|
content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
|
|
4299
4366
|
};
|
|
@@ -5170,8 +5237,9 @@ function determineTransportMode() {
|
|
|
5170
5237
|
* Start server with stdio transport
|
|
5171
5238
|
*/
|
|
5172
5239
|
async function startStdioServer() {
|
|
5240
|
+
const serverInstance = createServer();
|
|
5173
5241
|
const transport = new StdioServerTransport();
|
|
5174
|
-
await
|
|
5242
|
+
await serverInstance.connect(transport);
|
|
5175
5243
|
}
|
|
5176
5244
|
/**
|
|
5177
5245
|
* Start server with traditional SSE transport
|
|
@@ -5180,12 +5248,13 @@ async function startSSEServer() {
|
|
|
5180
5248
|
const app = express();
|
|
5181
5249
|
const transports = {};
|
|
5182
5250
|
app.get("/sse", async (_, res) => {
|
|
5251
|
+
const serverInstance = createServer();
|
|
5183
5252
|
const transport = new SSEServerTransport("/messages", res);
|
|
5184
5253
|
transports[transport.sessionId] = transport;
|
|
5185
5254
|
res.on("close", () => {
|
|
5186
5255
|
delete transports[transport.sessionId];
|
|
5187
5256
|
});
|
|
5188
|
-
await
|
|
5257
|
+
await serverInstance.connect(transport);
|
|
5189
5258
|
});
|
|
5190
5259
|
app.post("/messages", async (req, res) => {
|
|
5191
5260
|
const sessionId = req.query.sessionId;
|
|
@@ -5439,8 +5508,10 @@ async function startStreamableHTTPServer() {
|
|
|
5439
5508
|
}
|
|
5440
5509
|
}
|
|
5441
5510
|
};
|
|
5442
|
-
//
|
|
5443
|
-
|
|
5511
|
+
// Create a new Server instance per session to prevent
|
|
5512
|
+
// cross-client data leakage (GHSA-345p-7cg4-v4c7)
|
|
5513
|
+
const serverInstance = createServer();
|
|
5514
|
+
await serverInstance.connect(transport);
|
|
5444
5515
|
// Handle the request - context is already set up in the outer handleRequest wrapper
|
|
5445
5516
|
await transport.handleRequest(req, res, req.body);
|
|
5446
5517
|
}
|
package/build/schemas.js
CHANGED
|
@@ -856,6 +856,8 @@ export const GetMergeRequestNotesSchema = ProjectParamsSchema.extend({
|
|
|
856
856
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
857
857
|
sort: z.enum(["asc", "desc"]).optional().describe("The sort order of the notes"),
|
|
858
858
|
order_by: z.enum(["created_at", "updated_at"]).optional().describe("The field to sort the notes by"),
|
|
859
|
+
per_page: z.coerce.number().optional().describe("Number of items per page"),
|
|
860
|
+
page: z.coerce.number().optional().describe("Page number for pagination"),
|
|
859
861
|
});
|
|
860
862
|
export const GetMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
861
863
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
@@ -908,7 +910,14 @@ export const UpdateIssueNoteSchema = ProjectParamsSchema.extend({
|
|
|
908
910
|
issue_iid: z.coerce.string().describe("The IID of an issue"),
|
|
909
911
|
discussion_id: z.coerce.string().describe("The ID of a thread"),
|
|
910
912
|
note_id: z.coerce.string().describe("The ID of a thread note"),
|
|
911
|
-
body: z.string().describe("The content of the note or reply"),
|
|
913
|
+
body: z.string().optional().describe("The content of the note or reply"),
|
|
914
|
+
resolved: z.boolean().optional().describe("Resolve or unresolve the note"),
|
|
915
|
+
})
|
|
916
|
+
.refine(data => data.body !== undefined || data.resolved !== undefined, {
|
|
917
|
+
message: "At least one of 'body' or 'resolved' must be provided",
|
|
918
|
+
})
|
|
919
|
+
.refine(data => !(data.body !== undefined && data.resolved !== undefined), {
|
|
920
|
+
message: "Only one of 'body' or 'resolved' can be provided, not both",
|
|
912
921
|
});
|
|
913
922
|
// Input schema for adding a note to an existing issue discussion
|
|
914
923
|
export const CreateIssueNoteSchema = ProjectParamsSchema.extend({
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This test file demonstrates the new resolve functionality for issue notes.
|
|
3
|
+
* It shows how to use the update_issue_note tool to resolve or unresolve
|
|
4
|
+
* issue discussion threads.
|
|
5
|
+
*/
|
|
6
|
+
import fetch from "node-fetch";
|
|
7
|
+
// GitLab API configuration (replace with actual values when testing)
|
|
8
|
+
const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
|
|
9
|
+
const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_TOKEN || "";
|
|
10
|
+
const PROJECT_ID = process.env.PROJECT_ID || "your/project";
|
|
11
|
+
const ISSUE_IID = Number(process.env.ISSUE_IID || "1");
|
|
12
|
+
const DISCUSSION_ID = process.env.DISCUSSION_ID || "your-discussion-id";
|
|
13
|
+
const NOTE_ID = process.env.NOTE_ID || "your-note-id";
|
|
14
|
+
/**
|
|
15
|
+
* Test resolving an issue note
|
|
16
|
+
*/
|
|
17
|
+
async function testResolveIssueNote() {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
|
|
20
|
+
const response = await fetch(url.toString(), {
|
|
21
|
+
method: "PUT",
|
|
22
|
+
headers: {
|
|
23
|
+
Accept: "application/json",
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({ resolved: true }),
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
const errorBody = await response.text();
|
|
31
|
+
throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
32
|
+
}
|
|
33
|
+
const data = await response.json();
|
|
34
|
+
console.log("Successfully resolved issue note:");
|
|
35
|
+
console.log(JSON.stringify(data, null, 2));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error("Error resolving issue note:", error);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Test unresolving an issue note
|
|
45
|
+
*/
|
|
46
|
+
async function testUnresolveIssueNote() {
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
|
|
49
|
+
const response = await fetch(url.toString(), {
|
|
50
|
+
method: "PUT",
|
|
51
|
+
headers: {
|
|
52
|
+
Accept: "application/json",
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({ resolved: false }),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const errorBody = await response.text();
|
|
60
|
+
throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
61
|
+
}
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
console.log("Successfully unresolved issue note:");
|
|
64
|
+
console.log(JSON.stringify(data, null, 2));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error("Error unresolving issue note:", error);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Test updating note body (existing functionality should still work)
|
|
74
|
+
*/
|
|
75
|
+
async function testUpdateIssueNoteBody() {
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
|
|
78
|
+
const response = await fetch(url.toString(), {
|
|
79
|
+
method: "PUT",
|
|
80
|
+
headers: {
|
|
81
|
+
Accept: "application/json",
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({ body: "Updated note content" }),
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const errorBody = await response.text();
|
|
89
|
+
throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
90
|
+
}
|
|
91
|
+
const data = await response.json();
|
|
92
|
+
console.log("Successfully updated issue note body:");
|
|
93
|
+
console.log(JSON.stringify(data, null, 2));
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
console.error("Error updating issue note body:", error);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Only run the test if executed directly
|
|
102
|
+
if (require.main === module) {
|
|
103
|
+
console.log("Testing issue note resolve functionality...\n");
|
|
104
|
+
console.log("Note: This is a demonstration test file.");
|
|
105
|
+
console.log("To run actual tests, set the following environment variables:");
|
|
106
|
+
console.log(" - GITLAB_API_URL");
|
|
107
|
+
console.log(" - GITLAB_TOKEN");
|
|
108
|
+
console.log(" - PROJECT_ID");
|
|
109
|
+
console.log(" - ISSUE_IID");
|
|
110
|
+
console.log(" - DISCUSSION_ID");
|
|
111
|
+
console.log(" - NOTE_ID");
|
|
112
|
+
console.log("\nExample MCP tool usage:");
|
|
113
|
+
console.log(`
|
|
114
|
+
{
|
|
115
|
+
"name": "update_issue_note",
|
|
116
|
+
"arguments": {
|
|
117
|
+
"project_id": "your/project",
|
|
118
|
+
"issue_iid": "1",
|
|
119
|
+
"discussion_id": "abc123",
|
|
120
|
+
"note_id": "456",
|
|
121
|
+
"resolved": true
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
`);
|
|
125
|
+
}
|
|
126
|
+
// Export for use in other tests
|
|
127
|
+
export { testResolveIssueNote, testUnresolveIssueNote, testUpdateIssueNoteBody };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.28",
|
|
4
4
|
"description": "MCP server for using the GitLab API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "zereight",
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
"test:approvals": "npm run build && tsx test/test-merge-request-approvals.ts",
|
|
38
38
|
"lint": "eslint . --ext .ts",
|
|
39
39
|
"lint:fix": "eslint . --ext .ts --fix",
|
|
40
|
+
"release": "bash scripts/release.sh",
|
|
40
41
|
"format": "prettier --write \"**/*.{js,ts,json,md}\"",
|
|
41
42
|
"format:check": "prettier --check \"**/*.{js,ts,json,md}\""
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.24.2",
|
|
44
46
|
"@types/node-fetch": "^2.6.12",
|
|
45
47
|
"express": "^5.1.0",
|
|
46
48
|
"fetch-cookie": "^3.1.0",
|
|
@@ -53,9 +55,9 @@
|
|
|
53
55
|
"pino-pretty": "^13.0.0",
|
|
54
56
|
"pkce-challenge": "^5.0.0",
|
|
55
57
|
"socks-proxy-agent": "^8.0.5",
|
|
58
|
+
"tldts": "^6.1.86",
|
|
56
59
|
"tough-cookie": "^5.1.2",
|
|
57
60
|
"zod": "^3.24.2",
|
|
58
|
-
"@modelcontextprotocol/sdk": "^1.24.2",
|
|
59
61
|
"zod-to-json-schema": "3.24.5"
|
|
60
62
|
},
|
|
61
63
|
"devDependencies": {
|