@zereight/mcp-gitlab 2.1.13 → 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";
@@ -32,8 +139,8 @@ GitLabDiscussionNoteSchema, // Added
32
139
  GitLabDiscussionSchema,
33
140
  // Draft Notes Schemas
34
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
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";
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",
@@ -4916,11 +5023,13 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
4916
5023
  throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
4917
5024
  }
4918
5025
  await handleGitLabError(response);
4919
- const buffer = await response.arrayBuffer();
4920
5026
  const filename = `artifacts_job_${jobId}.zip`;
4921
5027
  const savePath = localPath ? path.join(localPath, filename) : filename;
4922
5028
  fs.mkdirSync(path.dirname(savePath), { recursive: true });
4923
- 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));
4924
5033
  return savePath;
4925
5034
  }
4926
5035
  /**
@@ -5626,22 +5735,42 @@ async function listGroupIterations(groupId, options = {}) {
5626
5735
  return z.array(GroupIteration).parse(data);
5627
5736
  }
5628
5737
  /**
5629
- * 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).
5630
5743
  *
5631
5744
  * @param {string} projectId - The ID or URL-encoded path of the project
5632
- * @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)
5633
5748
  * @returns {Promise<GitLabMarkdownUpload>} The upload response
5634
5749
  */
5635
- async function markdownUpload(projectId, filePath) {
5750
+ async function markdownUpload(projectId, filePath, content, filename) {
5636
5751
  projectId = decodeURIComponent(projectId);
5637
5752
  const effectiveProjectId = getEffectiveProjectId(projectId);
5638
- // Check if file exists
5639
- if (!fs.existsSync(filePath)) {
5640
- 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");
5641
5773
  }
5642
- // Read the file
5643
- const fileBuffer = fs.readFileSync(filePath);
5644
- const fileName = path.basename(filePath);
5645
5774
  // Create form data
5646
5775
  const FormData = (await import("form-data")).default;
5647
5776
  const form = new FormData();
@@ -5687,8 +5816,6 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
5687
5816
  if (!response.ok) {
5688
5817
  await handleGitLabError(response);
5689
5818
  }
5690
- // Get the file content as buffer
5691
- const buffer = Buffer.from(await response.arrayBuffer());
5692
5819
  const mimeType = getImageMimeType(filename);
5693
5820
  // For non-image files, always save to disk.
5694
5821
  // For image files, only save to disk if local_path is explicitly provided.
@@ -5711,9 +5838,15 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
5711
5838
  if (!fs.existsSync(dir)) {
5712
5839
  fs.mkdirSync(dir, { recursive: true });
5713
5840
  }
5714
- fs.writeFileSync(savePath, buffer);
5715
- 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 };
5716
5847
  }
5848
+ // Images returned inline — buffer into memory for base64 encoding
5849
+ const buffer = Buffer.from(await response.arrayBuffer());
5717
5850
  return { buffer, filename, mimeType };
5718
5851
  }
5719
5852
  /**
@@ -7355,6 +7488,15 @@ async function handleToolCall(params) {
7355
7488
  }
7356
7489
  case "download_job_artifacts": {
7357
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
+ }
7358
7500
  const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
7359
7501
  return {
7360
7502
  content: [
@@ -7560,6 +7702,13 @@ async function handleToolCall(params) {
7560
7702
  };
7561
7703
  }
7562
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
+ }
7563
7712
  const args = MarkdownUploadSchema.parse(params.arguments);
7564
7713
  const upload = await markdownUpload(args.project_id, args.file_path);
7565
7714
  return {
@@ -7568,6 +7717,19 @@ async function handleToolCall(params) {
7568
7717
  }
7569
7718
  case "download_attachment": {
7570
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
+ }
7571
7733
  const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
7572
7734
  if (result.mimeType && !args.local_path) {
7573
7735
  // Return image inline as base64 so the AI can see it
@@ -7663,6 +7825,14 @@ async function handleToolCall(params) {
7663
7825
  }
7664
7826
  case "download_release_asset": {
7665
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
+ }
7666
7836
  const assetContent = await downloadReleaseAsset(args.project_id, args.tag_name, args.direct_asset_path);
7667
7837
  return {
7668
7838
  content: [{ type: "text", text: assetContent }],
@@ -7859,6 +8029,182 @@ async function startStdioServer() {
7859
8029
  };
7860
8030
  await serverInstance.connect(transport);
7861
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
+ }
7862
8208
  /**
7863
8209
  * Start server with traditional SSE transport
7864
8210
  */
