@zereight/mcp-gitlab 2.1.12 → 2.1.14

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
@@ -1,5 +1,111 @@
1
1
  #!/usr/bin/env node
2
2
  import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, } from "./config.js";
3
+ /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
4
+ const IS_REMOTE = SSE || STREAMABLE_HTTP;
5
+ /**
6
+ * Encryption key for download tokens. When DOWNLOAD_TOKEN_SECRET is set
7
+ * (recommended for HA deployments behind a load balancer) the key is
8
+ * derived from that value so all replicas share the same key. Otherwise
9
+ * a random key is generated per process (tokens are not portable across
10
+ * restarts or replicas).
11
+ */
12
+ const DOWNLOAD_TOKEN_KEY = (() => {
13
+ const secret = process.env.DOWNLOAD_TOKEN_SECRET;
14
+ if (secret) {
15
+ return createHash("sha256").update(secret).digest();
16
+ }
17
+ return randomBytes(32);
18
+ })();
19
+ /** Download token TTL in seconds (default 5 minutes). */
20
+ const DOWNLOAD_TOKEN_TTL = Number.parseInt(process.env.DOWNLOAD_TOKEN_TTL || "300", 10);
21
+ function createDownloadToken(header, token, apiUrl, resource) {
22
+ const iv = randomBytes(12);
23
+ const cipher = createCipheriv("aes-256-gcm", DOWNLOAD_TOKEN_KEY, iv);
24
+ const payload = JSON.stringify({
25
+ h: header,
26
+ t: token,
27
+ e: Math.floor(Date.now() / 1000) + DOWNLOAD_TOKEN_TTL,
28
+ ...(apiUrl ? { u: apiUrl } : {}),
29
+ ...(resource ? { r: resource.type, p: resource.params } : {}),
30
+ });
31
+ const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
32
+ const tag = cipher.getAuthTag();
33
+ return Buffer.concat([iv, tag, encrypted]).toString("base64url");
34
+ }
35
+ function decryptDownloadToken(tokenStr) {
36
+ try {
37
+ const buf = Buffer.from(tokenStr, "base64url");
38
+ if (buf.length < 29)
39
+ return null;
40
+ const iv = buf.subarray(0, 12);
41
+ const tag = buf.subarray(12, 28);
42
+ const encrypted = buf.subarray(28);
43
+ const decipher = createDecipheriv("aes-256-gcm", DOWNLOAD_TOKEN_KEY, iv);
44
+ decipher.setAuthTag(tag);
45
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
46
+ const payload = JSON.parse(decrypted.toString("utf8"));
47
+ // Check TTL
48
+ if (payload.e && Math.floor(Date.now() / 1000) > payload.e) {
49
+ return null; // expired
50
+ }
51
+ return {
52
+ header: payload.h,
53
+ token: payload.t,
54
+ ...(payload.u ? { apiUrl: payload.u } : {}),
55
+ ...(payload.r ? { resourceType: payload.r, resourceParams: payload.p } : {}),
56
+ };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Build a URL pointing to the download proxy endpoint.
64
+ * Embeds an encrypted auth token (and API URL for dynamic routing)
65
+ * from the current session so the URL works standalone.
66
+ */
67
+ function buildDownloadUrl(type, params) {
68
+ const base = MCP_SERVER_URL || `http://${HOST}:${PORT}`;
69
+ const baseUrl = new URL(base);
70
+ // Preserve any path prefix (e.g. /gitlab-mcp) from the base URL
71
+ const basePath = baseUrl.pathname.replace(/\/+$/, "");
72
+ const url = new URL(`${basePath}/downloads/${type}`, baseUrl.origin);
73
+ for (const [key, value] of Object.entries(params)) {
74
+ url.searchParams.set(key, value);
75
+ }
76
+ // Embed auth (and apiUrl when dynamic routing is active) from current session or static config
77
+ // Token is bound to the specific resource (type + params) to prevent URL tampering
78
+ const resource = { type, params };
79
+ const ctx = sessionAuthStore.getStore();
80
+ if (ctx?.token) {
81
+ const headerValue = ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token;
82
+ const apiUrl = ENABLE_DYNAMIC_API_URL && ctx.apiUrl !== GITLAB_API_URL ? ctx.apiUrl : undefined;
83
+ url.searchParams.set("_token", createDownloadToken(ctx.header, headerValue, apiUrl, resource));
84
+ }
85
+ else {
86
+ // Fallback for SSE/static-token mode (no session auth context)
87
+ // Priority matches buildAuthHeaders: OAuth > PAT > JOB token
88
+ const staticToken = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN || GITLAB_JOB_TOKEN;
89
+ if (staticToken) {
90
+ let header;
91
+ let headerValue;
92
+ if (GITLAB_JOB_TOKEN && !GITLAB_PERSONAL_ACCESS_TOKEN && !OAUTH_ACCESS_TOKEN) {
93
+ header = "JOB-TOKEN";
94
+ headerValue = String(staticToken);
95
+ }
96
+ else if (IS_OLD) {
97
+ header = "Private-Token";
98
+ headerValue = String(staticToken);
99
+ }
100
+ else {
101
+ header = "Authorization";
102
+ headerValue = `Bearer ${staticToken}`;
103
+ }
104
+ url.searchParams.set("_token", createDownloadToken(header, headerValue, undefined, resource));
105
+ }
106
+ }
107
+ return url.toString();
108
+ }
3
109
  import { loadKeyMaterialFromEnv, looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./stateless/index.js";
4
110
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
111
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
@@ -10,6 +116,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
10
116
  import express from "express";
11
117
  import fetchCookie from "fetch-cookie";
12
118
  import fs from "node:fs";
119
+ import { pipeline as streamPipeline } from "node:stream/promises";
13
120
  import os from "node:os";
14
121
  import nodeFetch from "node-fetch";
15
122
  import path, { dirname } from "node:path";
@@ -26,14 +133,14 @@ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middlew
26
133
  import { GitLabClientPool } from "./gitlab-client-pool.js";
27
134
  import { allTools, readOnlyTools, destructiveTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
28
135
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
29
- CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
136
+ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
30
137
  // Discussion Schemas
31
138
  GitLabDiscussionNoteSchema, // Added
32
139
  GitLabDiscussionSchema,
33
140
  // Draft Notes Schemas
34
- GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
35
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
36
- import { randomUUID } from "node:crypto";
141
+ GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
142
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
143
+ import { randomUUID, createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
37
144
  import { pino } from "pino";
38
145
  const logger = pino({
39
146
  level: process.env.LOG_LEVEL || "info",
@@ -937,6 +1044,14 @@ function getEffectiveProjectId(projectId) {
937
1044
  }
938
1045
  throw new Error("No project ID provided and GITLAB_PROJECT_ID is not set");
939
1046
  }
1047
+ function rejectIfProjectScopedDeployment(toolName) {
1048
+ if (GITLAB_PROJECT_ID) {
1049
+ throw new Error(`${toolName} is not allowed when GITLAB_PROJECT_ID is set (server is locked to a single project)`);
1050
+ }
1051
+ if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
1052
+ throw new Error(`${toolName} is not allowed when GITLAB_ALLOWED_PROJECT_IDS is set (server access is restricted to configured projects)`);
1053
+ }
1054
+ }
940
1055
  /**
941
1056
  * Create a fork of a GitLab project
942
1057
  * 프로젝트 포크 생성 (Create a project fork)
@@ -4908,11 +5023,13 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
4908
5023
  throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
4909
5024
  }
4910
5025
  await handleGitLabError(response);
4911
- const buffer = await response.arrayBuffer();
4912
5026
  const filename = `artifacts_job_${jobId}.zip`;
4913
5027
  const savePath = localPath ? path.join(localPath, filename) : filename;
4914
5028
  fs.mkdirSync(path.dirname(savePath), { recursive: true });
4915
- fs.writeFileSync(savePath, Buffer.from(buffer));
5029
+ if (!response.body) {
5030
+ throw new Error("No response body from GitLab");
5031
+ }
5032
+ await streamPipeline(response.body, fs.createWriteStream(savePath));
4916
5033
  return savePath;
4917
5034
  }
4918
5035
  /**
@@ -5416,6 +5533,33 @@ async function getCommitDiff(projectId, sha, full_diff) {
5416
5533
  }
5417
5534
  return allDiffs;
5418
5535
  }
5536
+ /**
5537
+ * Get blame for a file at a specific ref.
5538
+ *
5539
+ * Wraps GitLab REST endpoint
5540
+ * GET /projects/:id/repository/files/:file_path/blame?ref=
5541
+ * Returns an array of entries; each entry has `lines` (the source lines covered)
5542
+ * and `commit` (the commit that last changed those lines: id, author, message, ...).
5543
+ *
5544
+ * @param {string} projectId - Project ID or URL-encoded path
5545
+ * @param {Omit<GetFileBlameOptions,"project_id">} options - file_path, ref, optional range_start/range_end
5546
+ * @returns {Promise<GitLabBlameEntry[]>} Blame entries in source order.
5547
+ */
5548
+ async function getFileBlame(projectId, options) {
5549
+ projectId = decodeURIComponent(projectId);
5550
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodeURIComponent(options.file_path)}/blame`);
5551
+ url.searchParams.append("ref", options.ref);
5552
+ if (options.range_start !== undefined && options.range_end !== undefined) {
5553
+ url.searchParams.append("range[start]", options.range_start.toString());
5554
+ url.searchParams.append("range[end]", options.range_end.toString());
5555
+ }
5556
+ const response = await fetch(url.toString(), {
5557
+ ...getFetchConfig(),
5558
+ });
5559
+ await handleGitLabError(response);
5560
+ const data = await response.json();
5561
+ return z.array(GitLabBlameEntrySchema).parse(data);
5562
+ }
5419
5563
  /**
5420
5564
  * List statuses for a commit.
5421
5565
  *
@@ -5591,22 +5735,42 @@ async function listGroupIterations(groupId, options = {}) {
5591
5735
  return z.array(GroupIteration).parse(data);
5592
5736
  }
5593
5737
  /**
5594
- * Upload a file to a GitLab project for use in markdown content
5738
+ * Upload a file to a GitLab project for use in markdown content.
5739
+ *
5740
+ * Accepts either a local file path or inline base64-encoded content
5741
+ * (the latter is useful for remote deployments where the client cannot
5742
+ * write to the server's filesystem).
5595
5743
  *
5596
5744
  * @param {string} projectId - The ID or URL-encoded path of the project
5597
- * @param {string} filePath - Path to the local file to upload
5745
+ * @param {string} filePath - Path to the local file to upload (optional if content provided)
5746
+ * @param {string} content - Base64-encoded file content (optional if filePath provided)
5747
+ * @param {string} filename - Filename for the uploaded content (required when content provided)
5598
5748
  * @returns {Promise<GitLabMarkdownUpload>} The upload response
5599
5749
  */
5600
- async function markdownUpload(projectId, filePath) {
5750
+ async function markdownUpload(projectId, filePath, content, filename) {
5601
5751
  projectId = decodeURIComponent(projectId);
5602
5752
  const effectiveProjectId = getEffectiveProjectId(projectId);
5603
- // Check if file exists
5604
- if (!fs.existsSync(filePath)) {
5605
- throw new Error(`File not found: ${filePath}`);
5753
+ let fileBuffer;
5754
+ let fileName;
5755
+ if (IS_REMOTE && filePath) {
5756
+ throw new Error("file_path cannot be used in remote mode. Provide base64-encoded content and filename instead.");
5757
+ }
5758
+ if (content) {
5759
+ // Inline content mode (remote deployments)
5760
+ fileBuffer = Buffer.from(content, "base64");
5761
+ fileName = filename || "upload";
5762
+ }
5763
+ else if (filePath) {
5764
+ // Local file mode
5765
+ if (!fs.existsSync(filePath)) {
5766
+ throw new Error(`File not found: ${filePath}`);
5767
+ }
5768
+ fileBuffer = fs.readFileSync(filePath);
5769
+ fileName = path.basename(filePath);
5770
+ }
5771
+ else {
5772
+ throw new Error("Either file_path or content must be provided");
5606
5773
  }
5607
- // Read the file
5608
- const fileBuffer = fs.readFileSync(filePath);
5609
- const fileName = path.basename(filePath);
5610
5774
  // Create form data
5611
5775
  const FormData = (await import("form-data")).default;
5612
5776
  const form = new FormData();
@@ -5652,8 +5816,6 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
5652
5816
  if (!response.ok) {
5653
5817
  await handleGitLabError(response);
5654
5818
  }
5655
- // Get the file content as buffer
5656
- const buffer = Buffer.from(await response.arrayBuffer());
5657
5819
  const mimeType = getImageMimeType(filename);
5658
5820
  // For non-image files, always save to disk.
5659
5821
  // For image files, only save to disk if local_path is explicitly provided.
@@ -5676,9 +5838,15 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
5676
5838
  if (!fs.existsSync(dir)) {
5677
5839
  fs.mkdirSync(dir, { recursive: true });
5678
5840
  }
5679
- fs.writeFileSync(savePath, buffer);
5680
- return { buffer, filename, mimeType, savedPath: savePath };
5841
+ // Stream directly to disk instead of buffering in memory
5842
+ if (!response.body) {
5843
+ throw new Error("No response body from GitLab");
5844
+ }
5845
+ await streamPipeline(response.body, fs.createWriteStream(savePath));
5846
+ return { buffer: Buffer.alloc(0), filename, mimeType, savedPath: savePath };
5681
5847
  }
5848
+ // Images returned inline — buffer into memory for base64 encoding
5849
+ const buffer = Buffer.from(await response.arrayBuffer());
5682
5850
  return { buffer, filename, mimeType };
5683
5851
  }
5684
5852
  /**
@@ -5959,6 +6127,10 @@ async function handleToolCall(params) {
5959
6127
  delete args.work_item_iid;
5960
6128
  }
5961
6129
  }
6130
+ // Centralized read-only guard: reject write tools even if client bypasses list_tools filtering
6131
+ if (GITLAB_READ_ONLY_MODE && !readOnlyTools.has(params.name)) {
6132
+ throw new Error(`${params.name} is not allowed in read-only mode`);
6133
+ }
5962
6134
  logger.info({ tool: params.name, event: "tool_call_start" }, `tool_call_start: ${params.name}`);
5963
6135
  switch (params.name) {
5964
6136
  case "execute_graphql": {
@@ -6009,9 +6181,7 @@ async function handleToolCall(params) {
6009
6181
  }
6010
6182
  }
6011
6183
  case "fork_repository": {
6012
- if (GITLAB_PROJECT_ID) {
6013
- throw new Error("Direct project ID is set. So fork_repository is not allowed");
6014
- }
6184
+ rejectIfProjectScopedDeployment("fork_repository");
6015
6185
  const forkArgs = ForkRepositorySchema.parse(params.arguments);
6016
6186
  try {
6017
6187
  const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace);
@@ -6110,15 +6280,40 @@ async function handleToolCall(params) {
6110
6280
  };
6111
6281
  }
6112
6282
  case "create_repository": {
6113
- if (GITLAB_PROJECT_ID) {
6114
- throw new Error("Direct project ID is set. So fork_repository is not allowed");
6115
- }
6283
+ rejectIfProjectScopedDeployment("create_repository");
6116
6284
  const args = CreateRepositorySchema.parse(params.arguments);
6117
6285
  const repository = await createRepository(args);
6118
6286
  return {
6119
6287
  content: [{ type: "text", text: JSON.stringify(repository, null, 2) }],
6120
6288
  };
6121
6289
  }
6290
+ case "create_group": {
6291
+ rejectIfProjectScopedDeployment("create_group");
6292
+ const args = CreateGroupSchema.parse(params.arguments);
6293
+ const url = new URL(`${getEffectiveApiUrl()}/groups`);
6294
+ const body = {
6295
+ name: args.name,
6296
+ path: args.path,
6297
+ };
6298
+ if (args.description)
6299
+ body.description = args.description;
6300
+ if (args.visibility)
6301
+ body.visibility = args.visibility;
6302
+ if (args.parent_id)
6303
+ body.parent_id = args.parent_id;
6304
+ const response = await fetch(url.toString(), {
6305
+ ...getFetchConfig(),
6306
+ method: "POST",
6307
+ headers: { ...getFetchConfig().headers, "Content-Type": "application/json" },
6308
+ body: JSON.stringify(body),
6309
+ });
6310
+ await handleGitLabError(response);
6311
+ const data = await response.json();
6312
+ const group = GitLabGroupSchema.parse(data);
6313
+ return {
6314
+ content: [{ type: "text", text: JSON.stringify(group, null, 2) }],
6315
+ };
6316
+ }
6122
6317
  case "get_file_contents": {
6123
6318
  const args = GetFileContentsSchema.parse(params.arguments);
6124
6319
  const contents = await getFileContents(args.project_id, args.file_path, args.ref);
@@ -7293,6 +7488,15 @@ async function handleToolCall(params) {
7293
7488
  }
7294
7489
  case "download_job_artifacts": {
7295
7490
  const { project_id, job_id, local_path } = DownloadJobArtifactsSchema.parse(params.arguments);
7491
+ if (IS_REMOTE) {
7492
+ if (local_path) {
7493
+ throw new Error("local_path cannot be used in remote mode — use the returned download_url instead");
7494
+ }
7495
+ const downloadUrl = buildDownloadUrl("job-artifacts", { project_id, job_id });
7496
+ return {
7497
+ content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: `artifacts_job_${job_id}.zip` }, null, 2) }],
7498
+ };
7499
+ }
7296
7500
  const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
7297
7501
  return {
7298
7502
  content: [
@@ -7466,6 +7670,14 @@ async function handleToolCall(params) {
7466
7670
  content: [{ type: "text", text: JSON.stringify(diff, null, 2) }],
7467
7671
  };
7468
7672
  }
7673
+ case "get_file_blame": {
7674
+ const args = GetFileBlameSchema.parse(params.arguments);
7675
+ const { project_id, ...options } = args;
7676
+ const blame = await getFileBlame(project_id, options);
7677
+ return {
7678
+ content: [{ type: "text", text: JSON.stringify(blame, null, 2) }],
7679
+ };
7680
+ }
7469
7681
  case "list_commit_statuses": {
7470
7682
  const args = ListCommitStatusesSchema.parse(params.arguments);
7471
7683
  const { project_id, sha, ...options } = args;
@@ -7490,6 +7702,13 @@ async function handleToolCall(params) {
7490
7702
  };
7491
7703
  }
7492
7704
  case "upload_markdown": {
7705
+ if (IS_REMOTE) {
7706
+ const args = MarkdownUploadRemoteSchema.parse(params.arguments);
7707
+ const upload = await markdownUpload(args.project_id, undefined, args.content, args.filename);
7708
+ return {
7709
+ content: [{ type: "text", text: JSON.stringify(upload, null, 2) }],
7710
+ };
7711
+ }
7493
7712
  const args = MarkdownUploadSchema.parse(params.arguments);
7494
7713
  const upload = await markdownUpload(args.project_id, args.file_path);
7495
7714
  return {
@@ -7498,6 +7717,19 @@ async function handleToolCall(params) {
7498
7717
  }
7499
7718
  case "download_attachment": {
7500
7719
  const args = DownloadAttachmentSchema.parse(params.arguments);
7720
+ if (IS_REMOTE && args.local_path) {
7721
+ throw new Error("local_path cannot be used in remote mode — use the returned download_url instead");
7722
+ }
7723
+ // In remote mode for non-image files, return proxy URL
7724
+ const mimeType = getImageMimeType(args.filename);
7725
+ if (IS_REMOTE && !mimeType) {
7726
+ const downloadUrl = buildDownloadUrl("attachment", {
7727
+ project_id: args.project_id, secret: args.secret, filename: args.filename,
7728
+ });
7729
+ return {
7730
+ content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: args.filename }, null, 2) }],
7731
+ };
7732
+ }
7501
7733
  const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
7502
7734
  if (result.mimeType && !args.local_path) {
7503
7735
  // Return image inline as base64 so the AI can see it
@@ -7593,6 +7825,14 @@ async function handleToolCall(params) {
7593
7825
  }
7594
7826
  case "download_release_asset": {
7595
7827
  const args = DownloadReleaseAssetSchema.parse(params.arguments);
7828
+ if (IS_REMOTE) {
7829
+ const downloadUrl = buildDownloadUrl("release-asset", {
7830
+ project_id: args.project_id, tag_name: args.tag_name, direct_asset_path: args.direct_asset_path,
7831
+ });
7832
+ return {
7833
+ content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: args.direct_asset_path.split("/").pop() || args.direct_asset_path }, null, 2) }],
7834
+ };
7835
+ }
7596
7836
  const assetContent = await downloadReleaseAsset(args.project_id, args.tag_name, args.direct_asset_path);
7597
7837
  return {
7598
7838
  content: [{ type: "text", text: assetContent }],
@@ -7789,6 +8029,182 @@ async function startStdioServer() {
7789
8029
  };
7790
8030
  await serverInstance.connect(transport);
7791
8031
  }
8032
+ /**
8033
+ * Register the /downloads/:type proxy endpoint on an Express app.
8034
+ * Streams GitLab API responses directly to the client. Auth is read from
8035
+ * an encrypted `_token` query param (self-contained URL) or from request headers.
8036
+ */
8037
+ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(process.env.MAX_REQUESTS_PER_MINUTE || "60", 10)) {
8038
+ const downloadRateLimits = {};
8039
+ let lastEviction = Date.now();
8040
+ const checkDownloadRateLimit = (token) => {
8041
+ const now = Date.now();
8042
+ // Evict expired entries every 60s to prevent unbounded growth
8043
+ if (now - lastEviction > 60000) {
8044
+ for (const key of Object.keys(downloadRateLimits)) {
8045
+ if (now > downloadRateLimits[key].resetAt)
8046
+ delete downloadRateLimits[key];
8047
+ }
8048
+ lastEviction = now;
8049
+ }
8050
+ const entry = downloadRateLimits[token];
8051
+ if (!entry || now > entry.resetAt) {
8052
+ downloadRateLimits[token] = { count: 1, resetAt: now + 60000 };
8053
+ return true;
8054
+ }
8055
+ if (entry.count >= maxRequestsPerMinute)
8056
+ return false;
8057
+ entry.count++;
8058
+ return true;
8059
+ };
8060
+ app.get("/downloads/:type", async (req, res) => {
8061
+ const headers = { Accept: "application/octet-stream" };
8062
+ let rateLimitKey;
8063
+ // Try embedded encrypted token first (self-contained URL), then headers
8064
+ const encryptedToken = req.query._token;
8065
+ let tokenApiUrl;
8066
+ if (encryptedToken) {
8067
+ const decrypted = decryptDownloadToken(encryptedToken);
8068
+ if (!decrypted) {
8069
+ res.status(401).json({ error: "Invalid or expired download token" });
8070
+ return;
8071
+ }
8072
+ // Verify resource binding — token must match the requested type and params
8073
+ if (decrypted.resourceType || decrypted.resourceParams) {
8074
+ const { type } = req.params;
8075
+ const queryParams = {};
8076
+ for (const [k, v] of Object.entries(req.query)) {
8077
+ if (k !== "_token" && typeof v === "string")
8078
+ queryParams[k] = v;
8079
+ }
8080
+ if (decrypted.resourceType !== type ||
8081
+ JSON.stringify(decrypted.resourceParams) !== JSON.stringify(queryParams)) {
8082
+ res.status(403).json({ error: "Download token does not match the requested resource" });
8083
+ return;
8084
+ }
8085
+ }
8086
+ headers[decrypted.header] = decrypted.token;
8087
+ rateLimitKey = decrypted.token;
8088
+ tokenApiUrl = decrypted.apiUrl;
8089
+ }
8090
+ else {
8091
+ const privateToken = req.headers["private-token"];
8092
+ const jobToken = req.headers["job-token"];
8093
+ const authHeader = req.headers["authorization"];
8094
+ if (privateToken) {
8095
+ headers["Private-Token"] = privateToken;
8096
+ rateLimitKey = privateToken;
8097
+ }
8098
+ else if (jobToken) {
8099
+ headers["JOB-TOKEN"] = jobToken;
8100
+ rateLimitKey = jobToken;
8101
+ }
8102
+ else if (authHeader) {
8103
+ headers["Authorization"] = authHeader;
8104
+ rateLimitKey = authHeader;
8105
+ }
8106
+ else {
8107
+ res.status(401).json({ error: "Authentication required" });
8108
+ return;
8109
+ }
8110
+ }
8111
+ if (!checkDownloadRateLimit(rateLimitKey)) {
8112
+ res.status(429).json({ error: "Rate limit exceeded" });
8113
+ return;
8114
+ }
8115
+ // API URL: prefer token-embedded URL, then X-GitLab-API-URL header, then default
8116
+ let apiUrl = tokenApiUrl || GITLAB_API_URL;
8117
+ if (!tokenApiUrl) {
8118
+ const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
8119
+ if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
8120
+ try {
8121
+ new URL(dynamicApiUrl);
8122
+ apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
8123
+ }
8124
+ catch {
8125
+ res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
8126
+ return;
8127
+ }
8128
+ }
8129
+ }
8130
+ const { type } = req.params;
8131
+ let gitlabUrl;
8132
+ try {
8133
+ switch (type) {
8134
+ case "job-artifacts": {
8135
+ const { project_id, job_id } = req.query;
8136
+ if (!project_id || !job_id) {
8137
+ res.status(400).json({ error: "project_id and job_id are required" });
8138
+ return;
8139
+ }
8140
+ const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
8141
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${job_id}/artifacts`;
8142
+ break;
8143
+ }
8144
+ case "attachment": {
8145
+ const { project_id, secret, filename } = req.query;
8146
+ if (!project_id || !secret || !filename) {
8147
+ res.status(400).json({ error: "project_id, secret, and filename are required" });
8148
+ return;
8149
+ }
8150
+ const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
8151
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`;
8152
+ break;
8153
+ }
8154
+ case "release-asset": {
8155
+ const { project_id, tag_name, direct_asset_path } = req.query;
8156
+ if (!project_id || !tag_name || !direct_asset_path) {
8157
+ res.status(400).json({ error: "project_id, tag_name, and direct_asset_path are required" });
8158
+ return;
8159
+ }
8160
+ const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
8161
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${direct_asset_path}`;
8162
+ break;
8163
+ }
8164
+ default:
8165
+ res.status(400).json({ error: `Unknown download type: ${type}` });
8166
+ return;
8167
+ }
8168
+ }
8169
+ catch (e) {
8170
+ // getEffectiveProjectId throws on access-denied
8171
+ const message = e instanceof Error ? e.message : "Invalid parameters";
8172
+ res.status(403).json({ error: message });
8173
+ return;
8174
+ }
8175
+ try {
8176
+ const agent = clientPool.getAgentFunctionForUrl(apiUrl);
8177
+ const gitlabResponse = await nodeFetch(gitlabUrl, { headers, agent });
8178
+ if (!gitlabResponse.ok) {
8179
+ res.status(gitlabResponse.status).json({
8180
+ error: `GitLab API error: ${gitlabResponse.status} ${gitlabResponse.statusText}`,
8181
+ });
8182
+ return;
8183
+ }
8184
+ const contentType = gitlabResponse.headers.get("content-type");
8185
+ const contentDisposition = gitlabResponse.headers.get("content-disposition");
8186
+ const contentLength = gitlabResponse.headers.get("content-length");
8187
+ if (contentType)
8188
+ res.setHeader("Content-Type", contentType);
8189
+ if (contentDisposition)
8190
+ res.setHeader("Content-Disposition", contentDisposition);
8191
+ if (contentLength)
8192
+ res.setHeader("Content-Length", contentLength);
8193
+ if (gitlabResponse.body) {
8194
+ gitlabResponse.body.pipe(res);
8195
+ }
8196
+ else {
8197
+ res.status(502).json({ error: "No response body from GitLab" });
8198
+ }
8199
+ }
8200
+ catch (error) {
8201
+ logger.error("Download proxy error:", error);
8202
+ if (!res.headersSent) {
8203
+ res.status(502).json({ error: "Failed to proxy download from GitLab" });
8204
+ }
8205
+ }
8206
+ });
8207
+ }
7792
8208
  /**
7793
8209
  * Start server with traditional SSE transport
7794
8210
  */
@@ -7815,6 +8231,7 @@ async function startSSEServer() {
7815
8231
  res.status(400).send("No transport found for sessionId");
7816
8232
  }
7817
8233
  });
8234
+ registerDownloadProxy(app);
7818
8235
  app.get("/health", (_, res) => {
7819
8236
  res.status(200).json({
7820
8237
  status: "healthy",
@@ -8184,6 +8601,7 @@ async function startStreamableHTTPServer() {
8184
8601
  };
8185
8602
  // Configure Express middleware
8186
8603
  app.use(express.json());
8604
+ registerDownloadProxy(app);
8187
8605
  // MCP OAuth — mount auth router and prepare bearer-auth middleware
8188
8606
  if (GITLAB_MCP_OAUTH) {
8189
8607
  // Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP.