@zereight/mcp-gitlab 2.0.28 → 2.0.30

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.md CHANGED
@@ -356,6 +356,46 @@ docker run -i --rm \
356
356
  - `USE_GITLAB_WIKI`: When set to 'true', enables the wiki-related tools (list_wiki_pages, get_wiki_page, create_wiki_page, update_wiki_page, delete_wiki_page). By default, wiki features are disabled.
357
357
  - `USE_MILESTONE`: When set to 'true', enables the milestone-related tools (list_milestones, get_milestone, create_milestone, edit_milestone, delete_milestone, get_milestone_issue, get_milestone_merge_requests, promote_milestone, get_milestone_burndown_events). By default, milestone features are disabled.
358
358
  - `USE_PIPELINE`: When set to 'true', enables the pipeline-related tools (list_pipelines, get_pipeline, list_pipeline_jobs, list_pipeline_trigger_jobs, get_pipeline_job, get_pipeline_job_output, create_pipeline, retry_pipeline, cancel_pipeline, play_pipeline_job, retry_pipeline_job, cancel_pipeline_job). By default, pipeline features are disabled.
359
+ - `GITLAB_TOOLSETS`: Comma-separated list of toolset IDs to enable. When empty or unset, default toolsets are used. Set to `"all"` to enable every toolset. Available toolsets (default toolsets marked with `*`):
360
+ - `merge_requests`\* — MR operations, notes, discussions, draft notes, threads (31 tools)
361
+ - `issues`\* — Issue CRUD, notes, links, discussions (14 tools)
362
+ - `repositories`\* — Search, create, file contents, push, fork, tree (7 tools)
363
+ - `branches`\* — Branch creation, commits, diffs (4 tools)
364
+ - `projects`\* — Project/namespace info, group projects, iterations (8 tools)
365
+ - `labels`\* — Label CRUD (5 tools)
366
+ - `pipelines` — Pipeline and job operations (12 tools)
367
+ - `milestones` — Milestone CRUD, issues, MRs, burndown (9 tools)
368
+ - `wiki` — Wiki page CRUD (5 tools)
369
+ - `releases`\* — Release CRUD, evidence, asset download (7 tools)
370
+ - `users`\* — User info, events, markdown upload, attachments (5 tools)
371
+
372
+ Note: `execute_graphql` is not in any toolset and must be added individually via `GITLAB_TOOLS` if needed.
373
+ Exposing arbitrary GraphQL would allow bypassing toolset boundaries (e.g. querying data that the user intentionally disabled via toolsets like wiki or pipelines), which is a security and permission-containment concern. Keeping `execute_graphql` out of all toolsets and requiring explicit opt-in via `GITLAB_TOOLS=execute_graphql` is intentional, to align with that principle rather than for backward compatibility.
374
+ CLI arg: `--toolsets`
375
+ - `GITLAB_TOOLS`: Comma-separated list of individual tool names to add on top of the enabled toolsets (additive). Useful for cherry-picking specific tools without enabling an entire toolset. Example: `GITLAB_TOOLS="list_pipelines,execute_graphql"`. CLI arg: `--tools`
376
+
377
+ Combined logic: `final tools = (tools from enabled toolsets) ∪ (GITLAB_TOOLS) ∪ (legacy flag overrides)`
378
+
379
+ Examples:
380
+ ```bash
381
+ # Default behavior (unchanged)
382
+ GITLAB_PERSONAL_ACCESS_TOKEN=xxx npx @zereight/mcp-gitlab
383
+
384
+ # Only issues and repositories
385
+ GITLAB_TOOLSETS="issues,repositories" npx @zereight/mcp-gitlab
386
+
387
+ # All toolsets
388
+ GITLAB_TOOLSETS="all" npx @zereight/mcp-gitlab
389
+
390
+ # Default toolsets + one extra pipeline tool
391
+ GITLAB_TOOLS="list_pipelines" npx @zereight/mcp-gitlab
392
+
393
+ # Specific toolsets + individual tools
394
+ GITLAB_TOOLSETS="issues,merge_requests" GITLAB_TOOLS="list_pipelines,get_pipeline" npx @zereight/mcp-gitlab
395
+
396
+ # Legacy flags still work (backward compatible)
397
+ USE_PIPELINE=true npx @zereight/mcp-gitlab
398
+ ```
359
399
  - `GITLAB_AUTH_COOKIE_PATH`: Path to an authentication cookie file for GitLab instances that require cookie-based authentication. When provided, the cookie will be included in all GitLab API requests.