@@ -7885,6 +8231,7 @@ async function startSSEServer() {
7885
8231
  res.status(400).send("No transport found for sessionId");
7886
8232
  }
7887
8233
  });
8234
+ registerDownloadProxy(app);
7888
8235
  app.get("/health", (_, res) => {
7889
8236
  res.status(200).json({
7890
8237
  status: "healthy",
@@ -8254,6 +8601,7 @@ async function startStreamableHTTPServer() {
8254
8601
  };
8255
8602
  // Configure Express middleware
8256
8603
  app.use(express.json());
8604
+ registerDownloadProxy(app);
8257
8605
  // MCP OAuth — mount auth router and prepare bearer-auth middleware
8258
8606
  if (GITLAB_MCP_OAUTH) {
8259
8607
  // Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP.
package/build/schemas.js CHANGED
@@ -2590,6 +2590,11 @@ export const MarkdownUploadSchema = z.object({
2590
2590
  project_id: z.string().describe("Project ID or URL-encoded path of the project"),
2591
2591
  file_path: z.string().describe("Path to the file to upload"),
2592
2592
  });
2593
+ export const MarkdownUploadRemoteSchema = z.object({
2594
+ project_id: z.string().describe("Project ID or URL-encoded path of the project"),
2595
+ content: z.string().describe("File content as base64-encoded string"),
2596
+ filename: z.string().describe("Filename for the uploaded content"),
2597
+ });
2593
2598
  export const DownloadAttachmentSchema = z.object({
2594
2599
  project_id: z.string().describe("Project ID or URL-encoded path of the project"),
2595
2600
  secret: z.string().describe("The 32-character secret of the upload"),
@@ -2599,6 +2604,11 @@ export const DownloadAttachmentSchema = z.object({
2599
2604
  .optional()
2600
2605
  .describe("Local path to save the file (optional, defaults to current directory)"),
2601
2606
  });
2607
+ export const DownloadAttachmentRemoteSchema = z.object({
2608
+ project_id: z.string().describe("Project ID or URL-encoded path of the project"),
2609
+ secret: z.string().describe("The 32-character secret of the upload"),
2610
+ filename: z.string().describe("The filename of the upload"),
2611
+ });
2602
2612
  export const GroupIteration = z.object({
2603
2613
  id: z.coerce.string(),
2604
2614
  iid: z.coerce.string(),
@@ -2943,6 +2953,10 @@ export const DownloadJobArtifactsSchema = z.object({
2943
2953
  .optional()
2944
2954
  .describe("Local directory to save the artifact archive (defaults to current directory)"),
2945
2955
  });
2956
+ export const DownloadJobArtifactsRemoteSchema = z.object({
2957
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2958
+ job_id: z.coerce.string().describe("The ID of the job"),
2959
+ });
2946
2960
  export const GetJobArtifactFileSchema = z.object({
2947
2961
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2948
2962
  job_id: z.coerce.string().describe("The ID of the job"),
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Remote Mode Download Proxy & Upload Tests
3
+ *
4
+ * Tests the /downloads/:type proxy endpoint and verifies that download/upload
5
+ * tools behave correctly in remote (StreamableHTTP) mode:
6
+ * - download_job_artifacts returns a download_url
7
+ * - download_attachment for non-image returns a download_url
8
+ * - download_attachment for image returns inline base64
9
+ * - upload_markdown with content+filename works
10
+ * - upload_markdown with file_path is rejected
11
+ */
12
+ import { describe, test, before, after } from 'node:test';
13
+ import assert from 'node:assert';
14
+ import { launchServer, TransportMode, HOST } from './utils/server-launcher.js';
15
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
16
+ const MOCK_TOKEN = 'glpat-mock-token-12345';
17
+ const TEST_PROJECT_ID = '123';
18
+ const TEST_JOB_ID = '456';
19
+ const TEST_SECRET = 'testsecret';
20
+ // Minimal 1x1 transparent PNG
21
+ const MINIMAL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
22
+ const LARGE_FILE_TOKEN = 'glpat-largefile-test-token';
23
+ const FAKE_ZIP = Buffer.from('PK\x03\x04fake-zip-content-for-testing');
24
+ const MOCK_UPLOAD_RESPONSE = {
25
+ id: 99,
26
+ alt: 'test-file.txt',
27
+ url: '/uploads/abc123secret/test-file.txt',
28
+ full_path: '/test-group/test-project/uploads/abc123secret/test-file.txt',
29
+ markdown: '[test-file.txt](/uploads/abc123secret/test-file.txt)',
30
+ };
31
+ function parseSSE(text) {
32
+ const lines = text.split('\n');
33
+ const dataLines = lines.filter(l => l.startsWith('data: '));
34
+ return dataLines.map(l => JSON.parse(l.slice(6)));
35
+ }
36
+ // --- Test suites ---
37
+ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () => {
38
+ let mockGitLab;
39
+ let server;
40
+ let serverPort;
41
+ before(async () => {
42
+ const mockPort = await findMockServerPort(9300);
43
+ mockGitLab = new MockGitLabServer({
44
+ port: mockPort,
45
+ validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN],
46
+ });
47
+ // Mock artifact download
48
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (_req, res) => {
49
+ res.set('Content-Type', 'application/zip');
50
+ res.set('Content-Disposition', `attachment; filename="artifacts_job_${TEST_JOB_ID}.zip"`);
51
+ res.send(FAKE_ZIP);
52
+ });
53
+ // Mock image attachment
54
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => {
55
+ res.set('Content-Type', 'image/png');
56
+ res.send(MINIMAL_PNG);
57
+ });
58
+ // Mock text attachment
59
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => {
60
+ res.set('Content-Type', 'text/plain');
61
+ res.send('hello document content');
62
+ });
63
+ // Mock large artifact (2MB) to verify streaming works for big files
64
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/999/artifacts`, (_req, res) => {
65
+ res.set('Content-Type', 'application/zip');
66
+ res.set('Content-Disposition', 'attachment; filename="large_artifacts.zip"');
67
+ // Send 2MB of data in chunks
68
+ const chunk = Buffer.alloc(64 * 1024, 0x42); // 64KB of 'B'
69
+ res.writeHead(200);
70
+ let sent = 0;
71
+ const total = 2 * 1024 * 1024; // 2MB
72
+ const sendChunk = () => {
73
+ while (sent < total) {
74
+ const size = Math.min(chunk.length, total - sent);
75
+ const ok = res.write(chunk.subarray(0, size));
76
+ sent += size;
77
+ if (!ok) {
78
+ res.once('drain', sendChunk);
79
+ return;
80
+ }
81
+ }
82
+ res.end();
83
+ };
84
+ sendChunk();
85
+ });
86
+ await mockGitLab.start();
87
+ serverPort = 3500;
88
+ server = await launchServer({
89
+ mode: TransportMode.STREAMABLE_HTTP,
90
+ port: serverPort,
91
+ timeout: 10_000,
92
+ env: {
93
+ STREAMABLE_HTTP: 'true',
94
+ REMOTE_AUTHORIZATION: 'true',
95
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
96
+ USE_PIPELINE: 'true',
97
+ MAX_REQUESTS_PER_MINUTE: '2',
98
+ },
99
+ });
100
+ });
101
+ after(async () => {
102
+ if (server)
103
+ server.kill();
104
+ if (mockGitLab)
105
+ await mockGitLab.stop();
106
+ });
107
+ test('returns 401 without auth headers', async () => {
108
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=${TEST_JOB_ID}`);
109
+ assert.strictEqual(res.status, 401);
110
+ const body = await res.json();
111
+ assert.ok(body.error.toLowerCase().includes('auth'));
112
+ });
113
+ test('returns 400 for missing parameters', async () => {
114
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}`, { headers: { 'Private-Token': MOCK_TOKEN } });
115
+ assert.strictEqual(res.status, 400);
116
+ const body = await res.json();
117
+ assert.ok(body.error.includes('required'));
118
+ });
119
+ test('returns 400 for unknown download types', async () => {
120
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/unknown-type?project_id=${TEST_PROJECT_ID}`, { headers: { 'Private-Token': MOCK_TOKEN } });
121
+ assert.strictEqual(res.status, 400);
122
+ const body = await res.json();
123
+ assert.ok(body.error.toLowerCase().includes('unknown'));
124
+ });
125
+ test('streams large file (2MB) without buffering issues', async () => {
126
+ // Use a dedicated token to avoid rate limit interference from other tests
127
+ const largeFileToken = 'glpat-largefile-test-token';
128
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=999`, { headers: { 'Private-Token': largeFileToken } });
129
+ assert.strictEqual(res.status, 200, 'Should stream large file successfully');
130
+ const buf = Buffer.from(await res.arrayBuffer());
131
+ const expectedSize = 2 * 1024 * 1024;
132
+ assert.strictEqual(buf.length, expectedSize, `Should receive full 2MB (got ${buf.length} bytes)`);
133
+ });
134
+ test('returns 429 after exceeding rate limit', async () => {
135
+ // Use a different token to get a fresh rate limit counter
136
+ const rateLimitToken = 'glpat-ratelimit-test-token';
137
+ let got429 = false;
138
+ for (let i = 0; i < 10; i++) {
139
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=${TEST_JOB_ID}`, { headers: { 'Private-Token': rateLimitToken } });
140
+ if (res.status === 429) {
141
+ got429 = true;
142
+ const body = await res.json();
143
+ assert.ok(body.error.toLowerCase().includes('rate limit'));
144
+ break;
145
+ }
146
+ // consume body (might be 401/403 from GitLab mock, but rate limit still increments)
147
+ await res.arrayBuffer();
148
+ }
149
+ assert.ok(got429, 'Should have received 429 within 10 requests (rate limit is 2/min)');
150
+ });
151
+ });
152
+ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000 }, () => {
153
+ let mockGitLab;
154
+ let server;
155
+ let serverPort;
156
+ let sessionId;
157
+ before(async () => {
158
+ const mockPort = await findMockServerPort(9310);
159
+ mockGitLab = new MockGitLabServer({
160
+ port: mockPort,
161
+ validTokens: [MOCK_TOKEN],
162
+ });
163
+ // Mock artifact download
164
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (_req, res) => {
165
+ res.set('Content-Type', 'application/zip');
166
+ res.set('Content-Disposition', `attachment; filename="artifacts_job_${TEST_JOB_ID}.zip"`);
167
+ res.send(FAKE_ZIP);
168
+ });
169
+ // Mock image attachment
170
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => {
171
+ res.set('Content-Type', 'image/png');
172
+ res.send(MINIMAL_PNG);
173
+ });
174
+ // Mock text attachment
175
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => {
176
+ res.set('Content-Type', 'text/plain');
177
+ res.send('hello document content');
178
+ });
179
+ // Mock upload endpoint
180
+ mockGitLab.addMockHandler('post', `/projects/${TEST_PROJECT_ID}/uploads`, (req, res) => {
181
+ res.status(201).json(MOCK_UPLOAD_RESPONSE);
182
+ });
183
+ await mockGitLab.start();
184
+ serverPort = 3510;
185
+ server = await launchServer({
186
+ mode: TransportMode.STREAMABLE_HTTP,
187
+ port: serverPort,
188
+ timeout: 10_000,
189
+ env: {
190
+ STREAMABLE_HTTP: 'true',
191
+ REMOTE_AUTHORIZATION: 'true',
192
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
193
+ USE_PIPELINE: 'true',
194
+ },
195
+ });
196
+ // Initialize MCP session
197
+ const initRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
198
+ method: 'POST',
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ 'Accept': 'application/json, text/event-stream',
202
+ 'Private-Token': MOCK_TOKEN,
203
+ },
204
+ body: JSON.stringify({
205
+ jsonrpc: '2.0',
206
+ id: 1,
207
+ method: 'initialize',
208
+ params: {
209
+ protocolVersion: '2025-03-26',
210
+ capabilities: {},
211
+ clientInfo: { name: 'test-remote-downloads', version: '1.0' },
212
+ },
213
+ }),
214
+ });
215
+ assert.strictEqual(initRes.status, 200, 'Initialize should succeed');
216
+ sessionId = initRes.headers.get('mcp-session-id');
217
+ assert.ok(sessionId, 'Should receive a session ID');
218
+ });
219
+ after(async () => {
220
+ if (server)
221
+ server.kill();
222
+ if (mockGitLab)
223
+ await mockGitLab.stop();
224
+ });
225
+ async function callTool(id, name, args) {
226
+ const res = await fetch(`http://${HOST}:${serverPort}/mcp`, {
227
+ method: 'POST',
228
+ headers: {
229
+ 'Content-Type': 'application/json',
230
+ 'Accept': 'application/json, text/event-stream',
231
+ 'Private-Token': MOCK_TOKEN,
232
+ 'mcp-session-id': sessionId,
233
+ },
234
+ body: JSON.stringify({
235
+ jsonrpc: '2.0',
236
+ id,
237
+ method: 'tools/call',
238
+ params: { name, arguments: args },
239
+ }),
240
+ });
241
+ assert.strictEqual(res.status, 200, `Tool call ${name} should return 200`);
242
+ const text = await res.text();
243
+ const responses = parseSSE(text);
244
+ const result = responses.find(r => r.id === id);
245
+ assert.ok(result, `Should find response with id=${id} in SSE stream`);
246
+ return result;
247
+ }
248
+ test('download_job_artifacts returns download_url with embedded auth token', async () => {
249
+ const result = await callTool(10, 'download_job_artifacts', {
250
+ project_id: TEST_PROJECT_ID,
251
+ job_id: TEST_JOB_ID,
252
+ });
253
+ assert.ok(result.result, 'Should have a result');
254
+ const content = result.result.content;
255
+ assert.ok(content && content.length > 0, 'Should have content');
256
+ const textBlock = content.find(c => c.type === 'text');
257
+ assert.ok(textBlock?.text, 'Should have text content');
258
+ const parsed = JSON.parse(textBlock.text);
259
+ assert.ok(parsed.download_url, 'Should contain download_url');
260
+ assert.ok(parsed.download_url.includes('/downloads/job-artifacts'), 'URL should point to proxy endpoint');
261
+ assert.ok(parsed.download_url.includes(`project_id=${TEST_PROJECT_ID}`), 'URL should include project_id');
262
+ assert.ok(parsed.download_url.includes(`job_id=${TEST_JOB_ID}`), 'URL should include job_id');
263
+ assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
264
+ assert.ok(parsed.filename.includes('.zip'), 'Should have zip filename');
265
+ // The URL should work WITHOUT auth headers (token is embedded)
266
+ const downloadRes = await fetch(parsed.download_url);
267
+ assert.strictEqual(downloadRes.status, 200, 'Download URL should work without auth headers');
268
+ const buf = Buffer.from(await downloadRes.arrayBuffer());
269
+ assert.ok(buf.length > 0, 'Downloaded content should not be empty');
270
+ assert.ok(buf.includes(Buffer.from('PK')), 'Should contain zip magic bytes');
271
+ });
272
+ test('download_attachment for non-image returns download_url', async () => {
273
+ const result = await callTool(11, 'download_attachment', {
274
+ project_id: TEST_PROJECT_ID,
275
+ secret: TEST_SECRET,
276
+ filename: 'document.txt',
277
+ });
278
+ assert.ok(result.result, 'Should have a result');
279
+ const content = result.result.content;
280
+ assert.ok(content && content.length > 0, 'Should have content');
281
+ const textBlock = content.find(c => c.type === 'text');
282
+ assert.ok(textBlock?.text, 'Should have text content');
283
+ const parsed = JSON.parse(textBlock.text);
284
+ assert.ok(parsed.download_url, 'Should contain download_url');
285
+ assert.ok(parsed.download_url.includes('/downloads/attachment'), 'URL should point to attachment proxy');
286
+ assert.ok(parsed.download_url.includes(`project_id=${TEST_PROJECT_ID}`), 'URL should include project_id');
287
+ assert.ok(parsed.download_url.includes(`secret=${TEST_SECRET}`), 'URL should include secret');
288
+ assert.ok(parsed.download_url.includes('filename=document.txt'), 'URL should include filename');
289
+ assert.strictEqual(parsed.filename, 'document.txt', 'Should echo the filename');
290
+ });
291
+ test('download_attachment for image returns base64 inline', async () => {
292
+ const result = await callTool(12, 'download_attachment', {
293
+ project_id: TEST_PROJECT_ID,
294
+ secret: TEST_SECRET,
295
+ filename: 'image.png',
296
+ });
297
+ assert.ok(result.result, 'Should have a result');
298
+ const content = result.result.content;
299
+ assert.ok(content && content.length > 0, 'Should have content');
300
+ const imageBlock = content.find(c => c.type === 'image');
301
+ assert.ok(imageBlock, 'Should contain an image content block');
302
+ assert.strictEqual(imageBlock.mimeType, 'image/png', 'Should have image/png mime type');
303
+ assert.ok(imageBlock.data && imageBlock.data.length > 0, 'Should have non-empty base64 data');
304
+ });
305
+ test('upload_markdown with content+filename works', async () => {
306
+ const fileContent = Buffer.from('hello upload test').toString('base64');
307
+ const result = await callTool(13, 'upload_markdown', {
308
+ project_id: TEST_PROJECT_ID,
309
+ content: fileContent,
310
+ filename: 'test-file.txt',
311
+ });
312
+ assert.ok(result.result, 'Should have a result');
313
+ assert.ok(!result.error, `Should not have error: ${result.error?.message}`);
314
+ const content = result.result.content;
315
+ assert.ok(content && content.length > 0, 'Should have content');
316
+ const textBlock = content.find(c => c.type === 'text');
317
+ assert.ok(textBlock?.text, 'Should have text content');
318
+ const parsed = JSON.parse(textBlock.text);
319
+ assert.ok(parsed.markdown, 'Should have markdown field');
320
+ assert.ok(parsed.url, 'Should have url field');
321
+ });
322
+ test('upload_markdown with file_path is rejected in remote mode', async () => {
323
+ const result = await callTool(14, 'upload_markdown', {
324
+ project_id: TEST_PROJECT_ID,
325
+ file_path: '/tmp/some-file.txt',
326
+ });
327
+ // In remote mode the server uses MarkdownUploadRemoteSchema which
328
+ // requires content+filename and does not accept file_path. This should
329
+ // result in a validation error.
330
+ const hasError = !!result.error ||
331
+ (result.result?.content?.some(c => c.type === 'text' && c.text && (c.text.toLowerCase().includes('error') ||
332
+ c.text.toLowerCase().includes('required') ||
333
+ c.text.toLowerCase().includes('invalid'))));
334
+ assert.ok(hasError, 'Should reject file_path in remote mode (needs content+filename)');
335
+ });
336
+ });
@@ -1,7 +1,8 @@
1
1
  import { zodToJsonSchema } from "zod-to-json-schema";
