@zereight/mcp-gitlab 2.0.28 → 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.
package/build/index.js CHANGED
@@ -36,21 +36,21 @@ import { CookieJar, parse as parseCookie } from "tough-cookie";
36
36
  import { fileURLToPath, URL } from "node:url";
37
37
  import { z } from "zod";
38
38
  import { zodToJsonSchema } from "zod-to-json-schema";
39
- import { initializeOAuth } from "./oauth.js";
39
+ import { initializeOAuthClient } from "./oauth.js";
40
40
  import { GitLabClientPool } from "./gitlab-client-pool.js";
41
41
  // Add type imports for proxy agents
42
42
  import { Agent } from "node:http";
43
43
  import { Agent as HttpsAgent } from "node:https";
44
44
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
45
- CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, DeleteDraftNoteSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema,
45
+ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, DeleteDraftNoteSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema,
46
46
  // pipeline job schemas
47
47
  GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
48
48
  // Discussion Schemas
49
49
  GitLabDiscussionNoteSchema, // Added
50
50
  GitLabDiscussionSchema,
51
51
  // Draft Notes Schemas
52
- GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
53
- ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, } from "./schemas.js";
52
+ GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
53
+ ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, } from "./schemas.js";
54
54
  import { randomUUID } from "node:crypto";
55
55
  import { pino } from "pino";
