@zereight/mcp-gitlab 2.1.13 → 2.1.15
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 +365 -17
- package/build/schemas.js +15 -1
- package/build/test/schema-tests.js +73 -3
- package/build/test/test-remote-downloads.js +336 -0
- package/build/test/test-upload-markdown.js +29 -2
- package/build/tools/registry.js +24 -9
- 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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
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
|
-
|
|
5715
|
-
|
|
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
|
@@ -2580,7 +2580,7 @@ export const GitLabProjectMemberSchema = z.object({
|
|
|
2580
2580
|
});
|
|
2581
2581
|
// Markdown upload schemas
|
|
2582
2582
|
export const GitLabMarkdownUploadSchema = z.object({
|
|
2583
|
-
id: z.coerce.number(),
|
|
2583
|
+
id: z.preprocess((val) => (val == null ? undefined : val), z.coerce.number().optional()),
|
|
2584
2584
|
alt: z.string(),
|
|
2585
2585
|
url: z.string(),
|
|
2586
2586
|
full_path: z.string(),
|
|
@@ -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"),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ts-node
|
|
2
|
-
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema } from '../schemas.js';
|
|
2
|
+
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema, GitLabMarkdownUploadSchema, } from '../schemas.js';
|
|
3
3
|
function runGetFileContentsSchemaTests() {
|
|
4
4
|
console.log('🧪 Testing GetFileContentsSchema...');
|
|
5
5
|
const cases = [
|
|
@@ -1122,6 +1122,75 @@ function runGetRepositoryTreeSchemaTests() {
|
|
|
1122
1122
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
1123
1123
|
return { passed, failed };
|
|
1124
1124
|
}
|
|
1125
|
+
function runGitLabMarkdownUploadSchemaTests() {
|
|
1126
|
+
console.log('\n=== GitLabMarkdownUpload Schema Tests ===');
|
|
1127
|
+
const idlessUpload = {
|
|
1128
|
+
alt: 'report.md',
|
|
1129
|
+
url: '/uploads/c617e74a47dfb1a6dd59d419619b725d/report.md',
|
|
1130
|
+
full_path: '/group/project/uploads/c617e74a47dfb1a6dd59d419619b725d/report.md',
|
|
1131
|
+
markdown: '[report.md](/uploads/c617e74a47dfb1a6dd59d419619b725d/report.md)',
|
|
1132
|
+
};
|
|
1133
|
+
const cases = [
|
|
1134
|
+
{
|
|
1135
|
+
name: 'schema:markdown_upload:accepts-idless-response',
|
|
1136
|
+
input: idlessUpload,
|
|
1137
|
+
expectedId: 'absent',
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
name: 'schema:markdown_upload:accepts-numeric-id',
|
|
1141
|
+
input: { ...idlessUpload, id: 42 },
|
|
1142
|
+
expectedId: 42,
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
name: 'schema:markdown_upload:coerces-string-id',
|
|
1146
|
+
input: { ...idlessUpload, id: '99' },
|
|
1147
|
+
expectedId: 99,
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
name: 'schema:markdown_upload:treats-null-id-as-absent',
|
|
1151
|
+
input: { ...idlessUpload, id: null },
|
|
1152
|
+
expectedId: 'absent',
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
name: 'schema:markdown_upload:rejects-invalid-id',
|
|
1156
|
+
input: { ...idlessUpload, id: 'not-a-number' },
|
|
1157
|
+
shouldFail: true,
|
|
1158
|
+
},
|
|
1159
|
+
];
|
|
1160
|
+
let passed = 0;
|
|
1161
|
+
let failed = 0;
|
|
1162
|
+
cases.forEach(testCase => {
|
|
1163
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
1164
|
+
const parsed = GitLabMarkdownUploadSchema.safeParse(testCase.input);
|
|
1165
|
+
if (testCase.shouldFail) {
|
|
1166
|
+
result.status = parsed.success ? 'failed' : 'passed';
|
|
1167
|
+
if (parsed.success)
|
|
1168
|
+
result.error = 'Expected schema validation to fail';
|
|
1169
|
+
}
|
|
1170
|
+
else if (!parsed.success) {
|
|
1171
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
1172
|
+
}
|
|
1173
|
+
else if (testCase.expectedId === 'absent' && parsed.data.id !== undefined) {
|
|
1174
|
+
result.error = `Expected id undefined, got ${parsed.data.id}`;
|
|
1175
|
+
}
|
|
1176
|
+
else if (typeof testCase.expectedId === 'number' && parsed.data.id !== testCase.expectedId) {
|
|
1177
|
+
result.error = `Expected id ${testCase.expectedId}, got ${parsed.data.id}`;
|
|
1178
|
+
}
|
|
1179
|
+
else {
|
|
1180
|
+
result.status = 'passed';
|
|
1181
|
+
}
|
|
1182
|
+
if (result.status === 'passed') {
|
|
1183
|
+
passed++;
|
|
1184
|
+
console.log(`✅ ${result.name}`);
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
failed++;
|
|
1188
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
1192
|
+
return { passed, failed };
|
|
1193
|
+
}
|
|
1125
1194
|
function runGitLabUserFullSchemaTests() {
|
|
1126
1195
|
console.log('🧪 Testing GitLabUserFullSchema...');
|
|
1127
1196
|
const adminResponse = {
|
|
@@ -1219,8 +1288,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
1219
1288
|
const treeItemResult = runGitLabTreeItemSchemaTests();
|
|
1220
1289
|
const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
|
|
1221
1290
|
const gitLabUserFullResult = runGitLabUserFullSchemaTests();
|
|
1222
|
-
const
|
|
1223
|
-
const
|
|
1291
|
+
const gitLabMarkdownUploadResult = runGitLabMarkdownUploadSchemaTests();
|
|
1292
|
+
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed + gitLabMarkdownUploadResult.passed;
|
|
1293
|
+
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed + gitLabMarkdownUploadResult.failed;
|
|
1224
1294
|
console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
|
|
1225
1295
|
if (totalFailed > 0) {
|
|
1226
1296
|
process.exit(1);
|
|
@@ -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,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, before, after } from 'node:test';
|
|
1
|
+
import { describe, test, before, after, beforeEach } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import fs from 'node:fs';
|
|
@@ -55,12 +55,18 @@ const MOCK_UPLOAD_RESPONSE = {
|
|
|
55
55
|
full_path: '/test-group/test-project/uploads/abc123secret/test-file.txt',
|
|
56
56
|
markdown: '[test-file.txt](/uploads/abc123secret/test-file.txt)',
|
|
57
57
|
};
|
|
58
|
+
let uploadResponse = MOCK_UPLOAD_RESPONSE;
|
|
58
59
|
describe('upload_markdown', () => {
|
|
59
60
|
let mockGitLab;
|
|
60
61
|
let env;
|
|
61
62
|
// Captured per-request state, reset before each invocation via the handler
|
|
62
63
|
let lastContentType;
|
|
63
64
|
let lastRawBody;
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
uploadResponse = MOCK_UPLOAD_RESPONSE;
|
|
67
|
+
lastContentType = undefined;
|
|
68
|
+
lastRawBody = undefined;
|
|
69
|
+
});
|
|
64
70
|
before(async () => {
|
|
65
71
|
const port = await findMockServerPort(9200);
|
|
66
72
|
mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
|
|
@@ -75,7 +81,7 @@ describe('upload_markdown', () => {
|
|
|
75
81
|
req.on('data', (chunk) => chunks.push(chunk));
|
|
76
82
|
req.on('end', () => {
|
|
77
83
|
lastRawBody = Buffer.concat(chunks).toString('binary');
|
|
78
|
-
res.status(201).json(
|
|
84
|
+
res.status(201).json(uploadResponse);
|
|
79
85
|
});
|
|
80
86
|
});
|
|
81
87
|
});
|
|
@@ -139,6 +145,27 @@ describe('upload_markdown', () => {
|
|
|
139
145
|
fs.unlinkSync(tmpFile);
|
|
140
146
|
}
|
|
141
147
|
});
|
|
148
|
+
test('accepts upload responses without id from older self-hosted GitLab', async () => {
|
|
149
|
+
const { id: _id, ...idlessUploadResponse } = MOCK_UPLOAD_RESPONSE;
|
|
150
|
+
uploadResponse = idlessUploadResponse;
|
|
151
|
+
const tmpFile = path.join(os.tmpdir(), 'mcp-upload-idless-response-test.txt');
|
|
152
|
+
fs.writeFileSync(tmpFile, 'idless response field test');
|
|
153
|
+
try {
|
|
154
|
+
const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
|
|
155
|
+
assert.ok(!raw.error, `Unexpected RPC error: ${raw.error?.message}`);
|
|
156
|
+
const text = raw.result?.content?.[0]?.text;
|
|
157
|
+
assert.ok(text, 'Result should contain a text content block');
|
|
158
|
+
const parsed = JSON.parse(text);
|
|
159
|
+
assert.strictEqual(parsed.id, undefined);
|
|
160
|
+
assert.strictEqual(parsed.markdown, MOCK_UPLOAD_RESPONSE.markdown);
|
|
161
|
+
assert.strictEqual(parsed.url, MOCK_UPLOAD_RESPONSE.url);
|
|
162
|
+
assert.strictEqual(parsed.alt, MOCK_UPLOAD_RESPONSE.alt);
|
|
163
|
+
assert.strictEqual(parsed.full_path, MOCK_UPLOAD_RESPONSE.full_path);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
fs.unlinkSync(tmpFile);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
142
169
|
test('returns an error when the file does not exist', async () => {
|
|
143
170
|
const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '/nonexistent/no-such-file.txt' }, env);
|
|
144
171
|
const hasError = typeof raw.error?.message === 'string' ||
|
package/build/tools/registry.js
CHANGED
|
@@ -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:
|
|
604
|
-
|
|
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:
|
|
714
|
-
|
|
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:
|
|
719
|
-
|
|
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:
|
|
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.
|
|
3
|
+
"version": "2.1.15",
|
|
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": [
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"changelog": "auto-changelog -p",
|
|
52
52
|
"test": "npm run test:all",
|
|
53
53
|
"test:all": "npm run build && npm run test:mock && npm run test:live",
|
|
54
|
-
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
|
|
54
|
+
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
|
|
55
55
|
"test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
|
|
56
56
|
"test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
|
|
57
57
|
"test:live": "node test/validate-api.js",
|