2
2
  import { toJSONSchema } from "../utils/schema.js";
3
- import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, } from "../config.js";
4
- import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, } from "../schemas.js";
3
+ import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, SSE, STREAMABLE_HTTP, } from "../config.js";
4
+ import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, } from "../schemas.js";
5
+ const IS_REMOTE = SSE || STREAMABLE_HTTP;
5
6
  // Define all available tools
6
7
  export const allTools = [
7
8
  {
@@ -600,8 +601,12 @@ export const allTools = [
600
601
  },
601
602
  {
602
603
  name: "download_job_artifacts",
603
- description: "Download job artifact archive (zip) to a local path",
604
- inputSchema: toJSONSchema(DownloadJobArtifactsSchema),
604
+ description: IS_REMOTE
605
+ ? "Get a download URL for a job's artifact archive (zip)"
606
+ : "Download job artifact archive (zip) and save to a local path",
607
+ inputSchema: IS_REMOTE
608
+ ? toJSONSchema(DownloadJobArtifactsRemoteSchema)
609
+ : toJSONSchema(DownloadJobArtifactsSchema),
605
610
  },
606
611
  {
607
612
  name: "get_job_artifact_file",
@@ -710,13 +715,21 @@ export const allTools = [
710
715
  },
711
716
  {
712
717
  name: "upload_markdown",
713
- description: "Upload a file for use in markdown content",
714
- inputSchema: toJSONSchema(MarkdownUploadSchema),
718
+ description: IS_REMOTE
719
+ ? "Upload base64-encoded content for use in markdown"
720
+ : "Upload a file for use in markdown content",
721
+ inputSchema: IS_REMOTE
722
+ ? toJSONSchema(MarkdownUploadRemoteSchema)
723
+ : toJSONSchema(MarkdownUploadSchema),
715
724
  },
716
725
  {
717
726
  name: "download_attachment",
718
- description: "Download an uploaded file from a project (images returned as base64; use local_path to save to disk)",
719
- inputSchema: toJSONSchema(DownloadAttachmentSchema),
727
+ description: IS_REMOTE
728
+ ? "Download an uploaded file from a project (images returned inline as base64, other files returned as download URL)"
729
+ : "Download an uploaded file from a project (images returned as base64; use local_path to save to disk)",
730
+ inputSchema: IS_REMOTE
731
+ ? toJSONSchema(DownloadAttachmentRemoteSchema)
732
+ : toJSONSchema(DownloadAttachmentSchema),
720
733
  },
721
734
  {
722
735
  name: "health_check",
@@ -765,7 +778,9 @@ export const allTools = [
765
778
  },
766
779
  {
767
780
  name: "download_release_asset",
768
- description: "Download a release asset file by direct asset path",
781
+ description: IS_REMOTE
782
+ ? "Get a download URL for a release asset file"
783
+ : "Download a release asset file by direct asset path",
769
784
  inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
770
785
  },
771
786
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.13",
3
+ "version": "2.1.14",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [