@zereight/mcp-gitlab 2.0.22 → 2.0.24
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 +36 -5
- package/build/index.js +500 -198
- package/build/schemas.js +100 -12
- package/build/test/test-list-project-members.js +132 -0
- package/build/test/test-merge-request-approvals.js +187 -0
- package/build/test/test-mr-diffs-filter.js +132 -0
- package/build/test/utils/mock-gitlab-server.js +54 -0
- package/package.json +6 -6
- package/build/test/readonly-mcp-tests.js +0 -381
package/build/index.js
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Parse CLI arguments
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const cliArgs = {};
|
|
5
|
+
for (let i = 0; i < args.length; i++) {
|
|
6
|
+
const arg = args[i];
|
|
7
|
+
if (arg.startsWith('--')) {
|
|
8
|
+
const [key, value] = arg.slice(2).split('=');
|
|
9
|
+
if (value) {
|
|
10
|
+
cliArgs[key] = value;
|
|
11
|
+
}
|
|
12
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
13
|
+
cliArgs[key] = args[++i];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function getConfig(cliKey, envKey, defaultValue) {
|
|
18
|
+
return cliArgs[cliKey] || process.env[envKey] || defaultValue;
|
|
19
|
+
}
|
|
2
20
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
21
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
4
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
23
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
24
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
-
import { AsyncLocalStorage } from "async_hooks";
|
|
25
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
26
|
import express from "express";
|
|
9
27
|
import fetchCookie from "fetch-cookie";
|
|
10
|
-
import fs from "fs";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import os from "node:os";
|
|
11
30
|
import { HttpProxyAgent } from "http-proxy-agent";
|
|
12
31
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
13
32
|
import nodeFetch from "node-fetch";
|
|
14
|
-
import path, { dirname } from "path";
|
|
33
|
+
import path, { dirname } from "node:path";
|
|
15
34
|
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
16
35
|
import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
17
|
-
import { fileURLToPath } from "url";
|
|
36
|
+
import { fileURLToPath, URL } from "node:url";
|
|
18
37
|
import { z } from "zod";
|
|
19
38
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
20
39
|
import { initializeOAuth } from "./oauth.js";
|
|
21
40
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
22
41
|
// Add type imports for proxy agents
|
|
23
|
-
import { Agent } from "http";
|
|
24
|
-
import { Agent as HttpsAgent } from "https";
|
|
25
|
-
import { URL } from "url";
|
|
42
|
+
import { Agent } from "node:http";
|
|
43
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
26
44
|
import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
|
|
27
45
|
CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, DeleteDraftNoteSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema,
|
|
28
46
|
// pipeline job schemas
|
|
@@ -32,8 +50,8 @@ GitLabDiscussionNoteSchema, // Added
|
|
|
32
50
|
GitLabDiscussionSchema,
|
|
33
51
|
// Draft Notes Schemas
|
|
34
52
|
GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
35
|
-
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema } from "./schemas.js";
|
|
36
|
-
import { randomUUID } from "crypto";
|
|
53
|
+
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, } from "./schemas.js";
|
|
54
|
+
import { randomUUID } from "node:crypto";
|
|
37
55
|
import { pino } from "pino";
|
|
38
56
|
const logger = pino({
|
|
39
57
|
level: process.env.LOG_LEVEL || "info",
|
|
@@ -68,8 +86,8 @@ try {
|
|
|
68
86
|
SERVER_VERSION = packageJson.version || SERVER_VERSION;
|
|
69
87
|
}
|
|
70
88
|
}
|
|
71
|
-
catch
|
|
72
|
-
//
|
|
89
|
+
catch {
|
|
90
|
+
// Intentionally ignored: version read failure is non-critical
|
|
73
91
|
}
|
|
74
92
|
const server = new Server({
|
|
75
93
|
name: "better-gitlab-mcp-server",
|
|
@@ -87,9 +105,9 @@ function validateConfiguration() {
|
|
|
87
105
|
// Validate SESSION_TIMEOUT_SECONDS
|
|
88
106
|
const timeoutStr = process.env.SESSION_TIMEOUT_SECONDS;
|
|
89
107
|
if (timeoutStr) {
|
|
90
|
-
const timeout = parseInt(timeoutStr);
|
|
108
|
+
const timeout = Number.parseInt(timeoutStr, 10);
|
|
91
109
|
// Allow values >=1 for testing purposes, but recommend 60-86400 for production
|
|
92
|
-
if (isNaN(timeout) || timeout < 1 || timeout > 86400) {
|
|
110
|
+
if (Number.isNaN(timeout) || timeout < 1 || timeout > 86400) {
|
|
93
111
|
errors.push(`SESSION_TIMEOUT_SECONDS must be between 1 and 86400 seconds, got: ${timeoutStr}`);
|
|
94
112
|
}
|
|
95
113
|
if (timeout < 60) {
|
|
@@ -99,80 +117,85 @@ function validateConfiguration() {
|
|
|
99
117
|
// Validate MAX_SESSIONS
|
|
100
118
|
const maxSessionsStr = process.env.MAX_SESSIONS;
|
|
101
119
|
if (maxSessionsStr) {
|
|
102
|
-
const maxSessions = parseInt(maxSessionsStr);
|
|
103
|
-
if (isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
|
|
120
|
+
const maxSessions = Number.parseInt(maxSessionsStr, 10);
|
|
121
|
+
if (Number.isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
|
|
104
122
|
errors.push(`MAX_SESSIONS must be between 1 and 10000, got: ${maxSessionsStr}`);
|
|
105
123
|
}
|
|
106
124
|
}
|
|
107
125
|
// Validate MAX_REQUESTS_PER_MINUTE
|
|
108
126
|
const maxReqStr = process.env.MAX_REQUESTS_PER_MINUTE;
|
|
109
127
|
if (maxReqStr) {
|
|
110
|
-
const maxReq = parseInt(maxReqStr);
|
|
111
|
-
if (isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
|
|
128
|
+
const maxReq = Number.parseInt(maxReqStr, 10);
|
|
129
|
+
if (Number.isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
|
|
112
130
|
errors.push(`MAX_REQUESTS_PER_MINUTE must be between 1 and 1000, got: ${maxReqStr}`);
|
|
113
131
|
}
|
|
114
132
|
}
|
|
115
133
|
// Validate PORT
|
|
116
|
-
const portStr =
|
|
134
|
+
const portStr = getConfig('port', 'PORT');
|
|
117
135
|
if (portStr) {
|
|
118
|
-
const port = parseInt(portStr);
|
|
119
|
-
if (isNaN(port) || port < 1 || port > 65535) {
|
|
136
|
+
const port = Number.parseInt(portStr, 10);
|
|
137
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
120
138
|
errors.push(`PORT must be between 1 and 65535, got: ${portStr}`);
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
141
|
// Validate GITLAB_API_URL format
|
|
124
|
-
const apiUrls =
|
|
142
|
+
const apiUrls = getConfig('api-url', 'GITLAB_API_URL')?.split(",") || [];
|
|
125
143
|
if (apiUrls.length > 0) {
|
|
126
144
|
for (const url of apiUrls) {
|
|
127
145
|
try {
|
|
128
146
|
new URL(url.trim());
|
|
129
147
|
}
|
|
130
|
-
catch
|
|
148
|
+
catch {
|
|
131
149
|
errors.push(`GITLAB_API_URL contains an invalid URL: ${url.trim()}`);
|
|
132
150
|
}
|
|
133
151
|
}
|
|
134
152
|
}
|
|
135
153
|
// Validate auth configuration
|
|
136
|
-
const remoteAuth =
|
|
137
|
-
const useOAuth =
|
|
138
|
-
const hasToken = !!
|
|
139
|
-
const hasCookie = !!
|
|
154
|
+
const remoteAuth = getConfig('remote-auth', 'REMOTE_AUTHORIZATION') === "true";
|
|
155
|
+
const useOAuth = getConfig('use-oauth', 'GITLAB_USE_OAUTH') === "true";
|
|
156
|
+
const hasToken = !!getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
|
|
157
|
+
const hasCookie = !!getConfig('cookie-path', 'GITLAB_AUTH_COOKIE_PATH');
|
|
140
158
|
if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
|
|
141
|
-
errors.push('Either
|
|
159
|
+
errors.push('Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)');
|
|
142
160
|
}
|
|
143
|
-
|
|
144
|
-
|
|
161
|
+
const enableDynamicApiUrl = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
|
|
162
|
+
if (enableDynamicApiUrl && !remoteAuth) {
|
|
163
|
+
errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
|
|
145
164
|
}
|
|
146
165
|
if (errors.length > 0) {
|
|
147
|
-
logger.error(
|
|
166
|
+
logger.error("Configuration validation failed:");
|
|
148
167
|
errors.forEach(err => logger.error(` - ${err}`));
|
|
149
168
|
process.exit(1);
|
|
150
169
|
}
|
|
151
|
-
logger.info(
|
|
170
|
+
logger.info("Configuration validation passed");
|
|
152
171
|
}
|
|
153
|
-
const GITLAB_PERSONAL_ACCESS_TOKEN =
|
|
172
|
+
const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
|
|
154
173
|
let OAUTH_ACCESS_TOKEN = null;
|
|
155
|
-
const GITLAB_AUTH_COOKIE_PATH =
|
|
156
|
-
const USE_OAUTH =
|
|
157
|
-
const IS_OLD =
|
|
158
|
-
const GITLAB_READ_ONLY_MODE =
|
|
159
|
-
const GITLAB_DENIED_TOOLS_REGEX =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const
|
|
174
|
+
const GITLAB_AUTH_COOKIE_PATH = getConfig('cookie-path', 'GITLAB_AUTH_COOKIE_PATH');
|
|
175
|
+
const USE_OAUTH = getConfig('use-oauth', 'GITLAB_USE_OAUTH') === "true";
|
|
176
|
+
const IS_OLD = getConfig('is-old', 'GITLAB_IS_OLD') === "true";
|
|
177
|
+
const GITLAB_READ_ONLY_MODE = getConfig('read-only', 'GITLAB_READ_ONLY_MODE') === "true";
|
|
178
|
+
const GITLAB_DENIED_TOOLS_REGEX = getConfig('denied-tools-regex', 'GITLAB_DENIED_TOOLS_REGEX')
|
|
179
|
+
? new RegExp(getConfig('denied-tools-regex', 'GITLAB_DENIED_TOOLS_REGEX'))
|
|
180
|
+
: undefined;
|
|
181
|
+
const USE_GITLAB_WIKI = getConfig('use-wiki', 'USE_GITLAB_WIKI') === "true";
|
|
182
|
+
const USE_MILESTONE = getConfig('use-milestone', 'USE_MILESTONE') === "true";
|
|
183
|
+
const USE_PIPELINE = getConfig('use-pipeline', 'USE_PIPELINE') === "true";
|
|
184
|
+
const SSE = getConfig('sse', 'SSE') === "true";
|
|
185
|
+
const STREAMABLE_HTTP = getConfig('streamable-http', 'STREAMABLE_HTTP') === "true";
|
|
186
|
+
const REMOTE_AUTHORIZATION = getConfig('remote-auth', 'REMOTE_AUTHORIZATION') === "true";
|
|
187
|
+
const ENABLE_DYNAMIC_API_URL = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
|
|
188
|
+
const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig('session-timeout', 'SESSION_TIMEOUT_SECONDS', '3600'), 10);
|
|
189
|
+
const HOST = getConfig('host', 'HOST') || '127.0.0.1';
|
|
190
|
+
const PORT = Number.parseInt(getConfig('port', 'PORT', '3002'), 10);
|
|
170
191
|
// Add proxy configuration
|
|
171
|
-
const HTTP_PROXY =
|
|
172
|
-
const HTTPS_PROXY =
|
|
173
|
-
const NODE_TLS_REJECT_UNAUTHORIZED =
|
|
174
|
-
const GITLAB_CA_CERT_PATH =
|
|
175
|
-
const GITLAB_POOL_MAX_SIZE =
|
|
192
|
+
const HTTP_PROXY = getConfig('http-proxy', 'HTTP_PROXY');
|
|
193
|
+
const HTTPS_PROXY = getConfig('https-proxy', 'HTTPS_PROXY');
|
|
194
|
+
const NODE_TLS_REJECT_UNAUTHORIZED = getConfig('tls-reject-unauthorized', 'NODE_TLS_REJECT_UNAUTHORIZED');
|
|
195
|
+
const GITLAB_CA_CERT_PATH = getConfig('ca-cert-path', 'GITLAB_CA_CERT_PATH');
|
|
196
|
+
const GITLAB_POOL_MAX_SIZE = getConfig('pool-max-size', 'GITLAB_POOL_MAX_SIZE')
|
|
197
|
+
? Number.parseInt(getConfig('pool-max-size', 'GITLAB_POOL_MAX_SIZE'), 10)
|
|
198
|
+
: 100;
|
|
176
199
|
let sslOptions = undefined;
|
|
177
200
|
if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
|
|
178
201
|
sslOptions = { rejectUnauthorized: false };
|
|
@@ -204,7 +227,9 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
|
|
|
204
227
|
httpAgent = httpAgent || new Agent();
|
|
205
228
|
// Initialize the client pool for managing multiple GitLab instances
|
|
206
229
|
const clientPool = new GitLabClientPool({
|
|
207
|
-
apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
|
|
230
|
+
apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
|
|
231
|
+
.split(",")
|
|
232
|
+
.map(normalizeGitLabApiUrl),
|
|
208
233
|
httpProxy: HTTP_PROXY,
|
|
209
234
|
httpsProxy: HTTPS_PROXY,
|
|
210
235
|
rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
|
|
@@ -212,73 +237,113 @@ const clientPool = new GitLabClientPool({
|
|
|
212
237
|
poolMaxSize: GITLAB_POOL_MAX_SIZE,
|
|
213
238
|
});
|
|
214
239
|
// Create cookie jar with clean Netscape file parsing
|
|
215
|
-
|
|
216
|
-
|
|
240
|
+
// Resolve cookie path once using os.homedir() for cross-platform support
|
|
241
|
+
const resolvedCookiePath = GITLAB_AUTH_COOKIE_PATH
|
|
242
|
+
? GITLAB_AUTH_COOKIE_PATH.startsWith("~/")
|
|
243
|
+
? path.join(os.homedir(), GITLAB_AUTH_COOKIE_PATH.slice(2))
|
|
244
|
+
: GITLAB_AUTH_COOKIE_PATH
|
|
245
|
+
: null;
|
|
246
|
+
const createCookieJar = async () => {
|
|
247
|
+
if (!resolvedCookiePath)
|
|
217
248
|
return null;
|
|
249
|
+
let cookieContent;
|
|
218
250
|
try {
|
|
219
|
-
|
|
220
|
-
? path.join(process.env.HOME || "", GITLAB_AUTH_COOKIE_PATH.slice(2))
|
|
221
|
-
: GITLAB_AUTH_COOKIE_PATH;
|
|
222
|
-
const jar = new CookieJar();
|
|
223
|
-
const cookieContent = fs.readFileSync(cookiePath, "utf8");
|
|
224
|
-
cookieContent.split("\n").forEach(line => {
|
|
225
|
-
// Handle #HttpOnly_ prefix
|
|
226
|
-
if (line.startsWith("#HttpOnly_")) {
|
|
227
|
-
line = line.slice(10);
|
|
228
|
-
}
|
|
229
|
-
// Skip comments and empty lines
|
|
230
|
-
if (line.startsWith("#") || !line.trim()) {
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
// Parse Netscape format: domain, flag, path, secure, expires, name, value
|
|
234
|
-
const parts = line.split("\t");
|
|
235
|
-
if (parts.length >= 7) {
|
|
236
|
-
const [domain, , path, secure, expires, name, value] = parts;
|
|
237
|
-
// Build cookie string in standard format
|
|
238
|
-
const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`;
|
|
239
|
-
// Use tough-cookie's parse function for robust parsing
|
|
240
|
-
const cookie = parseCookie(cookieStr);
|
|
241
|
-
if (cookie) {
|
|
242
|
-
const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
|
|
243
|
-
jar.setCookieSync(cookie, url);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
return jar;
|
|
251
|
+
cookieContent = await fs.promises.readFile(resolvedCookiePath, "utf8");
|
|
248
252
|
}
|
|
249
253
|
catch (error) {
|
|
250
|
-
logger.error("
|
|
254
|
+
logger.error({ error, path: resolvedCookiePath }, "Failed to read cookie file");
|
|
251
255
|
return null;
|
|
252
256
|
}
|
|
257
|
+
const jar = new CookieJar();
|
|
258
|
+
for (let line of cookieContent.split("\n")) {
|
|
259
|
+
// Handle #HttpOnly_ prefix
|
|
260
|
+
if (line.startsWith("#HttpOnly_")) {
|
|
261
|
+
line = line.slice(10);
|
|
262
|
+
}
|
|
263
|
+
// Skip comments and empty lines
|
|
264
|
+
if (line.startsWith("#") || !line.trim()) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// Parse Netscape format: domain, flag, path, secure, expires, name, value
|
|
268
|
+
const parts = line.split("\t");
|
|
269
|
+
if (parts.length >= 7) {
|
|
270
|
+
const [domain, , cookiePath, secure, expires, name, value] = parts;
|
|
271
|
+
// Build cookie string in standard format
|
|
272
|
+
const secureFlag = secure === "TRUE" ? "; Secure" : "";
|
|
273
|
+
const expiresFlag = expires === "0"
|
|
274
|
+
? ""
|
|
275
|
+
: `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
|
|
276
|
+
const cookieStr = `${name}=${value}; Domain=${domain}; Path=${cookiePath}${secureFlag}${expiresFlag}`;
|
|
277
|
+
// Use tough-cookie's parse function for robust parsing
|
|
278
|
+
const cookie = parseCookie(cookieStr);
|
|
279
|
+
if (cookie) {
|
|
280
|
+
const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
|
|
281
|
+
jar.setCookieSync(cookie, url);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return jar;
|
|
253
286
|
};
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
287
|
+
// Cookie jar and fetch - reloaded when cookie file changes
|
|
288
|
+
let cookieJar = null;
|
|
289
|
+
let fetch = nodeFetch;
|
|
290
|
+
let lastCookieMtime = 0;
|
|
291
|
+
let cookieReloadLock = null; // Mutex to prevent parallel reloads
|
|
292
|
+
// Auth proxies may redirect and set cookies on the first request. We make a throwaway
|
|
293
|
+
// request so subsequent requests have the correct cookies. Reset when cookies reload.
|
|
294
|
+
let initialSessionRequestMade = false;
|
|
295
|
+
// Cookie jar is loaded on first request via reloadCookiesIfChanged (lastCookieMtime=0 triggers load)
|
|
296
|
+
async function reloadCookiesIfChanged() {
|
|
297
|
+
if (!resolvedCookiePath)
|
|
260
298
|
return;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// Check if we already have GitLab session cookies
|
|
265
|
-
const gitlabCookies = cookieJar.getCookiesSync(baseUrl);
|
|
266
|
-
const hasSessionCookie = gitlabCookies.some(cookie => cookie.key === "_gitlab_session" || cookie.key === "remember_user_token");
|
|
267
|
-
if (!hasSessionCookie) {
|
|
299
|
+
if (cookieReloadLock)
|
|
300
|
+
return cookieReloadLock;
|
|
301
|
+
cookieReloadLock = (async () => {
|
|
268
302
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
303
|
+
const mtime = (await fs.promises.stat(resolvedCookiePath)).mtimeMs;
|
|
304
|
+
if (mtime !== lastCookieMtime) {
|
|
305
|
+
logger.info({ oldMtime: lastCookieMtime, newMtime: mtime }, lastCookieMtime === 0 ? "Loading cookie file" : "Cookie file changed, reloading");
|
|
306
|
+
lastCookieMtime = mtime;
|
|
307
|
+
const newJar = await createCookieJar();
|
|
308
|
+
cookieJar = newJar;
|
|
309
|
+
fetch = newJar ? fetchCookie(nodeFetch, newJar) : nodeFetch;
|
|
310
|
+
initialSessionRequestMade = false;
|
|
311
|
+
}
|
|
278
312
|
}
|
|
279
|
-
catch
|
|
280
|
-
//
|
|
313
|
+
catch {
|
|
314
|
+
// File deleted or inaccessible - clear cached cookies
|
|
315
|
+
if (cookieJar) {
|
|
316
|
+
logger.info("Cookie file removed, clearing cached cookies");
|
|
317
|
+
cookieJar = null;
|
|
318
|
+
fetch = nodeFetch;
|
|
319
|
+
lastCookieMtime = 0;
|
|
320
|
+
initialSessionRequestMade = false;
|
|
321
|
+
}
|
|
281
322
|
}
|
|
323
|
+
})();
|
|
324
|
+
try {
|
|
325
|
+
await cookieReloadLock;
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
cookieReloadLock = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function ensureSessionForRequest() {
|
|
332
|
+
if (!resolvedCookiePath)
|
|
333
|
+
return;
|
|
334
|
+
await reloadCookiesIfChanged();
|
|
335
|
+
if (!cookieJar || initialSessionRequestMade)
|
|
336
|
+
return;
|
|
337
|
+
try {
|
|
338
|
+
const response = await fetch(`${getEffectiveApiUrl()}/user`, {
|
|
339
|
+
...getFetchConfig(),
|
|
340
|
+
redirect: "follow",
|
|
341
|
+
});
|
|
342
|
+
// 401 means auth failed but the request completed - cookies were still exchanged
|
|
343
|
+
initialSessionRequestMade = response.ok || response.status === 401;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
logger.debug("Session warmup request failed, will retry on next request");
|
|
282
347
|
}
|
|
283
348
|
}
|
|
284
349
|
const sessionAuthStore = new AsyncLocalStorage();
|
|
@@ -299,9 +364,9 @@ function buildAuthHeaders() {
|
|
|
299
364
|
if (REMOTE_AUTHORIZATION) {
|
|
300
365
|
const ctx = sessionAuthStore.getStore();
|
|
301
366
|
logger.debug({ context: ctx }, "buildAuthHeaders: session context");
|
|
302
|
-
if (ctx
|
|
367
|
+
if (ctx?.token) {
|
|
303
368
|
return {
|
|
304
|
-
[ctx.header]: ctx.header ===
|
|
369
|
+
[ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
|
|
305
370
|
};
|
|
306
371
|
}
|
|
307
372
|
return {}; // No auth headers if no session context
|
|
@@ -309,7 +374,7 @@ function buildAuthHeaders() {
|
|
|
309
374
|
// Standard mode: prioritize OAuth token, then fall back to environment token
|
|
310
375
|
const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
311
376
|
if (IS_OLD && token) {
|
|
312
|
-
return {
|
|
377
|
+
return { "Private-Token": String(token) };
|
|
313
378
|
}
|
|
314
379
|
if (token) {
|
|
315
380
|
return { Authorization: `Bearer ${token}` };
|
|
@@ -324,7 +389,7 @@ function buildAuthHeaders() {
|
|
|
324
389
|
function getEffectiveApiUrl() {
|
|
325
390
|
if (ENABLE_DYNAMIC_API_URL) {
|
|
326
391
|
const ctx = sessionAuthStore.getStore();
|
|
327
|
-
if (ctx
|
|
392
|
+
if (ctx?.apiUrl) {
|
|
328
393
|
return ctx.apiUrl;
|
|
329
394
|
}
|
|
330
395
|
logger.warn({ ctx }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
|
|
@@ -349,7 +414,46 @@ const getFetchConfig = () => {
|
|
|
349
414
|
agent: agent,
|
|
350
415
|
};
|
|
351
416
|
};
|
|
352
|
-
const toJSONSchema = (schema) =>
|
|
417
|
+
const toJSONSchema = (schema) => {
|
|
418
|
+
const jsonSchema = zodToJsonSchema(schema, { $refStrategy: 'none' });
|
|
419
|
+
// Post-process to fix nullable/optional fields that should truly be optional
|
|
420
|
+
function fixNullableOptional(obj) {
|
|
421
|
+
if (obj && typeof obj === 'object') {
|
|
422
|
+
// If this object has properties, process them
|
|
423
|
+
if (obj.properties) {
|
|
424
|
+
const requiredSet = new Set(obj.required || []);
|
|
425
|
+
Object.keys(obj.properties).forEach(key => {
|
|
426
|
+
const prop = obj.properties[key];
|
|
427
|
+
// Handle fields that can be null or omitted
|
|
428
|
+
// If a property has type: ["object", "null"] or anyOf with null, it should not be required
|
|
429
|
+
if (prop.anyOf && prop.anyOf.some((t) => t.type === 'null')) {
|
|
430
|
+
requiredSet.delete(key);
|
|
431
|
+
}
|
|
432
|
+
else if (Array.isArray(prop.type) && prop.type.includes('null')) {
|
|
433
|
+
requiredSet.delete(key);
|
|
434
|
+
}
|
|
435
|
+
// Recursively process nested objects
|
|
436
|
+
obj.properties[key] = fixNullableOptional(prop);
|
|
437
|
+
});
|
|
438
|
+
// Normalize the required array after processing all properties
|
|
439
|
+
if (requiredSet.size > 0) {
|
|
440
|
+
obj.required = Array.from(requiredSet);
|
|
441
|
+
}
|
|
442
|
+
else if (Object.prototype.hasOwnProperty.call(obj, 'required')) {
|
|
443
|
+
delete obj.required;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// Process anyOf/allOf/oneOf
|
|
447
|
+
['anyOf', 'allOf', 'oneOf'].forEach(combiner => {
|
|
448
|
+
if (obj[combiner]) {
|
|
449
|
+
obj[combiner] = obj[combiner].map(fixNullableOptional);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return obj;
|
|
454
|
+
}
|
|
455
|
+
return fixNullableOptional(jsonSchema);
|
|
456
|
+
};
|
|
353
457
|
// Define all available tools
|
|
354
458
|
const allTools = [
|
|
355
459
|
{
|
|
@@ -357,6 +461,21 @@ const allTools = [
|
|
|
357
461
|
description: "Merge a merge request in a GitLab project",
|
|
358
462
|
inputSchema: toJSONSchema(MergeMergeRequestSchema),
|
|
359
463
|
},
|
|
464
|
+
{
|
|
465
|
+
name: "approve_merge_request",
|
|
466
|
+
description: "Approve a merge request. Requires appropriate permissions.",
|
|
467
|
+
inputSchema: toJSONSchema(ApproveMergeRequestSchema),
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: "unapprove_merge_request",
|
|
471
|
+
description: "Unapprove a previously approved merge request. Requires appropriate permissions.",
|
|
472
|
+
inputSchema: toJSONSchema(UnapproveMergeRequestSchema),
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
name: "get_merge_request_approval_state",
|
|
476
|
+
description: "Get the approval state of a merge request including approval rules and who has approved",
|
|
477
|
+
inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
|
|
478
|
+
},
|
|
360
479
|
{
|
|
361
480
|
name: "execute_graphql",
|
|
362
481
|
description: "Execute a GitLab GraphQL query",
|
|
@@ -422,6 +541,16 @@ const allTools = [
|
|
|
422
541
|
description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
|
|
423
542
|
inputSchema: toJSONSchema(ListMergeRequestDiffsSchema),
|
|
424
543
|
},
|
|
544
|
+
{
|
|
545
|
+
name: "list_merge_request_versions",
|
|
546
|
+
description: "List all versions of a merge request",
|
|
547
|
+
inputSchema: toJSONSchema(ListMergeRequestVersionsSchema),
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: "get_merge_request_version",
|
|
551
|
+
description: "Get a specific version of a merge request",
|
|
552
|
+
inputSchema: toJSONSchema(GetMergeRequestVersionSchema),
|
|
553
|
+
},
|
|
425
554
|
{
|
|
426
555
|
name: "get_branch_diffs",
|
|
427
556
|
description: "Get the changes/diffs between two branches or commits in a GitLab project",
|
|
@@ -443,7 +572,7 @@ const allTools = [
|
|
|
443
572
|
inputSchema: toJSONSchema(CreateMergeRequestThreadSchema),
|
|
444
573
|
},
|
|
445
574
|
{
|
|
446
|
-
name:
|
|
575
|
+
name: "resolve_merge_request_thread",
|
|
447
576
|
description: "Resolve a thread on a merge request",
|
|
448
577
|
inputSchema: toJSONSchema(ResolveMergeRequestThreadSchema),
|
|
449
578
|
},
|
|
@@ -483,7 +612,7 @@ const allTools = [
|
|
|
483
612
|
inputSchema: toJSONSchema(GetMergeRequestNoteSchema),
|
|
484
613
|
},
|
|
485
614
|
{
|
|
486
|
-
name:
|
|
615
|
+
name: "get_merge_request_notes",
|
|
487
616
|
description: "List notes for a merge request",
|
|
488
617
|
inputSchema: toJSONSchema(GetMergeRequestNotesSchema),
|
|
489
618
|
},
|
|
@@ -869,12 +998,14 @@ const allTools = [
|
|
|
869
998
|
},
|
|
870
999
|
];
|
|
871
1000
|
// Define which tools are read-only
|
|
872
|
-
const readOnlyTools = [
|
|
1001
|
+
const readOnlyTools = new Set([
|
|
873
1002
|
"search_repositories",
|
|
874
1003
|
"execute_graphql",
|
|
875
1004
|
"get_file_contents",
|
|
876
1005
|
"get_merge_request",
|
|
877
1006
|
"get_merge_request_diffs",
|
|
1007
|
+
"list_merge_request_versions",
|
|
1008
|
+
"get_merge_request_version",
|
|
878
1009
|
"get_branch_diffs",
|
|
879
1010
|
"mr_discussions",
|
|
880
1011
|
"list_issues",
|
|
@@ -919,18 +1050,19 @@ const readOnlyTools = [
|
|
|
919
1050
|
"list_releases",
|
|
920
1051
|
"get_release",
|
|
921
1052
|
"download_release_asset",
|
|
922
|
-
|
|
1053
|
+
"get_merge_request_approval_state",
|
|
1054
|
+
]);
|
|
923
1055
|
// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
|
|
924
|
-
const wikiToolNames = [
|
|
1056
|
+
const wikiToolNames = new Set([
|
|
925
1057
|
"list_wiki_pages",
|
|
926
1058
|
"get_wiki_page",
|
|
927
1059
|
"create_wiki_page",
|
|
928
1060
|
"update_wiki_page",
|
|
929
1061
|
"delete_wiki_page",
|
|
930
1062
|
"upload_wiki_attachment",
|
|
931
|
-
];
|
|
1063
|
+
]);
|
|
932
1064
|
// Define which tools are related to milestones and can be toggled by USE_MILESTONE
|
|
933
|
-
const milestoneToolNames = [
|
|
1065
|
+
const milestoneToolNames = new Set([
|
|
934
1066
|
"list_milestones",
|
|
935
1067
|
"get_milestone",
|
|
936
1068
|
"create_milestone",
|
|
@@ -940,9 +1072,9 @@ const milestoneToolNames = [
|
|
|
940
1072
|
"get_milestone_merge_requests",
|
|
941
1073
|
"promote_milestone",
|
|
942
1074
|
"get_milestone_burndown_events",
|
|
943
|
-
];
|
|
1075
|
+
]);
|
|
944
1076
|
// Define which tools are related to pipelines and can be toggled by USE_PIPELINE
|
|
945
|
-
const pipelineToolNames = [
|
|
1077
|
+
const pipelineToolNames = new Set([
|
|
946
1078
|
"list_pipelines",
|
|
947
1079
|
"get_pipeline",
|
|
948
1080
|
"list_pipeline_jobs",
|
|
@@ -955,7 +1087,7 @@ const pipelineToolNames = [
|
|
|
955
1087
|
"play_pipeline_job",
|
|
956
1088
|
"retry_pipeline_job",
|
|
957
1089
|
"cancel_pipeline_job",
|
|
958
|
-
];
|
|
1090
|
+
]);
|
|
959
1091
|
/**
|
|
960
1092
|
* Smart URL handling for GitLab API
|
|
961
1093
|
*
|
|
@@ -976,11 +1108,17 @@ function normalizeGitLabApiUrl(url) {
|
|
|
976
1108
|
return normalizedUrl;
|
|
977
1109
|
}
|
|
978
1110
|
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
979
|
-
const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
|
|
1111
|
+
const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
|
|
1112
|
+
.split(",")
|
|
1113
|
+
.map(normalizeGitLabApiUrl);
|
|
980
1114
|
const GITLAB_API_URL = GITLAB_API_URLS[0];
|
|
981
1115
|
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
|
|
982
|
-
const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(
|
|
983
|
-
|
|
1116
|
+
const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(",")
|
|
1117
|
+
.map(id => id.trim())
|
|
1118
|
+
.filter(Boolean) || [];
|
|
1119
|
+
const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE
|
|
1120
|
+
? Number.parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE, 10)
|
|
1121
|
+
: 20;
|
|
984
1122
|
// Validate authentication configuration
|
|
985
1123
|
if (REMOTE_AUTHORIZATION) {
|
|
986
1124
|
// Remote authorization mode: token comes from HTTP headers
|
|
@@ -1037,11 +1175,11 @@ function getEffectiveProjectId(projectId) {
|
|
|
1037
1175
|
}
|
|
1038
1176
|
// If a project ID is provided, check if it's in the whitelist
|
|
1039
1177
|
if (projectId && !GITLAB_ALLOWED_PROJECT_IDS.includes(projectId)) {
|
|
1040
|
-
throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(
|
|
1178
|
+
throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}`);
|
|
1041
1179
|
}
|
|
1042
1180
|
// If no project ID provided but we have multiple allowed projects, require an explicit choice
|
|
1043
1181
|
if (!projectId && GITLAB_ALLOWED_PROJECT_IDS.length > 1) {
|
|
1044
|
-
throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(
|
|
1182
|
+
throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}). Please specify a project ID.`);
|
|
1045
1183
|
}
|
|
1046
1184
|
return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
|
|
1047
1185
|
}
|
|
@@ -1473,18 +1611,22 @@ async function listDiscussions(projectId, resourceType, resourceIid, options = {
|
|
|
1473
1611
|
// Extract pagination headers
|
|
1474
1612
|
const pagination = {
|
|
1475
1613
|
x_next_page: response.headers.get("x-next-page")
|
|
1476
|
-
? parseInt(response.headers.get("x-next-page"))
|
|
1614
|
+
? Number.parseInt(response.headers.get("x-next-page"), 10)
|
|
1477
1615
|
: null,
|
|
1478
|
-
x_page: response.headers.get("x-page")
|
|
1616
|
+
x_page: response.headers.get("x-page")
|
|
1617
|
+
? Number.parseInt(response.headers.get("x-page"), 10)
|
|
1618
|
+
: undefined,
|
|
1479
1619
|
x_per_page: response.headers.get("x-per-page")
|
|
1480
|
-
? parseInt(response.headers.get("x-per-page"))
|
|
1620
|
+
? Number.parseInt(response.headers.get("x-per-page"), 10)
|
|
1481
1621
|
: undefined,
|
|
1482
1622
|
x_prev_page: response.headers.get("x-prev-page")
|
|
1483
|
-
? parseInt(response.headers.get("x-prev-page"))
|
|
1623
|
+
? Number.parseInt(response.headers.get("x-prev-page"), 10)
|
|
1624
|
+
: null,
|
|
1625
|
+
x_total: response.headers.get("x-total")
|
|
1626
|
+
? Number.parseInt(response.headers.get("x-total"), 10)
|
|
1484
1627
|
: null,
|
|
1485
|
-
x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")) : null,
|
|
1486
1628
|
x_total_pages: response.headers.get("x-total-pages")
|
|
1487
|
-
? parseInt(response.headers.get("x-total-pages"))
|
|
1629
|
+
? Number.parseInt(response.headers.get("x-total-pages"), 10)
|
|
1488
1630
|
: null,
|
|
1489
1631
|
};
|
|
1490
1632
|
return PaginatedDiscussionsResponseSchema.parse({
|
|
@@ -1881,10 +2023,10 @@ async function searchProjects(query, page = 1, perPage = 20) {
|
|
|
1881
2023
|
const totalCount = response.headers.get("x-total");
|
|
1882
2024
|
const totalPages = response.headers.get("x-total-pages");
|
|
1883
2025
|
// GitLab API doesn't return these headers for results > 10,000
|
|
1884
|
-
const count = totalCount ? parseInt(totalCount) : projects.length;
|
|
2026
|
+
const count = totalCount ? Number.parseInt(totalCount, 10) : projects.length;
|
|
1885
2027
|
return GitLabSearchResponseSchema.parse({
|
|
1886
2028
|
count,
|
|
1887
|
-
total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage),
|
|
2029
|
+
total_pages: totalPages ? Number.parseInt(totalPages, 10) : Math.ceil(count / perPage),
|
|
1888
2030
|
current_page: page,
|
|
1889
2031
|
items: projects,
|
|
1890
2032
|
});
|
|
@@ -1906,7 +2048,7 @@ async function createRepository(options) {
|
|
|
1906
2048
|
visibility: options.visibility,
|
|
1907
2049
|
initialize_with_readme: options.initialize_with_readme,
|
|
1908
2050
|
default_branch: "main",
|
|
1909
|
-
path: options.name.toLowerCase().
|
|
2051
|
+
path: options.name.toLowerCase().replaceAll(/\s+/g, "-"),
|
|
1910
2052
|
}),
|
|
1911
2053
|
});
|
|
1912
2054
|
if (!response.ok) {
|
|
@@ -2088,6 +2230,68 @@ async function mergeMergeRequest(projectId, options, mergeRequestIid) {
|
|
|
2088
2230
|
await handleGitLabError(response);
|
|
2089
2231
|
return GitLabMergeRequestSchema.parse(await response.json());
|
|
2090
2232
|
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Approve a merge request
|
|
2235
|
+
*
|
|
2236
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2237
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2238
|
+
* @param {string} sha - Optional SHA to approve (for validation that MR hasn't changed)
|
|
2239
|
+
* @param {string} approvalPassword - Optional password for approvals requiring re-authentication
|
|
2240
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after approving
|
|
2241
|
+
*/
|
|
2242
|
+
async function approveMergeRequest(projectId, mergeRequestIid, sha, approvalPassword) {
|
|
2243
|
+
projectId = decodeURIComponent(projectId);
|
|
2244
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approve`);
|
|
2245
|
+
const body = {};
|
|
2246
|
+
if (sha) {
|
|
2247
|
+
body.sha = sha;
|
|
2248
|
+
}
|
|
2249
|
+
if (approvalPassword) {
|
|
2250
|
+
body.approval_password = approvalPassword;
|
|
2251
|
+
}
|
|
2252
|
+
const response = await fetch(url.toString(), {
|
|
2253
|
+
...getFetchConfig(),
|
|
2254
|
+
method: "POST",
|
|
2255
|
+
body: JSON.stringify(body),
|
|
2256
|
+
});
|
|
2257
|
+
await handleGitLabError(response);
|
|
2258
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Unapprove a previously approved merge request
|
|
2262
|
+
*
|
|
2263
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2264
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2265
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after unapproving
|
|
2266
|
+
*/
|
|
2267
|
+
async function unapproveMergeRequest(projectId, mergeRequestIid) {
|
|
2268
|
+
projectId = decodeURIComponent(projectId);
|
|
2269
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/unapprove`);
|
|
2270
|
+
const response = await fetch(url.toString(), {
|
|
2271
|
+
...getFetchConfig(),
|
|
2272
|
+
method: "POST",
|
|
2273
|
+
body: JSON.stringify({}),
|
|
2274
|
+
});
|
|
2275
|
+
await handleGitLabError(response);
|
|
2276
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Get the approval state of a merge request
|
|
2280
|
+
*
|
|
2281
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2282
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2283
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state
|
|
2284
|
+
*/
|
|
2285
|
+
async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
2286
|
+
projectId = decodeURIComponent(projectId);
|
|
2287
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
|
|
2288
|
+
const response = await fetch(url.toString(), {
|
|
2289
|
+
...getFetchConfig(),
|
|
2290
|
+
method: "GET",
|
|
2291
|
+
});
|
|
2292
|
+
await handleGitLabError(response);
|
|
2293
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2294
|
+
}
|
|
2091
2295
|
/**
|
|
2092
2296
|
* Create a new note (comment) on an issue or merge request
|
|
2093
2297
|
* 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수
|
|
@@ -2250,7 +2454,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
2250
2454
|
}
|
|
2251
2455
|
// Handle empty response (204 No Content) or successful response
|
|
2252
2456
|
const responseText = await response.text();
|
|
2253
|
-
if (!responseText || responseText.trim() ===
|
|
2457
|
+
if (!responseText || responseText.trim() === "") {
|
|
2254
2458
|
// Return a success indicator for empty responses
|
|
2255
2459
|
return {
|
|
2256
2460
|
id: draftNoteId.toString(),
|
|
@@ -2260,7 +2464,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
2260
2464
|
updated_at: new Date().toISOString(),
|
|
2261
2465
|
system: false,
|
|
2262
2466
|
noteable_id: mergeRequestIid.toString(),
|
|
2263
|
-
noteable_type: "MergeRequest"
|
|
2467
|
+
noteable_type: "MergeRequest",
|
|
2264
2468
|
};
|
|
2265
2469
|
}
|
|
2266
2470
|
try {
|
|
@@ -2279,7 +2483,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
2279
2483
|
updated_at: new Date().toISOString(),
|
|
2280
2484
|
system: false,
|
|
2281
2485
|
noteable_id: mergeRequestIid.toString(),
|
|
2282
|
-
noteable_type: "MergeRequest"
|
|
2486
|
+
noteable_type: "MergeRequest",
|
|
2283
2487
|
};
|
|
2284
2488
|
}
|
|
2285
2489
|
}
|
|
@@ -2303,7 +2507,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
|
|
|
2303
2507
|
}
|
|
2304
2508
|
// Handle empty response (204 No Content) or successful response
|
|
2305
2509
|
const responseText = await response.text();
|
|
2306
|
-
if (!responseText || responseText.trim() ===
|
|
2510
|
+
if (!responseText || responseText.trim() === "") {
|
|
2307
2511
|
// Return empty array for successful bulk publish with no content
|
|
2308
2512
|
return [];
|
|
2309
2513
|
}
|
|
@@ -2354,7 +2558,6 @@ async function createMergeRequestThread(projectId, mergeRequestIid, body, positi
|
|
|
2354
2558
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
2355
2559
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions`);
|
|
2356
2560
|
const payload = { body };
|
|
2357
|
-
// Add optional parameters if provided
|
|
2358
2561
|
if (position) {
|
|
2359
2562
|
payload.position = position;
|
|
2360
2563
|
}
|
|
@@ -2370,6 +2573,46 @@ async function createMergeRequestThread(projectId, mergeRequestIid, body, positi
|
|
|
2370
2573
|
const data = await response.json();
|
|
2371
2574
|
return GitLabDiscussionSchema.parse(data);
|
|
2372
2575
|
}
|
|
2576
|
+
/**
|
|
2577
|
+
* List all versions of a merge request
|
|
2578
|
+
* 병합 요청의 모든 버전 목록 조회
|
|
2579
|
+
*
|
|
2580
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2581
|
+
* @param {number} mergeRequestIid - The internal ID of the merge request
|
|
2582
|
+
* @returns {Promise<GitLabMergeRequestVersion[]>} List of merge request versions
|
|
2583
|
+
*/
|
|
2584
|
+
async function listMergeRequestVersions(projectId, mergeRequestIid) {
|
|
2585
|
+
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
2586
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/versions`);
|
|
2587
|
+
const response = await fetch(url.toString(), {
|
|
2588
|
+
...getFetchConfig(),
|
|
2589
|
+
});
|
|
2590
|
+
await handleGitLabError(response);
|
|
2591
|
+
const data = await response.json();
|
|
2592
|
+
return z.array(GitLabMergeRequestVersionSchema).parse(data);
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Get a specific version of a merge request
|
|
2596
|
+
* 병합 요청의 특정 버전 상세 정보 조회
|
|
2597
|
+
*
|
|
2598
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2599
|
+
* @param {number} mergeRequestIid - The internal ID of the merge request
|
|
2600
|
+
* @param {number} versionId - The ID of the version
|
|
2601
|
+
* @returns {Promise<GitLabMergeRequestVersionDetail>} The merge request version details
|
|
2602
|
+
*/
|
|
2603
|
+
async function getMergeRequestVersion(projectId, mergeRequestIid, versionId, unidiff) {
|
|
2604
|
+
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
2605
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/versions/${versionId}`);
|
|
2606
|
+
if (unidiff !== undefined) {
|
|
2607
|
+
url.searchParams.append("unidiff", String(unidiff));
|
|
2608
|
+
}
|
|
2609
|
+
const response = await fetch(url.toString(), {
|
|
2610
|
+
...getFetchConfig(),
|
|
2611
|
+
});
|
|
2612
|
+
await handleGitLabError(response);
|
|
2613
|
+
const data = await response.json();
|
|
2614
|
+
return GitLabMergeRequestVersionDetailSchema.parse(data);
|
|
2615
|
+
}
|
|
2373
2616
|
/**
|
|
2374
2617
|
* List all namespaces
|
|
2375
2618
|
* 사용 가능한 모든 네임스페이스 목록 조회
|
|
@@ -3027,7 +3270,7 @@ async function cancelPipelineJob(projectId, jobId, force) {
|
|
|
3027
3270
|
projectId = decodeURIComponent(projectId);
|
|
3028
3271
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/cancel`);
|
|
3029
3272
|
if (force !== undefined) {
|
|
3030
|
-
url.searchParams.append(
|
|
3273
|
+
url.searchParams.append("force", force.toString());
|
|
3031
3274
|
}
|
|
3032
3275
|
const response = await fetch(url.toString(), {
|
|
3033
3276
|
...getFetchConfig(),
|
|
@@ -3444,7 +3687,8 @@ async function myIssues(options = {}) {
|
|
|
3444
3687
|
async function listProjectMembers(projectId, options = {}) {
|
|
3445
3688
|
projectId = decodeURIComponent(projectId);
|
|
3446
3689
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
3447
|
-
const
|
|
3690
|
+
const membersPath = options.include_inheritance ? "members/all" : "members";
|
|
3691
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/${membersPath}`);
|
|
3448
3692
|
// Add query parameters
|
|
3449
3693
|
if (options.query)
|
|
3450
3694
|
url.searchParams.append("query", options.query);
|
|
@@ -3744,24 +3988,19 @@ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
|
|
|
3744
3988
|
return await response.text();
|
|
3745
3989
|
}
|
|
3746
3990
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3747
|
-
// In remote auth mode, retrieve session context from AsyncLocalStorage
|
|
3748
|
-
// This ensures the context is available even when called from SDK's async chains
|
|
3749
|
-
const sessionContext = REMOTE_AUTHORIZATION ? sessionAuthStore.getStore() : null;
|
|
3750
3991
|
// Apply read-only filter first
|
|
3751
3992
|
const tools0 = GITLAB_READ_ONLY_MODE
|
|
3752
|
-
? allTools.filter(tool => readOnlyTools.
|
|
3993
|
+
? allTools.filter(tool => readOnlyTools.has(tool.name))
|
|
3753
3994
|
: allTools;
|
|
3754
3995
|
// Toggle wiki tools by USE_GITLAB_WIKI flag
|
|
3755
|
-
const tools1 = USE_GITLAB_WIKI
|
|
3756
|
-
? tools0
|
|
3757
|
-
: tools0.filter(tool => !wikiToolNames.includes(tool.name));
|
|
3996
|
+
const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
|
|
3758
3997
|
// Toggle milestone tools by USE_MILESTONE flag
|
|
3759
|
-
const tools2 = USE_MILESTONE
|
|
3760
|
-
? tools1
|
|
3761
|
-
: tools1.filter(tool => !milestoneToolNames.includes(tool.name));
|
|
3998
|
+
const tools2 = USE_MILESTONE ? tools1 : tools1.filter(tool => !milestoneToolNames.has(tool.name));
|
|
3762
3999
|
// Toggle pipeline tools by USE_PIPELINE flag
|
|
3763
|
-
let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.
|
|
3764
|
-
tools = GITLAB_DENIED_TOOLS_REGEX
|
|
4000
|
+
let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
|
|
4001
|
+
tools = GITLAB_DENIED_TOOLS_REGEX
|
|
4002
|
+
? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
|
|
4003
|
+
: tools;
|
|
3765
4004
|
// <<< START: Gemini 호환성을 위해 $schema 제거 >>>
|
|
3766
4005
|
tools = tools.map(tool => {
|
|
3767
4006
|
// inputSchema가 존재하고 객체인지 확인
|
|
@@ -3801,6 +4040,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3801
4040
|
// Fallback for non-remote-auth mode or if session is not found
|
|
3802
4041
|
return handleToolCall(request.params);
|
|
3803
4042
|
});
|
|
4043
|
+
/**
|
|
4044
|
+
* Filter diffs by excluded file patterns
|
|
4045
|
+
* Safely handles invalid regex patterns by logging and ignoring them
|
|
4046
|
+
*
|
|
4047
|
+
* @param diffs - Array of diff objects with new_path property
|
|
4048
|
+
* @param excludedFilePatterns - Array of regex patterns to exclude
|
|
4049
|
+
* @returns Filtered array of diffs
|
|
4050
|
+
*/
|
|
4051
|
+
function filterDiffsByPatterns(diffs, excludedFilePatterns) {
|
|
4052
|
+
if (!excludedFilePatterns?.length)
|
|
4053
|
+
return diffs;
|
|
4054
|
+
const regexPatterns = excludedFilePatterns
|
|
4055
|
+
.map((pattern) => {
|
|
4056
|
+
try {
|
|
4057
|
+
return new RegExp(pattern);
|
|
4058
|
+
}
|
|
4059
|
+
catch (e) {
|
|
4060
|
+
console.warn(`Invalid regex pattern ignored: ${pattern}`);
|
|
4061
|
+
return null;
|
|
4062
|
+
}
|
|
4063
|
+
})
|
|
4064
|
+
.filter((regex) => regex !== null);
|
|
4065
|
+
if (regexPatterns.length === 0)
|
|
4066
|
+
return diffs;
|
|
4067
|
+
const matchesAnyPattern = (path) => {
|
|
4068
|
+
if (!path)
|
|
4069
|
+
return false;
|
|
4070
|
+
return regexPatterns.some((regex) => regex.test(path));
|
|
4071
|
+
};
|
|
4072
|
+
return diffs.filter((diff) => !matchesAnyPattern(diff.new_path));
|
|
4073
|
+
}
|
|
3804
4074
|
async function handleToolCall(params) {
|
|
3805
4075
|
try {
|
|
3806
4076
|
if (!params.arguments) {
|
|
@@ -3903,17 +4173,7 @@ async function handleToolCall(params) {
|
|
|
3903
4173
|
case "get_branch_diffs": {
|
|
3904
4174
|
const args = GetBranchDiffsSchema.parse(params.arguments);
|
|
3905
4175
|
const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight);
|
|
3906
|
-
|
|
3907
|
-
const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern));
|
|
3908
|
-
// Helper function to check if a path matches any regex pattern
|
|
3909
|
-
const matchesAnyPattern = (path) => {
|
|
3910
|
-
if (!path)
|
|
3911
|
-
return false;
|
|
3912
|
-
return regexPatterns.some(regex => regex.test(path));
|
|
3913
|
-
};
|
|
3914
|
-
// Filter out files that match any of the regex patterns on new files
|
|
3915
|
-
diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path));
|
|
3916
|
-
}
|
|
4176
|
+
diffResp.diffs = filterDiffsByPatterns(diffResp.diffs, args.excluded_file_patterns);
|
|
3917
4177
|
return {
|
|
3918
4178
|
content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }],
|
|
3919
4179
|
};
|
|
@@ -4010,21 +4270,21 @@ async function handleToolCall(params) {
|
|
|
4010
4270
|
content: [{ type: "text", text: "Merge request note deleted successfully" }],
|
|
4011
4271
|
};
|
|
4012
4272
|
}
|
|
4013
|
-
case
|
|
4273
|
+
case "get_merge_request_note": {
|
|
4014
4274
|
const args = GetMergeRequestNoteSchema.parse(params.arguments);
|
|
4015
4275
|
const note = await getMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id);
|
|
4016
4276
|
return {
|
|
4017
4277
|
content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
|
|
4018
4278
|
};
|
|
4019
4279
|
}
|
|
4020
|
-
case
|
|
4280
|
+
case "get_merge_request_notes": {
|
|
4021
4281
|
const args = GetMergeRequestNotesSchema.parse(params.arguments);
|
|
4022
4282
|
const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
|
|
4023
4283
|
return {
|
|
4024
4284
|
content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
|
|
4025
4285
|
};
|
|
4026
4286
|
}
|
|
4027
|
-
case
|
|
4287
|
+
case "update_merge_request_note": {
|
|
4028
4288
|
const args = UpdateMergeRequestNoteSchema.parse(params.arguments);
|
|
4029
4289
|
const note = await updateMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id, args.body);
|
|
4030
4290
|
return {
|
|
@@ -4055,8 +4315,9 @@ async function handleToolCall(params) {
|
|
|
4055
4315
|
case "get_merge_request_diffs": {
|
|
4056
4316
|
const args = GetMergeRequestDiffsSchema.parse(params.arguments);
|
|
4057
4317
|
const diffs = await getMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.view);
|
|
4318
|
+
const filteredDiffs = filterDiffsByPatterns(diffs, args.excluded_file_patterns);
|
|
4058
4319
|
return {
|
|
4059
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
4320
|
+
content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
|
|
4060
4321
|
};
|
|
4061
4322
|
}
|
|
4062
4323
|
case "list_merge_request_diffs": {
|
|
@@ -4066,6 +4327,20 @@ async function handleToolCall(params) {
|
|
|
4066
4327
|
content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
|
|
4067
4328
|
};
|
|
4068
4329
|
}
|
|
4330
|
+
case "list_merge_request_versions": {
|
|
4331
|
+
const args = ListMergeRequestVersionsSchema.parse(params.arguments);
|
|
4332
|
+
const versions = await listMergeRequestVersions(args.project_id, args.merge_request_iid);
|
|
4333
|
+
return {
|
|
4334
|
+
content: [{ type: "text", text: JSON.stringify(versions, null, 2) }],
|
|
4335
|
+
};
|
|
4336
|
+
}
|
|
4337
|
+
case "get_merge_request_version": {
|
|
4338
|
+
const args = GetMergeRequestVersionSchema.parse(params.arguments);
|
|
4339
|
+
const version = await getMergeRequestVersion(args.project_id, args.merge_request_iid, args.version_id, args.unidiff);
|
|
4340
|
+
return {
|
|
4341
|
+
content: [{ type: "text", text: JSON.stringify(version, null, 2) }],
|
|
4342
|
+
};
|
|
4343
|
+
}
|
|
4069
4344
|
case "update_merge_request": {
|
|
4070
4345
|
const args = UpdateMergeRequestSchema.parse(params.arguments);
|
|
4071
4346
|
const { project_id, merge_request_iid, source_branch, ...options } = args;
|
|
@@ -4082,6 +4357,27 @@ async function handleToolCall(params) {
|
|
|
4082
4357
|
content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
|
|
4083
4358
|
};
|
|
4084
4359
|
}
|
|
4360
|
+
case "approve_merge_request": {
|
|
4361
|
+
const args = ApproveMergeRequestSchema.parse(params.arguments);
|
|
4362
|
+
const approvalState = await approveMergeRequest(args.project_id, args.merge_request_iid, args.sha, args.approval_password);
|
|
4363
|
+
return {
|
|
4364
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4365
|
+
};
|
|
4366
|
+
}
|
|
4367
|
+
case "unapprove_merge_request": {
|
|
4368
|
+
const args = UnapproveMergeRequestSchema.parse(params.arguments);
|
|
4369
|
+
const approvalState = await unapproveMergeRequest(args.project_id, args.merge_request_iid);
|
|
4370
|
+
return {
|
|
4371
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4372
|
+
};
|
|
4373
|
+
}
|
|
4374
|
+
case "get_merge_request_approval_state": {
|
|
4375
|
+
const args = GetMergeRequestApprovalStateSchema.parse(params.arguments);
|
|
4376
|
+
const approvalState = await getMergeRequestApprovalState(args.project_id, args.merge_request_iid);
|
|
4377
|
+
return {
|
|
4378
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4379
|
+
};
|
|
4380
|
+
}
|
|
4085
4381
|
case "mr_discussions": {
|
|
4086
4382
|
const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
|
|
4087
4383
|
const { project_id, merge_request_iid, ...options } = args;
|
|
@@ -4749,7 +5045,9 @@ async function handleToolCall(params) {
|
|
|
4749
5045
|
const args = DownloadAttachmentSchema.parse(params.arguments);
|
|
4750
5046
|
const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
|
|
4751
5047
|
return {
|
|
4752
|
-
content: [
|
|
5048
|
+
content: [
|
|
5049
|
+
{ type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) },
|
|
5050
|
+
],
|
|
4753
5051
|
};
|
|
4754
5052
|
}
|
|
4755
5053
|
case "list_events": {
|
|
@@ -4921,8 +5219,8 @@ async function startStreamableHTTPServer() {
|
|
|
4921
5219
|
const streamableTransports = {};
|
|
4922
5220
|
const authTimeouts = {};
|
|
4923
5221
|
// Configuration and limits
|
|
4924
|
-
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ||
|
|
4925
|
-
const MAX_REQUESTS_PER_MINUTE = parseInt(process.env.MAX_REQUESTS_PER_MINUTE ||
|
|
5222
|
+
const MAX_SESSIONS = Number.parseInt(process.env.MAX_SESSIONS || "1000", 10);
|
|
5223
|
+
const MAX_REQUESTS_PER_MINUTE = Number.parseInt(process.env.MAX_REQUESTS_PER_MINUTE || "60", 10);
|
|
4926
5224
|
// Metrics tracking
|
|
4927
5225
|
const metrics = {
|
|
4928
5226
|
activeSessions: 0,
|
|
@@ -4942,7 +5240,7 @@ async function startStreamableHTTPServer() {
|
|
|
4942
5240
|
// GitLab PAT format: glpat-xxxxx (min 20 chars)
|
|
4943
5241
|
if (token.length < 20)
|
|
4944
5242
|
return false;
|
|
4945
|
-
if (!/^[a-zA-Z0-9_
|
|
5243
|
+
if (!/^[-a-zA-Z0-9_.]+$/.test(token))
|
|
4946
5244
|
return false;
|
|
4947
5245
|
return true;
|
|
4948
5246
|
};
|
|
@@ -4967,9 +5265,9 @@ async function startStreamableHTTPServer() {
|
|
|
4967
5265
|
* Returns null if no auth found or invalid format
|
|
4968
5266
|
*/
|
|
4969
5267
|
const parseAuthHeaders = (req) => {
|
|
4970
|
-
const authHeader = req.headers[
|
|
4971
|
-
const privateToken = req.headers[
|
|
4972
|
-
const dynamicApiUrl = req.headers[
|
|
5268
|
+
const authHeader = req.headers["authorization"] || "";
|
|
5269
|
+
const privateToken = req.headers["private-token"] || "";
|
|
5270
|
+
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
|
|
4973
5271
|
let apiUrl = GITLAB_API_URL; // Default API URL
|
|
4974
5272
|
// Only process dynamic URL if the feature is enabled
|
|
4975
5273
|
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
|
|
@@ -4977,7 +5275,7 @@ async function startStreamableHTTPServer() {
|
|
|
4977
5275
|
new URL(dynamicApiUrl); // Ensure it's a valid URL format
|
|
4978
5276
|
apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
|
|
4979
5277
|
}
|
|
4980
|
-
catch
|
|
5278
|
+
catch {
|
|
4981
5279
|
logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. Auth will fail.`);
|
|
4982
5280
|
return null; // Reject if URL is malformed
|
|
4983
5281
|
}
|
|
@@ -4987,13 +5285,16 @@ async function startStreamableHTTPServer() {
|
|
|
4987
5285
|
let header = null;
|
|
4988
5286
|
if (privateToken) {
|
|
4989
5287
|
token = privateToken.trim();
|
|
4990
|
-
header =
|
|
5288
|
+
header = "Private-Token";
|
|
4991
5289
|
}
|
|
4992
5290
|
else if (authHeader) {
|
|
4993
|
-
|
|
5291
|
+
// Use \S+ instead of .+ to prevent ReDoS attacks
|
|
5292
|
+
// \S+ only matches non-whitespace, so trim() is technically unnecessary,
|
|
5293
|
+
// but we keep it for defensive coding and backward compatibility
|
|
5294
|
+
const match = /^Bearer\s+(\S+)$/i.exec(authHeader);
|
|
4994
5295
|
if (match) {
|
|
4995
5296
|
token = match[1].trim();
|
|
4996
|
-
header =
|
|
5297
|
+
header = "Authorization";
|
|
4997
5298
|
}
|
|
4998
5299
|
}
|
|
4999
5300
|
// Validate token and return AuthData object
|
|
@@ -5048,8 +5349,8 @@ async function startStreamableHTTPServer() {
|
|
|
5048
5349
|
if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
|
|
5049
5350
|
metrics.rejectedByRateLimit++;
|
|
5050
5351
|
res.status(429).json({
|
|
5051
|
-
error:
|
|
5052
|
-
message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed
|
|
5352
|
+
error: "Rate limit exceeded",
|
|
5353
|
+
message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed`,
|
|
5053
5354
|
});
|
|
5054
5355
|
return;
|
|
5055
5356
|
}
|
|
@@ -5057,8 +5358,8 @@ async function startStreamableHTTPServer() {
|
|
|
5057
5358
|
if (!sessionId && Object.keys(streamableTransports).length >= MAX_SESSIONS) {
|
|
5058
5359
|
metrics.rejectedByCapacity++;
|
|
5059
5360
|
res.status(503).json({
|
|
5060
|
-
error:
|
|
5061
|
-
message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later
|
|
5361
|
+
error: "Server capacity reached",
|
|
5362
|
+
message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later.`,
|
|
5062
5363
|
});
|
|
5063
5364
|
return;
|
|
5064
5365
|
}
|
|
@@ -5070,8 +5371,8 @@ async function startStreamableHTTPServer() {
|
|
|
5070
5371
|
if (!authData) {
|
|
5071
5372
|
metrics.authFailures++;
|
|
5072
5373
|
res.status(401).json({
|
|
5073
|
-
error:
|
|
5074
|
-
message:
|
|
5374
|
+
error: "Missing Authorization or Private-Token header",
|
|
5375
|
+
message: "Remote authorization is enabled. Please provide Authorization or Private-Token header.",
|
|
5075
5376
|
});
|
|
5076
5377
|
return;
|
|
5077
5378
|
}
|
|
@@ -5160,7 +5461,7 @@ async function startStreamableHTTPServer() {
|
|
|
5160
5461
|
header: authData.header,
|
|
5161
5462
|
token: authData.token,
|
|
5162
5463
|
lastUsed: authData.lastUsed,
|
|
5163
|
-
apiUrl: authData.apiUrl
|
|
5464
|
+
apiUrl: authData.apiUrl,
|
|
5164
5465
|
};
|
|
5165
5466
|
// Run the entire request handling within AsyncLocalStorage context
|
|
5166
5467
|
await sessionAuthStore.run(ctx, handleRequest);
|
|
@@ -5175,7 +5476,7 @@ async function startStreamableHTTPServer() {
|
|
|
5175
5476
|
res.setHeader("Allow", "POST, DELETE");
|
|
5176
5477
|
res.status(405).json({
|
|
5177
5478
|
error: "Method Not Allowed",
|
|
5178
|
-
message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server."
|
|
5479
|
+
message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server.",
|
|
5179
5480
|
});
|
|
5180
5481
|
});
|
|
5181
5482
|
// Metrics endpoint
|
|
@@ -5191,14 +5492,14 @@ async function startStreamableHTTPServer() {
|
|
|
5191
5492
|
maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
|
|
5192
5493
|
sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
|
|
5193
5494
|
remoteAuthEnabled: REMOTE_AUTHORIZATION,
|
|
5194
|
-
}
|
|
5495
|
+
},
|
|
5195
5496
|
});
|
|
5196
5497
|
});
|
|
5197
5498
|
// Health check endpoint
|
|
5198
5499
|
app.get("/health", (_req, res) => {
|
|
5199
5500
|
const isHealthy = Object.keys(streamableTransports).length < MAX_SESSIONS;
|
|
5200
5501
|
res.status(isHealthy ? 200 : 503).json({
|
|
5201
|
-
status: isHealthy ?
|
|
5502
|
+
status: isHealthy ? "healthy" : "degraded",
|
|
5202
5503
|
activeSessions: Object.keys(streamableTransports).length,
|
|
5203
5504
|
maxSessions: MAX_SESSIONS,
|
|
5204
5505
|
uptime: process.uptime(),
|
|
@@ -5242,7 +5543,7 @@ async function startStreamableHTTPServer() {
|
|
|
5242
5543
|
logger.info(`${signal} received, starting graceful shutdown...`);
|
|
5243
5544
|
// Stop accepting new connections
|
|
5244
5545
|
httpServer.close(() => {
|
|
5245
|
-
logger.info(
|
|
5546
|
+
logger.info("HTTP server closed");
|
|
5246
5547
|
});
|
|
5247
5548
|
// Close all active sessions
|
|
5248
5549
|
const sessionIds = Object.keys(streamableTransports);
|
|
@@ -5267,12 +5568,12 @@ async function startStreamableHTTPServer() {
|
|
|
5267
5568
|
Object.keys(authTimeouts).forEach(sessionId => {
|
|
5268
5569
|
clearAuthTimeout(sessionId);
|
|
5269
5570
|
});
|
|
5270
|
-
logger.info(
|
|
5571
|
+
logger.info("Graceful shutdown complete");
|
|
5271
5572
|
process.exit(0);
|
|
5272
5573
|
};
|
|
5273
5574
|
// Register signal handlers
|
|
5274
|
-
process.on(
|
|
5275
|
-
process.on(
|
|
5575
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
5576
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
5276
5577
|
}
|
|
5277
5578
|
/**
|
|
5278
5579
|
* Initialize server with specific transport mode
|
|
@@ -5293,10 +5594,11 @@ async function initializeServerByTransportMode(mode) {
|
|
|
5293
5594
|
logger.warn("Starting GitLab MCP Server with Streamable HTTP transport");
|
|
5294
5595
|
await startStreamableHTTPServer();
|
|
5295
5596
|
break;
|
|
5296
|
-
default:
|
|
5597
|
+
default: {
|
|
5297
5598
|
// This should never happen with proper enum usage, but TypeScript requires it
|
|
5298
5599
|
const exhaustiveCheck = mode;
|
|
5299
5600
|
throw new Error(`Unknown transport mode: ${exhaustiveCheck}`);
|
|
5601
|
+
}
|
|
5300
5602
|
}
|
|
5301
5603
|
}
|
|
5302
5604
|
/**
|