56
56
  const logger = pino({
@@ -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) => {
@@ -234,6 +243,28 @@ function validateConfiguration() {
234
243
  }
235
244
  const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
236
245
  let OAUTH_ACCESS_TOKEN = null;
246
+ let oauthClient = null;
247
+ /**
248
+ * Ensure the OAuth token is valid before making an API call.
249
+ * Refreshes the token lazily (only when a tool is actually called).
250
+ * This avoids background timers that cause issues with multiple instances.
251
+ */
252
+ async function ensureValidOAuthToken() {
253
+ if (!oauthClient)
254
+ return;
255
+ if (oauthClient.hasValidToken())
256
+ return;
257
+ try {
258
+ logger.info("OAuth token expired or missing, refreshing...");
259
+ const freshToken = await oauthClient.getAccessToken();
260
+ OAUTH_ACCESS_TOKEN = freshToken;
261
+ logger.info("OAuth token refreshed successfully");
262
+ }
263
+ catch (error) {
264
+ logger.error("Failed to refresh OAuth token:", error);
265
+ throw error;
266
+ }
267
+ }
237
268
  const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
238
269
  const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
239
270
  const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
@@ -269,6 +300,8 @@ const GITLAB_DENIED_TOOLS_REGEX = (() => {
269
300
  const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
270
301
  const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
271
302
  const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
303
+ const GITLAB_TOOLSETS_RAW = getConfig("toolsets", "GITLAB_TOOLSETS");
304
+ const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
272
305
  const SSE = getConfig("sse", "SSE") === "true";
273
306
  const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
274
307
  const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
@@ -315,7 +348,7 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
315
348
  httpAgent = httpAgent || new Agent();
316
349
  // Initialize the client pool for managing multiple GitLab instances
317
350
  const clientPool = new GitLabClientPool({
318
- apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
351
+ apiUrls: (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
319
352
  .split(",")
320
353
  .map(normalizeGitLabApiUrl),
321
354
  httpProxy: HTTP_PROXY,
@@ -446,7 +479,7 @@ const BASE_HEADERS = {
446
479
  /**
447
480
  * Build authentication headers dynamically based on context
448
481
  * In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
449
- * Otherwise, uses environment token
482
+ * Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
450
483
  */
451
484
  function buildAuthHeaders() {
452
485
  if (REMOTE_AUTHORIZATION) {
@@ -561,7 +594,7 @@ const allTools = [
561
594
  },
562
595
  {
563
596
  name: "get_merge_request_approval_state",
564
- description: "Get the approval state of a merge request including approval rules and who has approved",
597
+ description: "Get merge request approval details including approvers (uses approval_state when available, falls back to approvals endpoint)",
565
598
  inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
566
599
  },
567
600
  {
@@ -616,7 +649,7 @@ const allTools = [
616
649
  },
617
650
  {
618
651
  name: "get_merge_request",
619
- description: "Get details of a merge request (Either mergeRequestIid or branchName must be provided)",
652
+ description: "Get details of a merge request with compact deployment, commit addition, and approval summaries (Either mergeRequestIid or branchName must be provided)",
620
653
  inputSchema: toJSONSchema(GetMergeRequestSchema),
621
654
  },
622
655
  {
@@ -904,6 +937,26 @@ const allTools = [
904
937
  description: "Get details of a specific pipeline in a GitLab project",
905
938
  inputSchema: toJSONSchema(GetPipelineSchema),
906
939
  },
940
+ {
941
+ name: "list_deployments",
942
+ description: "List deployments in a GitLab project with filtering options",
943
+ inputSchema: toJSONSchema(ListDeploymentsSchema),
944
+ },
945
+ {
946
+ name: "get_deployment",
947
+ description: "Get details of a specific deployment in a GitLab project",
948
+ inputSchema: toJSONSchema(GetDeploymentSchema),
949
+ },
950
+ {
951
+ name: "list_environments",
952
+ description: "List environments in a GitLab project",
953
+ inputSchema: toJSONSchema(ListEnvironmentsSchema),
954
+ },
955
+ {
956
+ name: "get_environment",
957
+ description: "Get details of a specific environment in a GitLab project",
958
+ inputSchema: toJSONSchema(GetEnvironmentSchema),
959
+ },
907
960
  {
908
961
  name: "list_pipeline_jobs",
909
962
  description: "List all jobs in a specific pipeline",
@@ -954,6 +1007,21 @@ const allTools = [
954
1007
  description: "Cancel a running pipeline job",
955
1008
  inputSchema: toJSONSchema(CancelPipelineJobSchema),
956
1009
  },
1010
+ {
1011
+ name: "list_job_artifacts",
1012
+ description: "List artifact files in a job's artifacts archive. Returns file names, paths, types, and sizes.",
1013
+ inputSchema: toJSONSchema(ListJobArtifactsSchema),
1014
+ },
1015
+ {
1016
+ name: "download_job_artifacts",
1017
+ description: "Download the entire artifact archive (zip) for a job to a local path. Returns the saved file path.",
1018
+ inputSchema: toJSONSchema(DownloadJobArtifactsSchema),
1019
+ },
1020
+ {
1021
+ name: "get_job_artifact_file",
1022
+ description: "Get the content of a single file from a job's artifacts by its path within the archive",
1023
+ inputSchema: toJSONSchema(GetJobArtifactFileSchema),
1024
+ },
957
1025
  {
958
1026
  name: "list_merge_requests",
959
1027
  description: "List merge requests. Without project_id, lists MRs assigned to the authenticated user by default (use scope='all' for all accessible MRs). With project_id, lists MRs for that specific project.",
@@ -1036,7 +1104,7 @@ const allTools = [
1036
1104
  },
1037
1105
  {
1038
1106
  name: "download_attachment",
1039
- description: "Download an uploaded file from a GitLab project by secret and filename",
1107
+ 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
1108
  inputSchema: toJSONSchema(DownloadAttachmentSchema),
1041
1109
  },
1042
1110
  {
@@ -1111,10 +1179,17 @@ const readOnlyTools = new Set([
1111
1179
  "list_project_members",
1112
1180
  "get_pipeline",
1113
1181
  "list_pipelines",
1182
+ "list_deployments",
1183
+ "get_deployment",
1184
+ "list_environments",
1185
+ "get_environment",
1114
1186
  "list_pipeline_jobs",
1115
1187
  "list_pipeline_trigger_jobs",
1116
1188
  "get_pipeline_job",
1117
1189
  "get_pipeline_job_output",
1190
+ "list_job_artifacts",
1191
+ "download_job_artifacts",
1192
+ "get_job_artifact_file",
1118
1193
  "list_labels",
1119
1194
  "get_label",
1120
1195
  "list_group_projects",
@@ -1165,6 +1240,10 @@ const milestoneToolNames = new Set([
1165
1240
  const pipelineToolNames = new Set([
1166
1241
  "list_pipelines",
1167
1242
  "get_pipeline",
1243
+ "list_deployments",
1244
+ "get_deployment",
1245
+ "list_environments",
1246
+ "get_environment",
1168
1247
  "list_pipeline_jobs",
1169
1248
  "list_pipeline_trigger_jobs",
1170
1249
  "get_pipeline_job",
@@ -1175,7 +1254,271 @@ const pipelineToolNames = new Set([
1175
1254
  "play_pipeline_job",
1176
1255
  "retry_pipeline_job",
1177
1256
  "cancel_pipeline_job",
1257
+ "list_job_artifacts",
1258
+ "download_job_artifacts",
1259
+ "get_job_artifact_file",
1178
1260
  ]);
1261
+ const TOOLSET_DEFINITIONS = [
1262
+ {
1263
+ id: "merge_requests",
1264
+ isDefault: true,
1265
+ tools: new Set([
1266
+ "merge_merge_request",
1267
+ "approve_merge_request",
1268
+ "unapprove_merge_request",
1269
+ "get_merge_request_approval_state",
1270
+ "get_merge_request",
1271
+ "get_merge_request_diffs",
1272
+ "list_merge_request_diffs",
1273
+ "list_merge_request_versions",
1274
+ "get_merge_request_version",
1275
+ "update_merge_request",
1276
+ "create_merge_request",
1277
+ "list_merge_requests",
1278
+ "get_branch_diffs",
1279
+ "mr_discussions",
1280
+ "create_merge_request_note",
1281
+ "update_merge_request_note",
1282
+ "delete_merge_request_note",
1283
+ "get_merge_request_note",
1284
+ "get_merge_request_notes",
1285
+ "delete_merge_request_discussion_note",
1286
+ "update_merge_request_discussion_note",
1287
+ "create_merge_request_discussion_note",
1288
+ "get_draft_note",
1289
+ "list_draft_notes",
1290
+ "create_draft_note",
1291
+ "update_draft_note",
1292
+ "delete_draft_note",
1293
+ "publish_draft_note",
1294
+ "bulk_publish_draft_notes",
1295
+ "create_merge_request_thread",
1296
+ "resolve_merge_request_thread",
1297
+ ]),
1298
+ },
1299
+ {
1300
+ id: "issues",
1301
+ isDefault: true,
1302
+ tools: new Set([
1303
+ "create_issue",
1304
+ "list_issues",
1305
+ "my_issues",
1306
+ "get_issue",
1307
+ "update_issue",
1308
+ "delete_issue",
1309
+ "create_issue_note",
1310
+ "update_issue_note",
1311
+ "list_issue_links",
1312
+ "list_issue_discussions",
1313
+ "get_issue_link",
1314
+ "create_issue_link",
1315
+ "delete_issue_link",
1316
+ "create_note",
1317
+ ]),
1318
+ },
1319
+ {
1320
+ id: "repositories",
1321
+ isDefault: true,
1322
+ tools: new Set([
1323
+ "search_repositories",
1324
+ "create_repository",
1325
+ "get_file_contents",
1326
+ "push_files",
1327
+ "create_or_update_file",
1328
+ "fork_repository",
1329
+ "get_repository_tree",
1330
+ ]),
1331
+ },
1332
+ {
1333
+ id: "branches",
1334
+ isDefault: true,
1335
+ tools: new Set([
1336
+ "create_branch",
1337
+ "list_commits",
1338
+ "get_commit",
1339
+ "get_commit_diff",
1340
+ ]),
1341
+ },
1342
+ {
1343
+ id: "projects",
1344
+ isDefault: true,
1345
+ tools: new Set([
1346
+ "get_project",
1347
+ "list_projects",
1348
+ "list_project_members",
1349
+ "list_namespaces",
1350
+ "get_namespace",
1351
+ "verify_namespace",
1352
+ "list_group_projects",
1353
+ "list_group_iterations",
1354
+ ]),
1355
+ },
1356
+ {
1357
+ id: "labels",
1358
+ isDefault: true,
1359
+ tools: new Set([
1360
+ "list_labels",
1361
+ "get_label",
1362
+ "create_label",
1363
+ "update_label",
1364
+ "delete_label",
1365
+ ]),
1366
+ },
1367
+ {
1368
+ id: "pipelines",
1369
+ isDefault: true,
1370
+ tools: new Set([
1371
+ "list_pipelines",
1372
+ "get_pipeline",
1373
+ "list_deployments",
1374
+ "get_deployment",
1375
+ "list_environments",
1376
+ "get_environment",
1377
+ "list_pipeline_jobs",
1378
+ "list_pipeline_trigger_jobs",
1379
+ "get_pipeline_job",
1380
+ "get_pipeline_job_output",
1381
+ "create_pipeline",
1382
+ "retry_pipeline",
1383
+ "cancel_pipeline",
1384
+ "play_pipeline_job",
1385
+ "retry_pipeline_job",
1386
+ "cancel_pipeline_job",
1387
+ "list_job_artifacts",
1388
+ "download_job_artifacts",
1389
+ "get_job_artifact_file",
1390
+ ]),
1391
+ },
1392
+ {
1393
+ id: "milestones",
1394
+ isDefault: true,
1395
+ tools: new Set([
1396
+ "list_milestones",
1397
+ "get_milestone",
1398
+ "create_milestone",
1399
+ "edit_milestone",
1400
+ "delete_milestone",
1401
+ "get_milestone_issue",
1402
+ "get_milestone_merge_requests",
1403
+ "promote_milestone",
1404
+ "get_milestone_burndown_events",
1405
+ ]),
1406
+ },
1407
+ {
1408
+ id: "wiki",
1409
+ isDefault: true,
1410
+ tools: new Set([
1411
+ "list_wiki_pages",
1412
+ "get_wiki_page",
1413
+ "create_wiki_page",
1414
+ "update_wiki_page",
1415
+ "delete_wiki_page",
1416
+ ]),
1417
+ },
1418
+ {
1419
+ id: "releases",
1420
+ isDefault: true,
1421
+ tools: new Set([
1422
+ "list_releases",
1423
+ "get_release",
1424
+ "create_release",
1425
+ "update_release",
1426
+ "delete_release",
1427
+ "create_release_evidence",
1428
+ "download_release_asset",
1429
+ ]),
1430
+ },
1431
+ {
1432
+ id: "users",
1433
+ isDefault: true,
1434
+ tools: new Set([
1435
+ "get_users",
1436
+ "list_events",
1437
+ "get_project_events",
1438
+ "upload_markdown",
1439
+ "download_attachment",
1440
+ ]),
1441
+ },
1442
+ ];
1443
+ // Derived lookup: tool name → toolset ID
1444
+ const TOOLSET_BY_TOOL_NAME = new Map();
1445
+ for (const def of TOOLSET_DEFINITIONS) {
1446
+ for (const tool of def.tools) {
1447
+ if (TOOLSET_BY_TOOL_NAME.has(tool)) {
1448
+ logger.warn(`Tool "${tool}" is defined in multiple toolsets: "${TOOLSET_BY_TOOL_NAME.get(tool)}" and "${def.id}"`);
1449
+ }
1450
+ TOOLSET_BY_TOOL_NAME.set(tool, def.id);
1451
+ }
1452
+ }
1453
+ const DEFAULT_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.filter(d => d.isDefault).map(d => d.id));
1454
+ const ALL_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.map(d => d.id));
1455
+ function parseEnabledToolsets(raw) {
1456
+ if (!raw || raw.trim() === "") {
1457
+ return DEFAULT_TOOLSET_IDS;
1458
+ }
1459
+ const trimmed = raw.trim().toLowerCase();
1460
+ if (trimmed === "all") {
1461
+ return ALL_TOOLSET_IDS;
1462
+ }
1463
+ const selected = new Set(trimmed
1464
+ .split(",")
1465
+ .map(s => s.trim())
1466
+ .filter((s) => ALL_TOOLSET_IDS.has(s)));
1467
+ if (selected.size === 0) {
1468
+ logger.warn(`No valid toolsets found in configuration (${raw}). Falling back to default toolsets.`);
1469
+ return DEFAULT_TOOLSET_IDS;
1470
+ }
1471
+ return selected;
1472
+ }
1473
+ function parseIndividualTools(raw) {
1474
+ if (!raw || raw.trim() === "") {
1475
+ return new Set();
1476
+ }
1477
+ const allToolNames = new Set(allTools.map((t) => t.name));
1478
+ const parsed = raw
1479
+ .trim()
1480
+ .split(",")
1481
+ .map(s => s.trim().toLowerCase())
1482
+ .filter(Boolean);
1483
+ const unknown = parsed.filter(name => !allToolNames.has(name));
1484
+ if (unknown.length > 0) {
1485
+ logger.warn(`Unknown tool names in GITLAB_TOOLS (will be ignored): ${unknown.join(", ")}`);
1486
+ }
1487
+ return new Set(parsed);
1488
+ }
1489
+ function buildFeatureFlagOverrides() {
1490
+ const overrides = new Set();
1491
+ if (USE_GITLAB_WIKI) {
1492
+ for (const t of wikiToolNames)
1493
+ overrides.add(t);
1494
+ }
1495
+ if (USE_MILESTONE) {
1496
+ for (const t of milestoneToolNames)
1497
+ overrides.add(t);
1498
+ }
1499
+ if (USE_PIPELINE) {
1500
+ for (const t of pipelineToolNames)
1501
+ overrides.add(t);
1502
+ }
1503
+ return overrides;
1504
+ }
1505
+ function isToolInEnabledToolset(toolName, enabledToolsets) {
1506
+ const toolsetId = TOOLSET_BY_TOOL_NAME.get(toolName);
1507
+ // Tools not in any toolset (e.g. execute_graphql) are excluded by default
1508
+ if (toolsetId === undefined)
1509
+ return false;
1510
+ return enabledToolsets.has(toolsetId);
1511
+ }
1512
+ // Compute at startup
1513
+ const enabledToolsets = parseEnabledToolsets(GITLAB_TOOLSETS_RAW);
1514
+ const individuallyEnabledTools = parseIndividualTools(GITLAB_TOOLS_RAW);
1515
+ const featureFlagOverrides = buildFeatureFlagOverrides();
1516
+ // Warn about potentially confusing configuration
1517
+ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
1518
+ logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
1519
+ "Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
1520
+ }
1521
+ const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10;
1179
1522
  /**
1180
1523
  * Smart URL handling for GitLab API
1181
1524
  *
@@ -1196,7 +1539,7 @@ function normalizeGitLabApiUrl(url) {
1196
1539
  return normalizedUrl;
1197
1540
  }
1198
1541
  // Use the normalizeGitLabApiUrl function to handle various URL formats
1199
- const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
1542
+ const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
1200
1543
  .split(",")
1201
1544
  .map(normalizeGitLabApiUrl);
1202
1545
  const GITLAB_API_URL = GITLAB_API_URLS[0];
@@ -1292,7 +1635,7 @@ async function forkProject(projectId, namespace) {
1292
1635
  ...getFetchConfig(),
1293
1636
  method: "POST",
1294
1637
  });
1295
- // 이미 존재하는 프로젝트인 경우 처리
1638
+ // Handle case where project already exists
1296
1639
  if (response.status === 409) {
1297
1640
  throw new Error("Project already exists in the target namespace");
1298
1641
  }
@@ -1351,26 +1694,26 @@ async function getDefaultBranchRef(projectId) {
1351
1694
  * @returns {Promise<GitLabContent>} The file content
1352
1695
  */
1353
1696
  async function getFileContents(projectId, filePath, ref) {
1354
- projectId = decodeURIComponent(projectId); // Decode project ID
1355
- const effectiveProjectId = getEffectiveProjectId(projectId);
1697
+ const decodedProjectId = projectId ? decodeURIComponent(projectId) : "";
1698
+ const effectiveProjectId = getEffectiveProjectId(decodedProjectId);
1356
1699
  const encodedPath = encodeURIComponent(filePath);
1357
- // ref가 없는 경우 default branch 가져옴
1700
+ // Fall back to default branch if ref is not provided
1358
1701
  if (!ref) {
1359
- ref = await getDefaultBranchRef(projectId);
1702
+ ref = await getDefaultBranchRef(decodedProjectId);
1360
1703
  }
1361
1704
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}`);
1362
1705
  url.searchParams.append("ref", ref);
1363
1706
  const response = await fetch(url.toString(), {
1364
1707
  ...getFetchConfig(),
1365
1708
  });
1366
- // 파일을 찾을 없는 경우 처리
1709
+ // Handle file not found
1367
1710
  if (response.status === 404) {
1368
1711
  throw new Error(`File not found: ${filePath}`);
1369
1712
  }
1370
1713
  await handleGitLabError(response);
1371
1714
  const data = await response.json();
1372
1715
  const parsedData = GitLabContentSchema.parse(data);
1373
- // Base64 인코딩된 파일 내용을 UTF-8로 디코딩
1716
+ // Decode Base64-encoded file content to UTF-8
1374
1717
  if (!Array.isArray(parsedData) && parsedData.content) {
1375
1718
  parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8");
1376
1719
  parsedData.encoding = "utf8";
@@ -1400,7 +1743,7 @@ async function createIssue(projectId, options) {
1400
1743
  labels: options.labels?.join(","),
1401
1744
  }),
1402
1745
  });
1403
- // 잘못된 요청 처리
1746
+ // Handle bad request
1404
1747
  if (response.status === 400) {
1405
1748
  const errorBody = await response.text();
1406
1749
  throw new Error(`Invalid request: ${errorBody}`);
@@ -2190,6 +2533,7 @@ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
2190
2533
  let url;
2191
2534
  if (mergeRequestIid) {
2192
2535
  url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}`);
2536
+ url.searchParams.append("include_diverged_commits_count", "true");
2193
2537
  }
2194
2538
  else if (branchName) {
2195
2539
  url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests?source_branch=${encodeURIComponent(branchName)}`);
@@ -2204,10 +2548,219 @@ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
2204
2548
  const data = await response.json();
2205
2549
  // If response is an array (Comes from branchName search), return the first item if exist
2206
2550
  if (Array.isArray(data) && data.length > 0) {
2207
- return GitLabMergeRequestSchema.parse(data[0]);
2551
+ const mergeRequest = GitLabMergeRequestSchema.parse(data[0]);
2552
+ return getMergeRequest(projectId, mergeRequest.iid, undefined);
2208
2553
  }
2209
2554
  return GitLabMergeRequestSchema.parse(data);
2210
2555
  }
2556
+ async function getMergeRequestSourceCommitCount(projectId, mergeRequestIid) {
2557
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/commits`);
2558
+ url.searchParams.append("per_page", "100");
2559
+ let totalCount = 0;
2560
+ let page = 1;
2561
+ while (true) {
2562
+ url.searchParams.set("page", String(page));
2563
+ const response = await fetch(url.toString(), {
2564
+ ...getFetchConfig(),
2565
+ });
2566
+ await handleGitLabError(response);
2567
+ const data = await response.json();
2568
+ if (!Array.isArray(data)) {
2569
+ throw new Error("Unexpected merge request commits response format");
2570
+ }
2571
+ totalCount += data.length;
2572
+ const nextPage = response.headers.get("x-next-page");
2573
+ if (!nextPage) {
2574
+ break;
2575
+ }
2576
+ page = Number.parseInt(nextPage, 10);
2577
+ if (Number.isNaN(page) || page <= 0) {
2578
+ break;
2579
+ }
2580
+ }
2581
+ return totalCount;
2582
+ }
2583
+ async function getProjectMergeMethod(projectId) {
2584
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}`);
2585
+ const response = await fetch(url.toString(), {
2586
+ ...getFetchConfig(),
2587
+ });
2588
+ await handleGitLabError(response);
2589
+ const data = await response.json();
2590
+ const mergeMethod = z
2591
+ .object({
2592
+ merge_method: z.string().nullable().optional(),
2593
+ })
2594
+ .parse(data).merge_method;
2595
+ return typeof mergeMethod === "string" ? mergeMethod : null;
2596
+ }
2597
+ function estimateMergeCommitCount(mergeMethod, sourceCommitCount) {
2598
+ if (sourceCommitCount === 0) {
2599
+ return 0;
2600
+ }
2601
+ if (mergeMethod === "merge") {
2602
+ return 1;
2603
+ }
2604
+ if (mergeMethod === "ff" || mergeMethod === "rebase_merge") {
2605
+ return 0;
2606
+ }
2607
+ return null;
2608
+ }
2609
+ async function buildMergeRequestCommitAdditionSummary(projectId, mergeRequest) {
2610
+ try {
2611
+ const sourceCommitCount = await getMergeRequestSourceCommitCount(projectId, mergeRequest.iid);
2612
+ const mergeMethod = await getProjectMergeMethod(projectId);
2613
+ const mergeCommitCount = estimateMergeCommitCount(mergeMethod, sourceCommitCount);
2614
+ const summary = mergeCommitCount === null
2615
+ ? null
2616
+ : `${sourceCommitCount} commits and ${mergeCommitCount} merge commit${mergeCommitCount === 1 ? "" : "s"} will be added to ${mergeRequest.target_branch}.`;
2617
+ return {
2618
+ target_branch: mergeRequest.target_branch,
2619
+ source_commits_count: sourceCommitCount,
2620
+ merge_method: mergeMethod,
2621
+ merge_commit_count: mergeCommitCount,
2622
+ summary,
2623
+ };
2624
+ }
2625
+ catch (error) {
2626
+ const unavailableReason = error instanceof Error ? error.message : String(error);
2627
+ return {
2628
+ target_branch: mergeRequest.target_branch,
2629
+ source_commits_count: null,
2630
+ merge_method: null,
2631
+ merge_commit_count: null,
2632
+ summary: null,
2633
+ unavailable_reason: unavailableReason,
2634
+ };
2635
+ }
2636
+ }
2637
+ async function buildMergeRequestApprovalSummary(projectId, mergeRequestIid) {
2638
+ try {
2639
+ const approvalState = await getMergeRequestApprovalState(projectId, mergeRequestIid);
2640
+ const approvedByUsers = approvalState.approved_by || [];
2641
+ const approvedByUsernames = approvalState.approved_by_usernames || approvedByUsers.map(user => user.username);
2642
+ const inferredApproved = inferMergeRequestApproved(approvalState.rules);
2643
+ return {
2644
+ approved: approvalState.approved ?? inferredApproved,
2645
+ user_has_approved: approvalState.user_has_approved ?? null,
2646
+ user_can_approve: approvalState.user_can_approve ?? null,
2647
+ approved_by: approvedByUsers,
2648
+ approved_by_usernames: approvedByUsernames,
2649
+ rules_count: approvalState.rules?.length ?? null,
2650
+ source_endpoint: approvalState.source_endpoint ?? null,
2651
+ };
2652
+ }
2653
+ catch (error) {
2654
+ const unavailableReason = error instanceof Error ? error.message : String(error);
2655
+ return {
2656
+ approved: null,
2657
+ user_has_approved: null,
2658
+ user_can_approve: null,
2659
+ approved_by: [],
2660
+ approved_by_usernames: [],
2661
+ rules_count: null,
2662
+ source_endpoint: null,
2663
+ unavailable_reason: unavailableReason,
2664
+ };
2665
+ }
2666
+ }
2667
+ function toMergeRequestDeploymentSummaryRecord(deployment) {
2668
+ return {
2669
+ id: deployment.id,
2670
+ status: deployment.status,
2671
+ ref: deployment.ref,
2672
+ sha: deployment.sha,
2673
+ created_at: deployment.created_at,
2674
+ updated_at: deployment.updated_at,
2675
+ finished_at: deployment.finished_at,
2676
+ web_url: deployment.web_url,
2677
+ environment: deployment.environment
2678
+ ? {
2679
+ id: deployment.environment.id,
2680
+ name: deployment.environment.name,
2681
+ slug: deployment.environment.slug,
2682
+ external_url: deployment.environment.external_url,
2683
+ state: deployment.environment.state,
2684
+ tier: deployment.environment.tier,
2685
+ }
2686
+ : undefined,
2687
+ deployable: deployment.deployable === null
2688
+ ? null
2689
+ : deployment.deployable
2690
+ ? {
2691
+ id: deployment.deployable.id,
2692
+ name: deployment.deployable.name,
2693
+ status: deployment.deployable.status,
2694
+ stage: deployment.deployable.stage,
2695
+ web_url: deployment.deployable.web_url,
2696
+ pipeline: deployment.deployable.pipeline
2697
+ ? {
2698
+ id: deployment.deployable.pipeline.id,
2699
+ status: deployment.deployable.pipeline.status,
2700
+ ref: deployment.deployable.pipeline.ref,
2701
+ sha: deployment.deployable.pipeline.sha,
2702
+ web_url: deployment.deployable.pipeline.web_url,
2703
+ }
2704
+ : undefined,
2705
+ }
2706
+ : undefined,
2707
+ };
2708
+ }
2709
+ function sortDeploymentsByCreatedAtDesc(deployments) {
2710
+ return [...deployments].sort((a, b) => {
2711
+ const aTime = Date.parse(a.created_at);
2712
+ const bTime = Date.parse(b.created_at);
2713
+ if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
2714
+ return b.created_at.localeCompare(a.created_at);
2715
+ }
2716
+ return bTime - aTime;
2717
+ });
2718
+ }
2719
+ async function buildMergeRequestDeploymentSummary(projectId, mergeRequest) {
2720
+ const lookupSha = mergeRequest.merge_commit_sha ?? mergeRequest.diff_refs?.head_sha ?? null;
2721
+ if (!lookupSha) {
2722
+ return {
2723
+ lookup_sha: null,
2724
+ sort: "created_at_desc",
2725
+ limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
2726
+ total_count: 0,
2727
+ returned_count: 0,
2728
+ records: [],
2729
+ };
2730
+ }
2731
+ try {
2732
+ const deployments = await listDeployments(projectId, {
2733
+ sha: lookupSha,
2734
+ order_by: "created_at",
2735
+ sort: "desc",
2736
+ per_page: 100,
2737
+ });
2738
+ const sortedDeployments = sortDeploymentsByCreatedAtDesc(deployments);
2739
+ const records = sortedDeployments
2740
+ .slice(0, MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT)
2741
+ .map(toMergeRequestDeploymentSummaryRecord);
2742
+ return {
2743
+ lookup_sha: lookupSha,
2744
+ sort: "created_at_desc",
2745
+ limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
2746
+ total_count: sortedDeployments.length,
2747
+ returned_count: records.length,
2748
+ records,
2749
+ };
2750
+ }
2751
+ catch (error) {
2752
+ const unavailableReason = error instanceof Error ? error.message : String(error);
2753
+ return {
2754
+ lookup_sha: lookupSha,
2755
+ sort: "created_at_desc",
2756
+ limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
2757
+ total_count: 0,
2758
+ returned_count: 0,
2759
+ records: [],
2760
+ unavailable_reason: unavailableReason,
2761
+ };
2762
+ }
2763
+ }
2211
2764
  /**
2212
2765
  * Get merge request changes/diffs
2213
2766
  * MR 변경사항 조회 함수 (Function to retrieve merge request changes)
@@ -2402,13 +2955,61 @@ async function unapproveMergeRequest(projectId, mergeRequestIid) {
2402
2955
  */
2403
2956
  async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
2404
2957
  projectId = decodeURIComponent(projectId);
2405
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
2406
- const response = await fetch(url.toString(), {
2958
+ const approvalStateUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
2959
+ const approvalStateResponse = await fetch(approvalStateUrl.toString(), {
2407
2960
  ...getFetchConfig(),
2408
2961
  method: "GET",
2409
2962
  });
2410
- await handleGitLabError(response);
2411
- return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2963
+ if (approvalStateResponse.status === 404) {
2964
+ return getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
2965
+ }
2966
+ await handleGitLabError(approvalStateResponse);
2967
+ const parsedApprovalState = GitLabMergeRequestApprovalStateSchema.parse(await approvalStateResponse.json());
2968
+ const approvedByUsers = getUniqueApprovalUsers((parsedApprovalState.rules || []).flatMap(rule => rule.approved_by || []));
2969
+ const approvedByUsernames = approvedByUsers.map(user => user.username);
2970
+ return {
2971
+ ...parsedApprovalState,
2972
+ approved_by: approvedByUsers,
2973
+ approved_by_usernames: approvedByUsernames,
2974
+ source_endpoint: "approval_state",
2975
+ };
2976
+ }
2977
+ async function getMergeRequestApprovalsFallback(projectId, mergeRequestIid) {
2978
+ const approvalsUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approvals`);
2979
+ const approvalsResponse = await fetch(approvalsUrl.toString(), {
2980
+ ...getFetchConfig(),
2981
+ method: "GET",
2982
+ });
2983
+ await handleGitLabError(approvalsResponse);
2984
+ const parsedApprovals = GitLabMergeRequestApprovalsResponseSchema.parse(await approvalsResponse.json());
2985
+ const approvedByUsers = getUniqueApprovalUsers((parsedApprovals.approved_by || []).map(approvedByEntry => approvedByEntry.user));
2986
+ const approvedByUsernames = approvedByUsers.map(user => user.username);
2987
+ return GitLabMergeRequestApprovalStateSchema.parse({
2988
+ approved: parsedApprovals.approved,
2989
+ user_has_approved: parsedApprovals.user_has_approved,
2990
+ user_can_approve: parsedApprovals.user_can_approve,
2991
+ approved_by: approvedByUsers,
2992
+ approved_by_usernames: approvedByUsernames,
2993
+ source_endpoint: "approvals",
2994
+ });
2995
+ }
2996
+ function getUniqueApprovalUsers(users) {
2997
+ const uniqueUsers = new Map();
2998
+ for (const user of users) {
2999
+ if (!uniqueUsers.has(user.id)) {
3000
+ uniqueUsers.set(user.id, user);
3001
+ }
3002
+ }
3003
+ return [...uniqueUsers.values()];
3004
+ }
3005
+ function inferMergeRequestApproved(rules) {
3006
+ if (!rules || rules.length === 0) {
3007
+ return null;
3008
+ }
3009
+ if (rules.some(rule => typeof rule.approved !== "boolean")) {
3010
+ return null;
3011
+ }
3012
+ return rules.every(rule => rule.approved === true);
2412
3013
  }
2413
3014
  /**
2414
3015
  * Create a new note (comment) on an issue or merge request
@@ -2421,10 +3022,10 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
2421
3022
  * @param {string} body - The content of the note
2422
3023
  * @returns {Promise<any>} The created note
2423
3024
  */
2424
- async function createNote(projectId, noteableType, // 'issue' 또는 'merge_request' 타입 명시
3025
+ async function createNote(projectId, noteableType, // specifies 'issue' or 'merge_request' type
2425
3026
  noteableIid, body) {
2426
3027
  projectId = decodeURIComponent(projectId); // Decode project ID
2427
- // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능
3028
+ // ⚙️ Response type can be adjusted according to the GitLab API documentation
2428
3029
  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
3030
  );
2430
3031
  const response = await fetch(url.toString(), {
@@ -2472,14 +3073,18 @@ async function listDraftNotes(projectId, mergeRequestIid) {
2472
3073
  * @param {string} projectId - The ID or URL-encoded path of the project
2473
3074
  * @param {number|string} mergeRequestIid - The internal ID of the merge request
2474
3075
  * @param {string} body - The content of the draft note
3076
+ * @param {string} [inReplyToDiscussionId] - The ID of a discussion the draft note replies to
2475
3077
  * @param {MergeRequestThreadPosition} [position] - Position information for diff notes
2476
3078
  * @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing
2477
3079
  * @returns {Promise<GitLabDraftNote>} The created draft note
2478
3080
  */
2479
- async function createDraftNote(projectId, mergeRequestIid, body, position, resolveDiscussion) {
3081
+ async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscussionId, position, resolveDiscussion) {
2480
3082
  projectId = decodeURIComponent(projectId);
2481
3083
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes`);
2482
3084
  const requestBody = { note: body };
3085
+ if (inReplyToDiscussionId) {
3086
+ requestBody.in_reply_to_discussion_id = inReplyToDiscussionId;
3087
+ }
2483
3088
  if (position) {
2484
3089
  requestBody.position = position;
2485
3090
  }
@@ -3141,6 +3746,90 @@ async function getPipeline(projectId, pipelineId) {
3141
3746
  const data = await response.json();
3142
3747
  return GitLabPipelineSchema.parse(data);
3143
3748
  }
3749
+ /**
3750
+ * List deployments in a GitLab project
3751
+ *
3752
+ * @param {string} projectId - The ID or URL-encoded path of the project
3753
+ * @param {ListDeploymentsOptions} options - Options for filtering deployments
3754
+ * @returns {Promise<GitLabDeployment[]>} List of deployments
3755
+ */
3756
+ async function listDeployments(projectId, options = {}) {
3757
+ projectId = decodeURIComponent(projectId);
3758
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments`);
3759
+ Object.entries(options).forEach(([key, value]) => {
3760
+ if (value !== undefined) {
3761
+ url.searchParams.append(key, value.toString());
3762
+ }
3763
+ });
3764
+ const response = await fetch(url.toString(), {
3765
+ ...getFetchConfig(),
3766
+ });
3767
+ await handleGitLabError(response);
3768
+ const data = await response.json();
3769
+ return z.array(GitLabDeploymentSchema).parse(data);
3770
+ }
3771
+ /**
3772
+ * Get details of a specific deployment
3773
+ *
3774
+ * @param {string} projectId - The ID or URL-encoded path of the project
3775
+ * @param {number | string} deploymentId - The ID of the deployment
3776
+ * @returns {Promise<GitLabDeployment>} Deployment details
3777
+ */
3778
+ async function getDeployment(projectId, deploymentId) {
3779
+ projectId = decodeURIComponent(projectId);
3780
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments/${deploymentId}`);
3781
+ const response = await fetch(url.toString(), {
3782
+ ...getFetchConfig(),
3783
+ });
3784
+ if (response.status === 404) {
3785
+ throw new Error(`Deployment not found`);
3786
+ }
3787
+ await handleGitLabError(response);
3788
+ const data = await response.json();
3789
+ return GitLabDeploymentSchema.parse(data);
3790
+ }
3791
+ /**
3792
+ * List environments in a GitLab project
3793
+ *
3794
+ * @param {string} projectId - The ID or URL-encoded path of the project
3795
+ * @param {ListEnvironmentsOptions} options - Options for filtering environments
3796
+ * @returns {Promise<GitLabEnvironment[]>} List of environments
3797
+ */
3798
+ async function listEnvironments(projectId, options = {}) {
3799
+ projectId = decodeURIComponent(projectId);
3800
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments`);
3801
+ Object.entries(options).forEach(([key, value]) => {
3802
+ if (value !== undefined) {
3803
+ url.searchParams.append(key, value.toString());
3804
+ }
3805
+ });
3806
+ const response = await fetch(url.toString(), {
3807
+ ...getFetchConfig(),
3808
+ });
3809
+ await handleGitLabError(response);
3810
+ const data = await response.json();
3811
+ return z.array(GitLabEnvironmentSchema).parse(data);
3812
+ }
3813
+ /**
3814
+ * Get details of a specific environment
3815
+ *
3816
+ * @param {string} projectId - The ID or URL-encoded path of the project
3817
+ * @param {number | string} environmentId - The ID of the environment
3818
+ * @returns {Promise<GitLabEnvironment>} Environment details
3819
+ */
3820
+ async function getEnvironment(projectId, environmentId) {
3821
+ projectId = decodeURIComponent(projectId);
3822
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments/${environmentId}`);
3823
+ const response = await fetch(url.toString(), {
3824
+ ...getFetchConfig(),
3825
+ });
3826
+ if (response.status === 404) {
3827
+ throw new Error(`Environment not found`);
3828
+ }
3829
+ await handleGitLabError(response);
3830
+ const data = await response.json();
3831
+ return GitLabEnvironmentSchema.parse(data);
3832
+ }
3144
3833
  /**
3145
3834
  * List all jobs in a specific pipeline
3146
3835
  *
@@ -3265,21 +3954,107 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
3265
3954
  }
3266
3955
  return fullTrace;
3267
3956
  }
3957
+ /**
3958
+ * List artifact files in a job's artifacts archive
3959
+ *
3960
+ * @param {string} projectId - The ID or URL-encoded path of the project
3961
+ * @param {string} jobId - The ID of the job
3962
+ * @param {Object} options - Options for listing artifacts
3963
+ * @returns {Promise<GitLabArtifactEntry[]>} List of artifact entries
3964
+ */
3965
+ async function listJobArtifacts(projectId, jobId, options = {}) {
3966
+ projectId = decodeURIComponent(projectId);
3967
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/artifacts/tree`);
3968
+ Object.entries(options).forEach(([key, value]) => {
3969
+ if (value !== undefined) {
3970
+ if (typeof value === "boolean") {
3971
+ url.searchParams.append(key, value ? "true" : "false");
3972
+ }
3973
+ else {
3974
+ url.searchParams.append(key, value.toString());
3975
+ }
3976
+ }
3977
+ });
3978
+ const response = await fetch(url.toString(), {
3979
+ ...getFetchConfig(),
3980
+ });
3981
+ if (response.status === 404) {
3982
+ throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
3983
+ }
3984
+ await handleGitLabError(response);
3985
+ const data = await response.json();
3986
+ return z.array(GitLabArtifactEntrySchema).parse(data);
3987
+ }
3988
+ /**
3989
+ * Download the entire artifact archive for a job and save to disk
3990
+ *
3991
+ * @param {string} projectId - The ID or URL-encoded path of the project
3992
+ * @param {string} jobId - The ID of the job
3993
+ * @param {string} localPath - Optional local directory to save the archive
3994
+ * @returns {Promise<string>} The path where the artifact archive was saved
3995
+ */
3996
+ async function downloadJobArtifacts(projectId, jobId, localPath) {
3997
+ projectId = decodeURIComponent(projectId);
3998
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3999
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts`);
4000
+ const response = await fetch(url.toString(), {
4001
+ ...getFetchConfig(),
4002
+ });
4003
+ if (response.status === 404) {
4004
+ throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
4005
+ }
4006
+ await handleGitLabError(response);
4007
+ const buffer = await response.arrayBuffer();
4008
+ const filename = `artifacts_job_${jobId}.zip`;
4009
+ const savePath = localPath ? path.join(localPath, filename) : filename;
4010
+ fs.mkdirSync(path.dirname(savePath), { recursive: true });
4011
+ fs.writeFileSync(savePath, Buffer.from(buffer));
4012
+ return savePath;
4013
+ }
4014
+ /**
4015
+ * Download a single file from a job's artifacts
4016
+ *
4017
+ * @param {string} projectId - The ID or URL-encoded path of the project
4018
+ * @param {string} jobId - The ID of the job
4019
+ * @param {string} artifactPath - Path to the file within the artifacts archive
4020
+ * @returns {Promise<string>} The file content as text
4021
+ */
4022
+ async function getJobArtifactFile(projectId, jobId, artifactPath) {
4023
+ projectId = decodeURIComponent(projectId);
4024
+ const effectiveProjectId = getEffectiveProjectId(projectId);
4025
+ const encodedArtifactPath = artifactPath
4026
+ .split("/")
4027
+ .map(segment => encodeURIComponent(segment))
4028
+ .join("/");
4029
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts/${encodedArtifactPath}`);
4030
+ const response = await fetch(url.toString(), {
4031
+ ...getFetchConfig(),
4032
+ });
4033
+ if (response.status === 404) {
4034
+ throw new Error(`Artifact file not found: ${artifactPath}`);
4035
+ }
4036
+ await handleGitLabError(response);
4037
+ return await response.text();
4038
+ }
3268
4039
  /**
3269
4040
  * Create a new pipeline
3270
4041
  *
3271
4042
  * @param {string} projectId - The ID or URL-encoded path of the project
3272
4043
  * @param {string} ref - The branch or tag to run the pipeline on
3273
4044
  * @param {Array} variables - Optional variables for the pipeline
4045
+ * @param {Record<string, string>} inputs - Optional input parameters for the pipeline
3274
4046
  * @returns {Promise<GitLabPipeline>} The created pipeline
3275
4047
  */
3276
- async function createPipeline(projectId, ref, variables) {
4048
+ async function createPipeline(projectId, ref, variables, inputs) {
3277
4049
  projectId = decodeURIComponent(projectId); // Decode project ID
3278
4050
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline`);
3279
4051
  const body = { ref };
3280
4052
  if (variables && variables.length > 0) {
3281
4053
  body.variables = variables;
3282
4054
  }
4055
+ if (inputs && Object.keys(inputs).length > 0) {
4056
+ body.inputs = inputs;
4057
+ }
3283
4058
  const response = await fetch(url.toString(), {
3284
4059
  method: "POST",
3285
4060
  headers: {
@@ -3902,6 +4677,20 @@ async function markdownUpload(projectId, filePath) {
3902
4677
  const data = await response.json();
3903
4678
  return GitLabMarkdownUploadSchema.parse(data);
3904
4679
  }
4680
+ const IMAGE_MIME_TYPES = {
4681
+ ".png": "image/png",
4682
+ ".jpg": "image/jpeg",
4683
+ ".jpeg": "image/jpeg",
4684
+ ".gif": "image/gif",
4685
+ ".webp": "image/webp",
4686
+ ".svg": "image/svg+xml",
4687
+ ".bmp": "image/bmp",
4688
+ ".ico": "image/x-icon",
4689
+ };
4690
+ function getImageMimeType(filename) {
4691
+ const ext = path.extname(filename).toLowerCase();
4692
+ return IMAGE_MIME_TYPES[ext] ?? null;
4693
+ }
3905
4694
  async function downloadAttachment(projectId, secret, filename, localPath) {
3906
4695
  const effectiveProjectId = getEffectiveProjectId(projectId);
3907
4696
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
@@ -3916,12 +4705,33 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
3916
4705
  await handleGitLabError(response);
3917
4706
  }
3918
4707
  // 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;
4708
+ const buffer = Buffer.from(await response.arrayBuffer());
4709
+ const mimeType = getImageMimeType(filename);
4710
+ // For non-image files, always save to disk.
4711
+ // For image files, only save to disk if local_path is explicitly provided.
4712
+ if (!mimeType || localPath) {
4713
+ let savePath;
4714
+ if (localPath) {
4715
+ const normalizedLocalPath = path.normalize(localPath);
4716
+ if (path.isAbsolute(normalizedLocalPath) ||
4717
+ normalizedLocalPath === ".." ||
4718
+ normalizedLocalPath.startsWith(".." + path.sep) ||
4719
+ normalizedLocalPath.includes(path.sep + ".." + path.sep)) {
4720
+ throw new Error("Invalid local_path: directory traversal is not allowed.");
4721
+ }
4722
+ savePath = path.join(normalizedLocalPath, filename);
4723
+ }
4724
+ else {
4725
+ savePath = filename;
4726
+ }
4727
+ const dir = path.dirname(savePath);
4728
+ if (!fs.existsSync(dir)) {
4729
+ fs.mkdirSync(dir, { recursive: true });
4730
+ }
4731
+ fs.writeFileSync(savePath, buffer);
4732
+ return { buffer, filename, mimeType, savedPath: savePath };
4733
+ }
4734
+ return { buffer, filename, mimeType };
3925
4735
  }
3926
4736
  /**
3927
4737
  * List all events for the currently authenticated user
@@ -4147,6 +4957,8 @@ async function handleToolCall(params) {
4147
4957
  if (GITLAB_AUTH_COOKIE_PATH) {
4148
4958
  await ensureSessionForRequest();
4149
4959
  }
4960
+ // Lazy OAuth token refresh: only validate/refresh when a tool is actually called
4961
+ await ensureValidOAuthToken();
4150
4962
  logger.info(params.name);
4151
4963
  switch (params.name) {
4152
4964
  case "execute_graphql": {
@@ -4375,8 +5187,22 @@ async function handleToolCall(params) {
4375
5187
  case "get_merge_request": {
4376
5188
  const args = GetMergeRequestSchema.parse(params.arguments);
4377
5189
  const mergeRequest = await getMergeRequest(args.project_id, args.merge_request_iid, args.source_branch);
5190
+ const deploymentSummary = await buildMergeRequestDeploymentSummary(args.project_id, mergeRequest);
5191
+ const commitAdditionSummary = await buildMergeRequestCommitAdditionSummary(args.project_id, mergeRequest);
5192
+ const approvalSummary = await buildMergeRequestApprovalSummary(args.project_id, mergeRequest.iid);
5193
+ const mergeRequestWithDeploymentSummary = {
5194
+ ...mergeRequest,
5195
+ deployment_summary: deploymentSummary,
5196
+ commit_addition_summary: commitAdditionSummary,
5197
+ approval_summary: approvalSummary,
5198
+ };
4378
5199
  return {
4379
- content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
5200
+ content: [
5201
+ {
5202
+ type: "text",
5203
+ text: JSON.stringify(mergeRequestWithDeploymentSummary, null, 2),
5204
+ },
5205
+ ],
4380
5206
  };
4381
5207
  }
4382
5208
  case "get_merge_request_diffs": {
@@ -4573,8 +5399,8 @@ async function handleToolCall(params) {
4573
5399
  }
4574
5400
  case "create_draft_note": {
4575
5401
  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);
5402
+ const { project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion } = args;
5403
+ const draftNote = await createDraftNote(project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion);
4578
5404
  return {
4579
5405
  content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }],
4580
5406
  };
@@ -4835,6 +5661,36 @@ async function handleToolCall(params) {
4835
5661
  ],
4836
5662
  };
4837
5663
  }
5664
+ case "list_deployments": {
5665
+ const args = ListDeploymentsSchema.parse(params.arguments);
5666
+ const { project_id, ...options } = args;
5667
+ const deployments = await listDeployments(project_id, options);
5668
+ return {
5669
+ content: [{ type: "text", text: JSON.stringify(deployments, null, 2) }],
5670
+ };
5671
+ }
5672
+ case "get_deployment": {
5673
+ const { project_id, deployment_id } = GetDeploymentSchema.parse(params.arguments);
5674
+ const deployment = await getDeployment(project_id, deployment_id);
5675
+ return {
5676
+ content: [{ type: "text", text: JSON.stringify(deployment, null, 2) }],
5677
+ };
5678
+ }
5679
+ case "list_environments": {
5680
+ const args = ListEnvironmentsSchema.parse(params.arguments);
5681
+ const { project_id, ...options } = args;
5682
+ const environments = await listEnvironments(project_id, options);
5683
+ return {
5684
+ content: [{ type: "text", text: JSON.stringify(environments, null, 2) }],
5685
+ };
5686
+ }
5687
+ case "get_environment": {
5688
+ const { project_id, environment_id } = GetEnvironmentSchema.parse(params.arguments);
5689
+ const environment = await getEnvironment(project_id, environment_id);
5690
+ return {
5691
+ content: [{ type: "text", text: JSON.stringify(environment, null, 2) }],
5692
+ };
5693
+ }
4838
5694
  case "list_pipeline_jobs": {
4839
5695
  const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse(params.arguments);
4840
5696
  const jobs = await listPipelineJobs(project_id, pipeline_id, options);
@@ -4884,8 +5740,8 @@ async function handleToolCall(params) {
4884
5740
  };
4885
5741
  }
4886
5742
  case "create_pipeline": {
4887
- const { project_id, ref, variables } = CreatePipelineSchema.parse(params.arguments);
4888
- const pipeline = await createPipeline(project_id, ref, variables);
5743
+ const { project_id, ref, variables, inputs } = CreatePipelineSchema.parse(params.arguments);
5744
+ const pipeline = await createPipeline(project_id, ref, variables, inputs);
4889
5745
  return {
4890
5746
  content: [
4891
5747
  {
@@ -4955,6 +5811,42 @@ async function handleToolCall(params) {
4955
5811
  ],
4956
5812
  };
4957
5813
  }
5814
+ case "list_job_artifacts": {
5815
+ const { project_id, job_id, ...options } = ListJobArtifactsSchema.parse(params.arguments);
5816
+ const artifacts = await listJobArtifacts(project_id, job_id, options);
5817
+ return {
5818
+ content: [
5819
+ {
5820
+ type: "text",
5821
+ text: JSON.stringify(artifacts, null, 2),
5822
+ },
5823
+ ],
5824
+ };
5825
+ }
5826
+ case "download_job_artifacts": {
5827
+ const { project_id, job_id, local_path } = DownloadJobArtifactsSchema.parse(params.arguments);
5828
+ const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
5829
+ return {
5830
+ content: [
5831
+ {
5832
+ type: "text",
5833
+ text: JSON.stringify({ success: true, file_path: filePath }, null, 2),
5834
+ },
5835
+ ],
5836
+ };
5837
+ }
5838
+ case "get_job_artifact_file": {
5839
+ const { project_id, job_id, artifact_path } = GetJobArtifactFileSchema.parse(params.arguments);
5840
+ const fileContent = await getJobArtifactFile(project_id, job_id, artifact_path);
5841
+ return {
5842
+ content: [
5843
+ {
5844
+ type: "text",
5845
+ text: fileContent,
5846
+ },
5847
+ ],
5848
+ };
5849
+ }
4958
5850
  case "list_merge_requests": {
4959
5851
  const args = ListMergeRequestsSchema.parse(params.arguments);
4960
5852
  const mergeRequests = await listMergeRequests(args.project_id, args);
@@ -5110,10 +6002,26 @@ async function handleToolCall(params) {
5110
6002
  }
5111
6003
  case "download_attachment": {
5112
6004
  const args = DownloadAttachmentSchema.parse(params.arguments);
5113
- const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
6005
+ const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
6006
+ if (result.mimeType && !args.local_path) {
6007
+ // Return image inline as base64 so the AI can see it
6008
+ const base64 = result.buffer.toString("base64");
6009
+ return {
6010
+ content: [
6011
+ { type: "image", data: base64, mimeType: result.mimeType },
6012
+ {
6013
+ type: "text",
6014
+ text: JSON.stringify({ filename: result.filename, mimeType: result.mimeType }, null, 2),
6015
+ },
6016
+ ],
6017
+ };
6018
+ }
5114
6019
  return {
5115
6020
  content: [
5116
- { type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) },
6021
+ {
6022
+ type: "text",
6023
+ text: JSON.stringify({ success: true, file_path: result.savedPath }, null, 2),
6024
+ },
5117
6025
  ],
5118
6026
  };
5119
6027
  }
@@ -5685,9 +6593,10 @@ async function runServer() {
5685
6593
  logger.info("Using OAuth authentication...");
5686
6594
  try {
5687
6595
  const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
5688
- OAUTH_ACCESS_TOKEN = await initializeOAuth(gitlabBaseUrl);
6596
+ const oauthResult = await initializeOAuthClient(gitlabBaseUrl);
6597
+ oauthClient = oauthResult.client;
6598
+ OAUTH_ACCESS_TOKEN = oauthResult.accessToken;
5689
6599
  logger.info("OAuth authentication successful");
5690
- // Note: Headers are automatically generated by buildAuthHeaders() using OAUTH_ACCESS_TOKEN
5691
6600
  }
5692
6601
  catch (error) {
5693
6602
  logger.error("OAuth authentication failed:", error);