@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/README.md +152 -151
- package/build/index.js +443 -25
- package/build/schemas.js +91 -0
- package/build/test/test-get-file-blame.js +145 -0
- package/build/test/test-geteffectiveprojectid.js +230 -6
- package/build/test/test-remote-downloads.js +336 -0
- package/build/test/test-toolset-filtering.js +4 -1
- package/build/tools/registry.js +43 -11
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
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
|
-
|
|
5680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|