360
400
  - `SSE`: When set to 'true', enables the Server-Sent Events transport.
361
401
  - `STREAMABLE_HTTP`: When set to 'true', enables the Streamable HTTP transport. If both **SSE** and **STREAMABLE_HTTP** are set to 'true', the server will prioritize Streamable HTTP over SSE transport.
package/build/index.js CHANGED
@@ -95,6 +95,29 @@ catch {
95
95
  * cross-client data leakage (GHSA-345p-7cg4-v4c7).
96
96
  */
97
97
  function createServer() {
98
+ // Precompute filtered tool list once at server creation (Steps 1–5 are static)
99
+ // Step 1: Toolset filter — keep tools in enabled toolsets
100
+ const toolsAfterToolsets = allTools.filter(tool => isToolInEnabledToolset(tool.name, enabledToolsets));
101
+ // Step 2: Add GITLAB_TOOLS (individual tools bypass toolset filter)
102
+ const toolsetToolNames = new Set(toolsAfterToolsets.map(t => t.name));
103
+ const toolsAfterIndividual = [
104
+ ...toolsAfterToolsets,
105
+ ...allTools.filter(tool => individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name)),
106
+ ];
107
+ // Step 3: Add legacy flag overrides (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI)
108
+ const afterIndividualNames = new Set(toolsAfterIndividual.map(t => t.name));
109
+ const toolsAfterLegacy = [
110
+ ...toolsAfterIndividual,
111
+ ...allTools.filter(tool => featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name)),
112
+ ];
113
+ // Step 4: Read-only filter
114
+ const toolsAfterReadOnly = GITLAB_READ_ONLY_MODE
115
+ ? toolsAfterLegacy.filter(tool => readOnlyTools.has(tool.name))
116
+ : toolsAfterLegacy;
117
+ // Step 5: Regex denial filter
118
+ const precomputedFilteredTools = GITLAB_DENIED_TOOLS_REGEX
119
+ ? toolsAfterReadOnly.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
120
+ : toolsAfterReadOnly;
98
121
  const serverInstance = new Server({
99
122
  name: "better-gitlab-mcp-server",
100
123
  version: SERVER_VERSION,
@@ -104,39 +127,25 @@ function createServer() {
104
127
  },
105
128
  });
106
129
  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가 존재하고 객체인지 확인
130
+ // Step 6: Gemini $schema cleanup (only dynamic step per request)
131
+ // <<< START: Remove $schema for Gemini compatibility >>>
132
+ const tools = precomputedFilteredTools.map(tool => {
133
+ // Check if inputSchema exists and is an object
125
134
  if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
126
- // $schema 키가 존재하면 삭제
135
+ // Remove $schema key if present
127
136
  if ("$schema" in tool.inputSchema) {
128
- // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장)
137
+ // Create a new object to preserve immutability (optional but recommended)
129
138
  const modifiedSchema = { ...tool.inputSchema };
130
139
  delete modifiedSchema.$schema;
131
140
  return { ...tool, inputSchema: modifiedSchema };
132
141
  }
133
142
  }
134
- // 변경이 필요 없으면 그대로 반환
143
+ // Return as-is if no modification needed
135
144
  return tool;
136
145
  });
137
- // <<< END: Gemini 호환성을 위해 $schema 제거 >>>
146
+ // <<< END: Remove $schema for Gemini compatibility >>>
138
147
  return {
139
- tools, // $schema가 제거된 도구 목록 반환
148
+ tools, // return tool list with $schema removed
140
149
  };
141
150
  });
142
151
  serverInstance.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -269,6 +278,8 @@ const GITLAB_DENIED_TOOLS_REGEX = (() => {
269
278
  const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
270
279
  const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
271
280
  const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
281
+ const GITLAB_TOOLSETS_RAW = getConfig("toolsets", "GITLAB_TOOLSETS");
282
+ const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
272
283
  const SSE = getConfig("sse", "SSE") === "true";
273
284
  const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
274
285
  const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
@@ -315,7 +326,7 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
315
326
  httpAgent = httpAgent || new Agent();
316
327
  // Initialize the client pool for managing multiple GitLab instances
317
328
  const clientPool = new GitLabClientPool({
318
- apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
329
+ apiUrls: (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
319
330
  .split(",")
320
331
  .map(normalizeGitLabApiUrl),
321
332
  httpProxy: HTTP_PROXY,
@@ -1036,7 +1047,7 @@ const allTools = [
1036
1047
  },
1037
1048
  {
1038
1049
  name: "download_attachment",
1039
- description: "Download an uploaded file from a GitLab project by secret and filename",
1050
+ description: "Download an uploaded file from a GitLab project by secret and filename. Image files (png, jpg, gif, webp, svg, bmp, ico) are returned inline as base64 image content so the AI can view them directly. Non-image files are saved to disk. Use local_path to force saving image files to disk instead.",
1040
1051
  inputSchema: toJSONSchema(DownloadAttachmentSchema),
1041
1052
  },
1042
1053
  {
@@ -1176,6 +1187,259 @@ const pipelineToolNames = new Set([
1176
1187
  "retry_pipeline_job",
1177
1188
  "cancel_pipeline_job",
1178
1189
  ]);
1190
+ const TOOLSET_DEFINITIONS = [
1191
+ {
1192
+ id: "merge_requests",
1193
+ isDefault: true,
1194
+ tools: new Set([
1195
+ "merge_merge_request",
1196
+ "approve_merge_request",
1197
+ "unapprove_merge_request",
1198
+ "get_merge_request_approval_state",
1199
+ "get_merge_request",
1200
+ "get_merge_request_diffs",
1201
+ "list_merge_request_diffs",
1202
+ "list_merge_request_versions",
1203
+ "get_merge_request_version",
1204
+ "update_merge_request",
1205
+ "create_merge_request",
1206
+ "list_merge_requests",
1207
+ "get_branch_diffs",
1208
+ "mr_discussions",
1209
+ "create_merge_request_note",
1210
+ "update_merge_request_note",
1211
+ "delete_merge_request_note",
1212
+ "get_merge_request_note",
1213
+ "get_merge_request_notes",
1214
+ "delete_merge_request_discussion_note",
1215
+ "update_merge_request_discussion_note",
1216
+ "create_merge_request_discussion_note",
1217
+ "get_draft_note",
1218
+ "list_draft_notes",
1219
+ "create_draft_note",
1220
+ "update_draft_note",
1221
+ "delete_draft_note",
1222
+ "publish_draft_note",
1223
+ "bulk_publish_draft_notes",
1224
+ "create_merge_request_thread",
1225
+ "resolve_merge_request_thread",
1226
+ ]),
1227
+ },
1228
+ {
1229
+ id: "issues",
1230
+ isDefault: true,
1231
+ tools: new Set([
1232
+ "create_issue",
1233
+ "list_issues",
1234
+ "my_issues",
1235
+ "get_issue",
1236
+ "update_issue",
1237
+ "delete_issue",
1238
+ "create_issue_note",
1239
+ "update_issue_note",
1240
+ "list_issue_links",
1241
+ "list_issue_discussions",
1242
+ "get_issue_link",
1243
+ "create_issue_link",
1244
+ "delete_issue_link",
1245
+ "create_note",
1246
+ ]),
1247
+ },
1248
+ {
1249
+ id: "repositories",
1250
+ isDefault: true,
1251
+ tools: new Set([
1252
+ "search_repositories",
1253
+ "create_repository",
1254
+ "get_file_contents",
1255
+ "push_files",
1256
+ "create_or_update_file",
1257
+ "fork_repository",
1258
+ "get_repository_tree",
1259
+ ]),
1260
+ },
1261
+ {
1262
+ id: "branches",
1263
+ isDefault: true,
1264
+ tools: new Set([
1265
+ "create_branch",
1266
+ "list_commits",
1267
+ "get_commit",
1268
+ "get_commit_diff",
1269
+ ]),
1270
+ },
1271
+ {
1272
+ id: "projects",
1273
+ isDefault: true,
1274
+ tools: new Set([
1275
+ "get_project",
1276
+ "list_projects",
1277
+ "list_project_members",
1278
+ "list_namespaces",
1279
+ "get_namespace",
1280
+ "verify_namespace",
1281
+ "list_group_projects",
1282
+ "list_group_iterations",
1283
+ ]),
1284
+ },
1285
+ {
1286
+ id: "labels",
1287
+ isDefault: true,
1288
+ tools: new Set([
1289
+ "list_labels",
1290
+ "get_label",
1291
+ "create_label",
1292
+ "update_label",
1293
+ "delete_label",
1294
+ ]),
1295
+ },
1296
+ {
1297
+ id: "pipelines",
1298
+ isDefault: false,
1299
+ tools: new Set([
1300
+ "list_pipelines",
1301
+ "get_pipeline",
1302
+ "list_pipeline_jobs",
1303
+ "list_pipeline_trigger_jobs",
1304
+ "get_pipeline_job",
1305
+ "get_pipeline_job_output",
1306
+ "create_pipeline",
1307
+ "retry_pipeline",
1308
+ "cancel_pipeline",
1309
+ "play_pipeline_job",
1310
+ "retry_pipeline_job",
1311
+ "cancel_pipeline_job",
1312
+ ]),
1313
+ },
1314
+ {
1315
+ id: "milestones",
1316
+ isDefault: false,
1317
+ tools: new Set([
1318
+ "list_milestones",
1319
+ "get_milestone",
1320
+ "create_milestone",
1321
+ "edit_milestone",
1322
+ "delete_milestone",
1323
+ "get_milestone_issue",
1324
+ "get_milestone_merge_requests",
1325
+ "promote_milestone",
1326
+ "get_milestone_burndown_events",
1327
+ ]),
1328
+ },
1329
+ {
1330
+ id: "wiki",
1331
+ isDefault: false,
1332
+ tools: new Set([
1333
+ "list_wiki_pages",
1334
+ "get_wiki_page",
1335
+ "create_wiki_page",
1336
+ "update_wiki_page",
1337
+ "delete_wiki_page",
1338
+ ]),
1339
+ },
1340
+ {
1341
+ id: "releases",
1342
+ isDefault: true,
1343
+ tools: new Set([
1344
+ "list_releases",
1345
+ "get_release",
1346
+ "create_release",
1347
+ "update_release",
1348
+ "delete_release",
1349
+ "create_release_evidence",
1350
+ "download_release_asset",
1351
+ ]),
1352
+ },
1353
+ {
1354
+ id: "users",
1355
+ isDefault: true,
1356
+ tools: new Set([
1357
+ "get_users",
1358
+ "list_events",
1359
+ "get_project_events",
1360
+ "upload_markdown",
1361
+ "download_attachment",
1362
+ ]),
1363
+ },
1364
+ ];
1365
+ // Derived lookup: tool name → toolset ID
1366
+ const TOOLSET_BY_TOOL_NAME = new Map();
1367
+ for (const def of TOOLSET_DEFINITIONS) {
1368
+ for (const tool of def.tools) {
1369
+ if (TOOLSET_BY_TOOL_NAME.has(tool)) {
1370
+ logger.warn(`Tool "${tool}" is defined in multiple toolsets: "${TOOLSET_BY_TOOL_NAME.get(tool)}" and "${def.id}"`);
1371
+ }
1372
+ TOOLSET_BY_TOOL_NAME.set(tool, def.id);
1373
+ }
1374
+ }
1375
+ const DEFAULT_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.filter(d => d.isDefault).map(d => d.id));
1376
+ const ALL_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.map(d => d.id));
1377
+ function parseEnabledToolsets(raw) {
1378
+ if (!raw || raw.trim() === "") {
1379
+ return DEFAULT_TOOLSET_IDS;
1380
+ }
1381
+ const trimmed = raw.trim().toLowerCase();
1382
+ if (trimmed === "all") {
1383
+ return ALL_TOOLSET_IDS;
1384
+ }
1385
+ const selected = new Set(trimmed
1386
+ .split(",")
1387
+ .map(s => s.trim())
1388
+ .filter((s) => ALL_TOOLSET_IDS.has(s)));
1389
+ if (selected.size === 0) {
1390
+ logger.warn(`No valid toolsets found in configuration (${raw}). Falling back to default toolsets.`);
1391
+ return DEFAULT_TOOLSET_IDS;
1392
+ }
1393
+ return selected;
1394
+ }
1395
+ function parseIndividualTools(raw) {
1396
+ if (!raw || raw.trim() === "") {
1397
+ return new Set();
1398
+ }
1399
+ const allToolNames = new Set(allTools.map((t) => t.name));
1400
+ const parsed = raw
1401
+ .trim()
1402
+ .split(",")
1403
+ .map(s => s.trim().toLowerCase())
1404
+ .filter(Boolean);
1405
+ const unknown = parsed.filter(name => !allToolNames.has(name));
1406
+ if (unknown.length > 0) {
1407
+ logger.warn(`Unknown tool names in GITLAB_TOOLS (will be ignored): ${unknown.join(", ")}`);
1408
+ }
1409
+ return new Set(parsed);
1410
+ }
1411
+ function buildFeatureFlagOverrides() {
1412
+ const overrides = new Set();
1413
+ if (USE_GITLAB_WIKI) {
1414
+ for (const t of wikiToolNames)
1415
+ overrides.add(t);
1416
+ }
1417
+ if (USE_MILESTONE) {
1418
+ for (const t of milestoneToolNames)
1419
+ overrides.add(t);
1420
+ }
1421
+ if (USE_PIPELINE) {
1422
+ for (const t of pipelineToolNames)
1423
+ overrides.add(t);
1424
+ }
1425
+ return overrides;
1426
+ }
1427
+ function isToolInEnabledToolset(toolName, enabledToolsets) {
1428
+ const toolsetId = TOOLSET_BY_TOOL_NAME.get(toolName);
1429
+ // Tools not in any toolset (e.g. execute_graphql) are excluded by default
1430
+ if (toolsetId === undefined)
1431
+ return false;
1432
+ return enabledToolsets.has(toolsetId);
1433
+ }
1434
+ // Compute at startup
1435
+ const enabledToolsets = parseEnabledToolsets(GITLAB_TOOLSETS_RAW);
1436
+ const individuallyEnabledTools = parseIndividualTools(GITLAB_TOOLS_RAW);
1437
+ const featureFlagOverrides = buildFeatureFlagOverrides();
1438
+ // Warn about potentially confusing configuration
1439
+ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
1440
+ logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
1441
+ "Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
1442
+ }
1179
1443
  /**
1180
1444
  * Smart URL handling for GitLab API
1181
1445
  *
@@ -1196,7 +1460,7 @@ function normalizeGitLabApiUrl(url) {
1196
1460
  return normalizedUrl;
1197
1461
  }
1198
1462
  // Use the normalizeGitLabApiUrl function to handle various URL formats
1199
- const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
1463
+ const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
1200
1464
  .split(",")
1201
1465
  .map(normalizeGitLabApiUrl);
1202
1466
  const GITLAB_API_URL = GITLAB_API_URLS[0];
@@ -1292,7 +1556,7 @@ async function forkProject(projectId, namespace) {
1292
1556
  ...getFetchConfig(),
1293
1557
  method: "POST",
1294
1558
  });
1295
- // 이미 존재하는 프로젝트인 경우 처리
1559
+ // Handle case where project already exists
1296
1560
  if (response.status === 409) {
1297
1561
  throw new Error("Project already exists in the target namespace");
1298
1562
  }
@@ -1354,7 +1618,7 @@ async function getFileContents(projectId, filePath, ref) {
1354
1618
  projectId = decodeURIComponent(projectId); // Decode project ID
1355
1619
  const effectiveProjectId = getEffectiveProjectId(projectId);
1356
1620
  const encodedPath = encodeURIComponent(filePath);
1357
- // ref가 없는 경우 default branch 가져옴
1621
+ // Fall back to default branch if ref is not provided
1358
1622
  if (!ref) {
1359
1623
  ref = await getDefaultBranchRef(projectId);
1360
1624
  }
@@ -1363,14 +1627,14 @@ async function getFileContents(projectId, filePath, ref) {
1363
1627
  const response = await fetch(url.toString(), {
1364
1628
  ...getFetchConfig(),
1365
1629
  });
1366
- // 파일을 찾을 없는 경우 처리
1630
+ // Handle file not found
1367
1631
  if (response.status === 404) {
1368
1632
  throw new Error(`File not found: ${filePath}`);
1369
1633
  }
1370
1634
  await handleGitLabError(response);
1371
1635
  const data = await response.json();
1372
1636
  const parsedData = GitLabContentSchema.parse(data);
1373
- // Base64 인코딩된 파일 내용을 UTF-8로 디코딩
1637
+ // Decode Base64-encoded file content to UTF-8
1374
1638
  if (!Array.isArray(parsedData) && parsedData.content) {
1375
1639
  parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8");
1376
1640
  parsedData.encoding = "utf8";
@@ -1400,7 +1664,7 @@ async function createIssue(projectId, options) {
1400
1664
  labels: options.labels?.join(","),
1401
1665
  }),
1402
1666
  });
1403
- // 잘못된 요청 처리
1667
+ // Handle bad request
1404
1668
  if (response.status === 400) {
1405
1669
  const errorBody = await response.text();
1406
1670
  throw new Error(`Invalid request: ${errorBody}`);
@@ -2421,10 +2685,10 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
2421
2685
  * @param {string} body - The content of the note
2422
2686
  * @returns {Promise<any>} The created note
2423
2687
  */
2424
- async function createNote(projectId, noteableType, // 'issue' 또는 'merge_request' 타입 명시
2688
+ async function createNote(projectId, noteableType, // specifies 'issue' or 'merge_request' type
2425
2689
  noteableIid, body) {
2426
2690
  projectId = decodeURIComponent(projectId); // Decode project ID
2427
- // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능
2691
+ // ⚙️ Response type can be adjusted according to the GitLab API documentation
2428
2692
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
2429
2693
  );
2430
2694
  const response = await fetch(url.toString(), {
@@ -2472,14 +2736,18 @@ async function listDraftNotes(projectId, mergeRequestIid) {
2472
2736
  * @param {string} projectId - The ID or URL-encoded path of the project
2473
2737
  * @param {number|string} mergeRequestIid - The internal ID of the merge request
2474
2738
  * @param {string} body - The content of the draft note
2739
+ * @param {string} [inReplyToDiscussionId] - The ID of a discussion the draft note replies to
2475
2740
  * @param {MergeRequestThreadPosition} [position] - Position information for diff notes
2476
2741
  * @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing
2477
2742
  * @returns {Promise<GitLabDraftNote>} The created draft note
2478
2743
  */
2479
- async function createDraftNote(projectId, mergeRequestIid, body, position, resolveDiscussion) {
2744
+ async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscussionId, position, resolveDiscussion) {
2480
2745
  projectId = decodeURIComponent(projectId);
2481
2746
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes`);
2482
2747
  const requestBody = { note: body };
2748
+ if (inReplyToDiscussionId) {
2749
+ requestBody.in_reply_to_discussion_id = inReplyToDiscussionId;
2750
+ }
2483
2751
  if (position) {
2484
2752
  requestBody.position = position;
2485
2753
  }
@@ -3902,6 +4170,20 @@ async function markdownUpload(projectId, filePath) {
3902
4170
  const data = await response.json();
3903
4171
  return GitLabMarkdownUploadSchema.parse(data);
3904
4172
  }
4173
+ const IMAGE_MIME_TYPES = {
4174
+ ".png": "image/png",
4175
+ ".jpg": "image/jpeg",
4176
+ ".jpeg": "image/jpeg",
4177
+ ".gif": "image/gif",
4178
+ ".webp": "image/webp",
4179
+ ".svg": "image/svg+xml",
4180
+ ".bmp": "image/bmp",
4181
+ ".ico": "image/x-icon",
4182
+ };
4183
+ function getImageMimeType(filename) {
4184
+ const ext = path.extname(filename).toLowerCase();
4185
+ return IMAGE_MIME_TYPES[ext] ?? null;
4186
+ }
3905
4187
  async function downloadAttachment(projectId, secret, filename, localPath) {
3906
4188
  const effectiveProjectId = getEffectiveProjectId(projectId);
3907
4189
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
@@ -3916,12 +4198,33 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
3916
4198
  await handleGitLabError(response);
3917
4199
  }
3918
4200
  // Get the file content as buffer
3919
- const buffer = await response.arrayBuffer();
3920
- // Determine the save path
3921
- const savePath = localPath ? path.join(localPath, filename) : filename;
3922
- // Write the file to disk
3923
- fs.writeFileSync(savePath, Buffer.from(buffer));
3924
- return savePath;
4201
+ const buffer = Buffer.from(await response.arrayBuffer());
4202
+ const mimeType = getImageMimeType(filename);
4203
+ // For non-image files, always save to disk.
4204
+ // For image files, only save to disk if local_path is explicitly provided.
4205
+ if (!mimeType || localPath) {
4206
+ let savePath;
4207
+ if (localPath) {
4208
+ const normalizedLocalPath = path.normalize(localPath);
4209
+ if (path.isAbsolute(normalizedLocalPath) ||
4210
+ normalizedLocalPath === ".." ||
4211
+ normalizedLocalPath.startsWith(".." + path.sep) ||
4212
+ normalizedLocalPath.includes(path.sep + ".." + path.sep)) {
4213
+ throw new Error("Invalid local_path: directory traversal is not allowed.");
4214
+ }
4215
+ savePath = path.join(normalizedLocalPath, filename);
4216
+ }
4217
+ else {
4218
+ savePath = filename;
4219
+ }
4220
+ const dir = path.dirname(savePath);
4221
+ if (!fs.existsSync(dir)) {
4222
+ fs.mkdirSync(dir, { recursive: true });
4223
+ }
4224
+ fs.writeFileSync(savePath, buffer);
4225
+ return { buffer, filename, mimeType, savedPath: savePath };
4226
+ }
4227
+ return { buffer, filename, mimeType };
3925
4228
  }
3926
4229
  /**
3927
4230
  * List all events for the currently authenticated user
@@ -4573,8 +4876,8 @@ async function handleToolCall(params) {
4573
4876
  }
4574
4877
  case "create_draft_note": {
4575
4878
  const args = CreateDraftNoteSchema.parse(params.arguments);
4576
- const { project_id, merge_request_iid, body, position, resolve_discussion } = args;
4577
- const draftNote = await createDraftNote(project_id, merge_request_iid, body, position, resolve_discussion);
4879
+ const { project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion } = args;
4880
+ const draftNote = await createDraftNote(project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion);
4578
4881
  return {
4579
4882
  content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }],
4580
4883
  };
@@ -5110,10 +5413,26 @@ async function handleToolCall(params) {
5110
5413
  }
5111
5414
  case "download_attachment": {
5112
5415
  const args = DownloadAttachmentSchema.parse(params.arguments);
5113
- const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
5416
+ const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
5417
+ if (result.mimeType && !args.local_path) {
5418
+ // Return image inline as base64 so the AI can see it
5419
+ const base64 = result.buffer.toString("base64");
5420
+ return {
5421
+ content: [
5422
+ { type: "image", data: base64, mimeType: result.mimeType },
5423
+ {
5424
+ type: "text",
5425
+ text: JSON.stringify({ filename: result.filename, mimeType: result.mimeType }, null, 2),
5426
+ },
5427
+ ],
5428
+ };
5429
+ }
5114
5430
  return {
5115
5431
  content: [
5116
- { type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) },
5432
+ {
5433
+ type: "text",
5434
+ text: JSON.stringify({ success: true, file_path: result.savedPath }, null, 2),
5435
+ },
5117
5436
  ],
5118
5437
  };
5119
5438
  }