@zereight/mcp-gitlab 2.0.25 → 2.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -4,12 +4,12 @@ const args = process.argv.slice(2);
4
4
  const cliArgs = {};
5
5
  for (let i = 0; i < args.length; i++) {
6
6
  const arg = args[i];
7
- if (arg.startsWith('--')) {
8
- const [key, value] = arg.slice(2).split('=');
7
+ if (arg.startsWith("--")) {
8
+ const [key, value] = arg.slice(2).split("=");
9
9
  if (value) {
10
10
  cliArgs[key] = value;
11
11
  }
12
- else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
12
+ else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
13
13
  cliArgs[key] = args[++i];
14
14
  }
15
15
  }
@@ -89,14 +89,86 @@ try {
89
89
  catch {
90
90
  // Intentionally ignored: version read failure is non-critical
91
91
  }
92
- const server = new Server({
93
- name: "better-gitlab-mcp-server",
94
- version: SERVER_VERSION,
95
- }, {
96
- capabilities: {
97
- tools: {},
98
- },
99
- });
92
+ /**
93
+ * Create a new MCP Server instance with request handlers registered.
94
+ * Each transport connection gets its own Server instance to prevent
95
+ * cross-client data leakage (GHSA-345p-7cg4-v4c7).
96
+ */
97
+ function createServer() {
98
+ // Precompute filtered tool list once at server creation (Steps 1–5 are static)
99
+ // Step 1: Toolset filter — keep tools in enabled toolsets
100
+ const toolsAfterToolsets = allTools.filter(tool => isToolInEnabledToolset(tool.name, enabledToolsets));
101
+ // Step 2: Add GITLAB_TOOLS (individual tools bypass toolset filter)
102
+ const toolsetToolNames = new Set(toolsAfterToolsets.map(t => t.name));
103
+ const toolsAfterIndividual = [
104
+ ...toolsAfterToolsets,
105
+ ...allTools.filter(tool => individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name)),
106
+ ];
107
+ // Step 3: Add legacy flag overrides (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI)
108
+ const afterIndividualNames = new Set(toolsAfterIndividual.map(t => t.name));
109
+ const toolsAfterLegacy = [
110
+ ...toolsAfterIndividual,
111
+ ...allTools.filter(tool => featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name)),
112
+ ];
113
+ // Step 4: Read-only filter
114
+ const toolsAfterReadOnly = GITLAB_READ_ONLY_MODE
115
+ ? toolsAfterLegacy.filter(tool => readOnlyTools.has(tool.name))
116
+ : toolsAfterLegacy;
117
+ // Step 5: Regex denial filter
118
+ const precomputedFilteredTools = GITLAB_DENIED_TOOLS_REGEX
119
+ ? toolsAfterReadOnly.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
120
+ : toolsAfterReadOnly;
121
+ const serverInstance = new Server({
122
+ name: "better-gitlab-mcp-server",
123
+ version: SERVER_VERSION,
124
+ }, {
125
+ capabilities: {
126
+ tools: {},
127
+ },
128
+ });
129
+ serverInstance.setRequestHandler(ListToolsRequestSchema, async () => {
130
+ // Step 6: Gemini $schema cleanup (only dynamic step per request)
131
+ // <<< START: Remove $schema for Gemini compatibility >>>
132
+ const tools = precomputedFilteredTools.map(tool => {
133
+ // Check if inputSchema exists and is an object
134
+ if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
135
+ // Remove $schema key if present
136
+ if ("$schema" in tool.inputSchema) {
137
+ // Create a new object to preserve immutability (optional but recommended)
138
+ const modifiedSchema = { ...tool.inputSchema };
139
+ delete modifiedSchema.$schema;
140
+ return { ...tool, inputSchema: modifiedSchema };
141
+ }
142
+ }
143
+ // Return as-is if no modification needed
144
+ return tool;
145
+ });
146
+ // <<< END: Remove $schema for Gemini compatibility >>>
147
+ return {
148
+ tools, // return tool list with $schema removed
149
+ };
150
+ });
151
+ serverInstance.setRequestHandler(CallToolRequestSchema, async (request) => {
152
+ // Manually retrieve the session context using the session ID passed in the request.
153
+ // This is a robust workaround for AsyncLocalStorage context loss.
154
+ const sessionId = request.params.sessionId;
155
+ if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
156
+ const authData = authBySession[sessionId];
157
+ const sessionContext = {
158
+ sessionId,
159
+ header: authData.header,
160
+ token: authData.token,
161
+ lastUsed: authData.lastUsed,
162
+ apiUrl: authData.apiUrl,
163
+ };
164
+ // Run the handler within the retrieved context
165
+ return await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
166
+ }
167
+ // Fallback for non-remote-auth mode or if session is not found
168
+ return handleToolCall(request.params);
169
+ });
170
+ return serverInstance;
171
+ }
100
172
  /**
101
173
  * Validate configuration at startup
102
174
  */
@@ -131,7 +203,7 @@ function validateConfiguration() {
131
203
  }
132
204
  }
133
205
  // Validate PORT
134
- const portStr = getConfig('port', 'PORT');
206
+ const portStr = getConfig("port", "PORT");
135
207
  if (portStr) {
136
208
  const port = Number.parseInt(portStr, 10);
137
209
  if (Number.isNaN(port) || port < 1 || port > 65535) {
@@ -139,7 +211,7 @@ function validateConfiguration() {
139
211
  }
140
212
  }
141
213
  // Validate GITLAB_API_URL format
142
- const apiUrls = getConfig('api-url', 'GITLAB_API_URL')?.split(",") || [];
214
+ const apiUrls = getConfig("api-url", "GITLAB_API_URL")?.split(",") || [];
143
215
  if (apiUrls.length > 0) {
144
216
  for (const url of apiUrls) {
145
217
  try {
@@ -151,14 +223,14 @@ function validateConfiguration() {
151
223
  }
152
224
  }
153
225
  // Validate auth configuration
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');
226
+ const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
227
+ const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
228
+ const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
229
+ const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
158
230
  if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
159
- errors.push('Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)');
231
+ errors.push("Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)");
160
232
  }
161
- const enableDynamicApiUrl = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
233
+ const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
162
234
  if (enableDynamicApiUrl && !remoteAuth) {
163
235
  errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
164
236
  }
@@ -169,32 +241,59 @@ function validateConfiguration() {
169
241
  }
170
242
  logger.info("Configuration validation passed");
171
243
  }
172
- const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
244
+ const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
173
245
  let OAUTH_ACCESS_TOKEN = null;
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);
246
+ const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
247
+ const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
248
+ const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
249
+ const GITLAB_READ_ONLY_MODE = getConfig("read-only", "GITLAB_READ_ONLY_MODE") === "true";
250
+ const GITLAB_DENIED_TOOLS_REGEX = (() => {
251
+ const pattern = getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX");
252
+ if (!pattern)
253
+ return undefined;
254
+ // Reject patterns that are too long (potential ReDoS vector)
255
+ const MAX_PATTERN_LENGTH = 200;
256
+ if (pattern.length > MAX_PATTERN_LENGTH) {
257
+ logger.error(`GITLAB_DENIED_TOOLS_REGEX pattern exceeds ${MAX_PATTERN_LENGTH} chars. Ignoring.`);
258
+ return undefined;
259
+ }
260
+ // Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
261
+ // e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
262
+ const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]|\(\?[^:)]/;
263
+ if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
264
+ logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
265
+ return undefined;
266
+ }
267
+ try {
268
+ const regex = new RegExp(pattern);
269
+ // Dry-run against a sample string to catch immediate issues
270
+ regex.test("sample_tool_name");
271
+ return regex;
272
+ }
273
+ catch {
274
+ logger.error(`Invalid GITLAB_DENIED_TOOLS_REGEX pattern: "${pattern}". Ignoring.`);
275
+ return undefined;
276
+ }
277
+ })();
278
+ const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
279
+ const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
280
+ const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
281
+ const GITLAB_TOOLSETS_RAW = getConfig("toolsets", "GITLAB_TOOLSETS");
282
+ const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
283
+ const SSE = getConfig("sse", "SSE") === "true";
284
+ const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
285
+ const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
286
+ const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
287
+ const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig("session-timeout", "SESSION_TIMEOUT_SECONDS", "3600"), 10);
288
+ const HOST = getConfig("host", "HOST") || "127.0.0.1";
289
+ const PORT = Number.parseInt(getConfig("port", "PORT", "3002"), 10);
191
290
  // Add proxy configuration
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)
291
+ const HTTP_PROXY = getConfig("http-proxy", "HTTP_PROXY");
292
+ const HTTPS_PROXY = getConfig("https-proxy", "HTTPS_PROXY");
293
+ const NODE_TLS_REJECT_UNAUTHORIZED = getConfig("tls-reject-unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED");
294
+ const GITLAB_CA_CERT_PATH = getConfig("ca-cert-path", "GITLAB_CA_CERT_PATH");
295
+ const GITLAB_POOL_MAX_SIZE = getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE")
296
+ ? Number.parseInt(getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE"), 10)
198
297
  : 100;
199
298
  let sslOptions = undefined;
200
299
  if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
@@ -227,7 +326,7 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
227
326
  httpAgent = httpAgent || new Agent();
228
327
  // Initialize the client pool for managing multiple GitLab instances
229
328
  const clientPool = new GitLabClientPool({
230
- apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
329
+ apiUrls: (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
231
330
  .split(",")
232
331
  .map(normalizeGitLabApiUrl),
233
332
  httpProxy: HTTP_PROXY,
@@ -415,10 +514,10 @@ const getFetchConfig = () => {
415
514
  };
416
515
  };
417
516
  const toJSONSchema = (schema) => {
418
- const jsonSchema = zodToJsonSchema(schema, { $refStrategy: 'none' });
517
+ const jsonSchema = zodToJsonSchema(schema, { $refStrategy: "none" });
419
518
  // Post-process to fix nullable/optional fields that should truly be optional
420
519
  function fixNullableOptional(obj) {
421
- if (obj && typeof obj === 'object') {
520
+ if (obj && typeof obj === "object") {
422
521
  // If this object has properties, process them
423
522
  if (obj.properties) {
424
523
  const requiredSet = new Set(obj.required || []);
@@ -426,10 +525,10 @@ const toJSONSchema = (schema) => {
426
525
  const prop = obj.properties[key];
427
526
  // Handle fields that can be null or omitted
428
527
  // 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')) {
528
+ if (prop.anyOf && prop.anyOf.some((t) => t.type === "null")) {
430
529
  requiredSet.delete(key);
431
530
  }
432
- else if (Array.isArray(prop.type) && prop.type.includes('null')) {
531
+ else if (Array.isArray(prop.type) && prop.type.includes("null")) {
433
532
  requiredSet.delete(key);
434
533
  }
435
534
  // Recursively process nested objects
@@ -439,12 +538,12 @@ const toJSONSchema = (schema) => {
439
538
  if (requiredSet.size > 0) {
440
539
  obj.required = Array.from(requiredSet);
441
540
  }
442
- else if (Object.prototype.hasOwnProperty.call(obj, 'required')) {
541
+ else if (Object.prototype.hasOwnProperty.call(obj, "required")) {
443
542
  delete obj.required;
444
543
  }
445
544
  }
446
545
  // Process anyOf/allOf/oneOf
447
- ['anyOf', 'allOf', 'oneOf'].forEach(combiner => {
546
+ ["anyOf", "allOf", "oneOf"].forEach(combiner => {
448
547
  if (obj[combiner]) {
449
548
  obj[combiner] = obj[combiner].map(fixNullableOptional);
450
549
  }
@@ -948,7 +1047,7 @@ const allTools = [
948
1047
  },
949
1048
  {
950
1049
  name: "download_attachment",
951
- description: "Download an uploaded file from a GitLab project by secret and filename",
1050
+ description: "Download an uploaded file from a GitLab project by secret and filename. Image files (png, jpg, gif, webp, svg, bmp, ico) are returned inline as base64 image content so the AI can view them directly. Non-image files are saved to disk. Use local_path to force saving image files to disk instead.",
952
1051
  inputSchema: toJSONSchema(DownloadAttachmentSchema),
953
1052
  },
954
1053
  {
@@ -1088,6 +1187,259 @@ const pipelineToolNames = new Set([
1088
1187
  "retry_pipeline_job",
1089
1188
  "cancel_pipeline_job",
1090
1189
  ]);
1190
+ const TOOLSET_DEFINITIONS = [
1191
+ {
1192
+ id: "merge_requests",
1193
+ isDefault: true,
1194
+ tools: new Set([
1195
+ "merge_merge_request",
1196
+ "approve_merge_request",
1197
+ "unapprove_merge_request",
1198
+ "get_merge_request_approval_state",
1199
+ "get_merge_request",
1200
+ "get_merge_request_diffs",
1201
+ "list_merge_request_diffs",
1202
+ "list_merge_request_versions",
1203
+ "get_merge_request_version",
1204
+ "update_merge_request",
1205
+ "create_merge_request",
1206
+ "list_merge_requests",
1207
+ "get_branch_diffs",
1208
+ "mr_discussions",
1209
+ "create_merge_request_note",
1210
+ "update_merge_request_note",
1211
+ "delete_merge_request_note",
1212
+ "get_merge_request_note",
1213
+ "get_merge_request_notes",
1214
+ "delete_merge_request_discussion_note",
1215
+ "update_merge_request_discussion_note",
1216
+ "create_merge_request_discussion_note",
1217
+ "get_draft_note",
1218
+ "list_draft_notes",
1219
+ "create_draft_note",
1220
+ "update_draft_note",
1221
+ "delete_draft_note",
1222
+ "publish_draft_note",
1223
+ "bulk_publish_draft_notes",
1224
+ "create_merge_request_thread",
1225
+ "resolve_merge_request_thread",
1226
+ ]),
1227
+ },
1228
+ {
1229
+ id: "issues",
1230
+ isDefault: true,
1231
+ tools: new Set([
1232
+ "create_issue",
1233
+ "list_issues",
1234
+ "my_issues",
1235
+ "get_issue",
1236
+ "update_issue",
1237
+ "delete_issue",
1238
+ "create_issue_note",
1239
+ "update_issue_note",
1240
+ "list_issue_links",
1241
+ "list_issue_discussions",
1242
+ "get_issue_link",
1243
+ "create_issue_link",
1244
+ "delete_issue_link",
1245
+ "create_note",
1246
+ ]),
1247
+ },
1248
+ {
1249
+ id: "repositories",
1250
+ isDefault: true,
1251
+ tools: new Set([
1252
+ "search_repositories",
1253
+ "create_repository",
1254
+ "get_file_contents",
1255
+ "push_files",
1256
+ "create_or_update_file",
1257
+ "fork_repository",
1258
+ "get_repository_tree",
1259
+ ]),
1260
+ },
1261
+ {
1262
+ id: "branches",
1263
+ isDefault: true,
1264
+ tools: new Set([
1265
+ "create_branch",
1266
+ "list_commits",
1267
+ "get_commit",
1268
+ "get_commit_diff",
1269
+ ]),
1270
+ },
1271
+ {
1272
+ id: "projects",
1273
+ isDefault: true,
1274
+ tools: new Set([
1275
+ "get_project",
1276
+ "list_projects",
1277
+ "list_project_members",
1278
+ "list_namespaces",
1279
+ "get_namespace",
1280
+ "verify_namespace",
1281
+ "list_group_projects",
1282
+ "list_group_iterations",
1283
+ ]),
1284
+ },
1285
+ {
1286
+ id: "labels",
1287
+ isDefault: true,
1288
+ tools: new Set([
1289
+ "list_labels",
1290
+ "get_label",
1291
+ "create_label",
1292
+ "update_label",
1293
+ "delete_label",
1294
+ ]),
1295
+ },
1296
+ {
1297
+ id: "pipelines",
1298
+ isDefault: false,
1299
+ tools: new Set([
1300
+ "list_pipelines",
1301
+ "get_pipeline",
1302
+ "list_pipeline_jobs",
1303
+ "list_pipeline_trigger_jobs",
1304
+ "get_pipeline_job",
1305
+ "get_pipeline_job_output",
1306
+ "create_pipeline",
1307
+ "retry_pipeline",
1308
+ "cancel_pipeline",
1309
+ "play_pipeline_job",
1310
+ "retry_pipeline_job",
1311
+ "cancel_pipeline_job",
1312
+ ]),
1313
+ },
1314
+ {
1315
+ id: "milestones",
1316
+ isDefault: false,
1317
+ tools: new Set([
1318
+ "list_milestones",
1319
+ "get_milestone",
1320
+ "create_milestone",
1321
+ "edit_milestone",
1322
+ "delete_milestone",
1323
+ "get_milestone_issue",
1324
+ "get_milestone_merge_requests",
1325
+ "promote_milestone",
1326
+ "get_milestone_burndown_events",
1327
+ ]),
1328
+ },
1329
+ {
1330
+ id: "wiki",
1331
+ isDefault: false,
1332
+ tools: new Set([
1333
+ "list_wiki_pages",
1334
+ "get_wiki_page",
1335
+ "create_wiki_page",
1336
+ "update_wiki_page",
1337
+ "delete_wiki_page",
1338
+ ]),
1339
+ },
1340
+ {
1341
+ id: "releases",
1342
+ isDefault: true,
1343
+ tools: new Set([
1344
+ "list_releases",
1345
+ "get_release",
1346
+ "create_release",
1347
+ "update_release",
1348
+ "delete_release",
1349
+ "create_release_evidence",
1350
+ "download_release_asset",
1351
+ ]),
1352
+ },
1353
+ {
1354
+ id: "users",
1355
+ isDefault: true,
1356
+ tools: new Set([
1357
+ "get_users",
1358
+ "list_events",
1359
+ "get_project_events",
1360
+ "upload_markdown",
1361
+ "download_attachment",
1362
+ ]),
1363
+ },
1364
+ ];
1365
+ // Derived lookup: tool name → toolset ID
1366
+ const TOOLSET_BY_TOOL_NAME = new Map();
1367
+ for (const def of TOOLSET_DEFINITIONS) {
1368
+ for (const tool of def.tools) {
1369
+ if (TOOLSET_BY_TOOL_NAME.has(tool)) {
1370
+ logger.warn(`Tool "${tool}" is defined in multiple toolsets: "${TOOLSET_BY_TOOL_NAME.get(tool)}" and "${def.id}"`);
1371
+ }
1372
+ TOOLSET_BY_TOOL_NAME.set(tool, def.id);
1373
+ }
1374
+ }
1375
+ const DEFAULT_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.filter(d => d.isDefault).map(d => d.id));
1376
+ const ALL_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.map(d => d.id));
1377
+ function parseEnabledToolsets(raw) {
1378
+ if (!raw || raw.trim() === "") {
1379
+ return DEFAULT_TOOLSET_IDS;
1380
+ }
1381
+ const trimmed = raw.trim().toLowerCase();
1382
+ if (trimmed === "all") {
1383
+ return ALL_TOOLSET_IDS;
1384
+ }
1385
+ const selected = new Set(trimmed
1386
+ .split(",")
1387
+ .map(s => s.trim())
1388
+ .filter((s) => ALL_TOOLSET_IDS.has(s)));
1389
+ if (selected.size === 0) {
1390
+ logger.warn(`No valid toolsets found in configuration (${raw}). Falling back to default toolsets.`);
1391
+ return DEFAULT_TOOLSET_IDS;
1392
+ }
1393
+ return selected;
1394
+ }
1395
+ function parseIndividualTools(raw) {
1396
+ if (!raw || raw.trim() === "") {
1397
+ return new Set();
1398
+ }
1399
+ const allToolNames = new Set(allTools.map((t) => t.name));
1400
+ const parsed = raw
1401
+ .trim()
1402
+ .split(",")
1403
+ .map(s => s.trim().toLowerCase())
1404
+ .filter(Boolean);
1405
+ const unknown = parsed.filter(name => !allToolNames.has(name));
1406
+ if (unknown.length > 0) {
1407
+ logger.warn(`Unknown tool names in GITLAB_TOOLS (will be ignored): ${unknown.join(", ")}`);
1408
+ }
1409
+ return new Set(parsed);
1410
+ }
1411
+ function buildFeatureFlagOverrides() {
1412
+ const overrides = new Set();
1413
+ if (USE_GITLAB_WIKI) {
1414
+ for (const t of wikiToolNames)
1415
+ overrides.add(t);
1416
+ }
1417
+ if (USE_MILESTONE) {
1418
+ for (const t of milestoneToolNames)
1419
+ overrides.add(t);
1420
+ }
1421
+ if (USE_PIPELINE) {
1422
+ for (const t of pipelineToolNames)
1423
+ overrides.add(t);
1424
+ }
1425
+ return overrides;
1426
+ }
1427
+ function isToolInEnabledToolset(toolName, enabledToolsets) {
1428
+ const toolsetId = TOOLSET_BY_TOOL_NAME.get(toolName);
1429
+ // Tools not in any toolset (e.g. execute_graphql) are excluded by default
1430
+ if (toolsetId === undefined)
1431
+ return false;
1432
+ return enabledToolsets.has(toolsetId);
1433
+ }
1434
+ // Compute at startup
1435
+ const enabledToolsets = parseEnabledToolsets(GITLAB_TOOLSETS_RAW);
1436
+ const individuallyEnabledTools = parseIndividualTools(GITLAB_TOOLS_RAW);
1437
+ const featureFlagOverrides = buildFeatureFlagOverrides();
1438
+ // Warn about potentially confusing configuration
1439
+ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
1440
+ logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
1441
+ "Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
1442
+ }
1091
1443
  /**
1092
1444
  * Smart URL handling for GitLab API
1093
1445
  *
@@ -1108,7 +1460,7 @@ function normalizeGitLabApiUrl(url) {
1108
1460
  return normalizedUrl;
1109
1461
  }
1110
1462
  // Use the normalizeGitLabApiUrl function to handle various URL formats
1111
- const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
1463
+ const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
1112
1464
  .split(",")
1113
1465
  .map(normalizeGitLabApiUrl);
1114
1466
  const GITLAB_API_URL = GITLAB_API_URLS[0];
@@ -1204,7 +1556,7 @@ async function forkProject(projectId, namespace) {
1204
1556
  ...getFetchConfig(),
1205
1557
  method: "POST",
1206
1558
  });
1207
- // 이미 존재하는 프로젝트인 경우 처리
1559
+ // Handle case where project already exists
1208
1560
  if (response.status === 409) {
1209
1561
  throw new Error("Project already exists in the target namespace");
1210
1562
  }
@@ -1266,7 +1618,7 @@ async function getFileContents(projectId, filePath, ref) {
1266
1618
  projectId = decodeURIComponent(projectId); // Decode project ID
1267
1619
  const effectiveProjectId = getEffectiveProjectId(projectId);
1268
1620
  const encodedPath = encodeURIComponent(filePath);
1269
- // ref가 없는 경우 default branch 가져옴
1621
+ // Fall back to default branch if ref is not provided
1270
1622
  if (!ref) {
1271
1623
  ref = await getDefaultBranchRef(projectId);
1272
1624
  }
@@ -1275,14 +1627,14 @@ async function getFileContents(projectId, filePath, ref) {
1275
1627
  const response = await fetch(url.toString(), {
1276
1628
  ...getFetchConfig(),
1277
1629
  });
1278
- // 파일을 찾을 없는 경우 처리
1630
+ // Handle file not found
1279
1631
  if (response.status === 404) {
1280
1632
  throw new Error(`File not found: ${filePath}`);
1281
1633
  }
1282
1634
  await handleGitLabError(response);
1283
1635
  const data = await response.json();
1284
1636
  const parsedData = GitLabContentSchema.parse(data);
1285
- // Base64 인코딩된 파일 내용을 UTF-8로 디코딩
1637
+ // Decode Base64-encoded file content to UTF-8
1286
1638
  if (!Array.isArray(parsedData) && parsedData.content) {
1287
1639
  parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8");
1288
1640
  parsedData.encoding = "utf8";
@@ -1312,7 +1664,7 @@ async function createIssue(projectId, options) {
1312
1664
  labels: options.labels?.join(","),
1313
1665
  }),
1314
1666
  });
1315
- // 잘못된 요청 처리
1667
+ // Handle bad request
1316
1668
  if (response.status === 400) {
1317
1669
  const errorBody = await response.text();
1318
1670
  throw new Error(`Invalid request: ${errorBody}`);
@@ -1703,17 +2055,41 @@ async function updateMergeRequestDiscussionNote(projectId, mergeRequestIid, disc
1703
2055
  }
1704
2056
  /**
1705
2057
  * Update an issue discussion note
2058
+ *
2059
+ * Note: Only one of `body` or `resolved` can be provided per GitLab API requirements.
2060
+ * At least one parameter must be provided.
2061
+ *
1706
2062
  * @param {string} projectId - The ID or URL-encoded path of the project
1707
- * @param {number} issueIid - The IID of an issue
2063
+ * @param {number|string} issueIid - The IID of an issue
1708
2064
  * @param {string} discussionId - The ID of a thread
1709
- * @param {number} noteId - The ID of a thread note
1710
- * @param {string} body - The new content of the note
2065
+ * @param {number|string} noteId - The ID of a thread note
2066
+ * @param {string} [body] - The new content of the note (optional, mutually exclusive with resolved)
2067
+ * @param {boolean} [resolved] - Resolve (true) or unresolve (false) the thread (optional, mutually exclusive with body)
1711
2068
  * @returns {Promise<GitLabDiscussionNote>} The updated note
2069
+ *
2070
+ * @example
2071
+ * // Resolve a thread
2072
+ * await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, undefined, true);
2073
+ *
2074
+ * @example
2075
+ * // Unresolve a thread
2076
+ * await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, undefined, false);
2077
+ *
2078
+ * @example
2079
+ * // Update note body
2080
+ * await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, 'Updated content');
1712
2081
  */
1713
- async function updateIssueNote(projectId, issueIid, discussionId, noteId, body) {
2082
+ async function updateIssueNote(projectId, issueIid, discussionId, noteId, body, resolved) {
1714
2083
  projectId = decodeURIComponent(projectId); // Decode project ID
1715
2084
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}`);
1716
- const payload = { body };
2085
+ // Only one of body or resolved can be sent according to GitLab API
2086
+ const payload = {};
2087
+ if (body !== undefined) {
2088
+ payload.body = body;
2089
+ }
2090
+ else if (resolved !== undefined) {
2091
+ payload.resolved = resolved;
2092
+ }
1717
2093
  const response = await fetch(url.toString(), {
1718
2094
  ...getFetchConfig(),
1719
2095
  method: "PUT",
@@ -2309,10 +2685,10 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
2309
2685
  * @param {string} body - The content of the note
2310
2686
  * @returns {Promise<any>} The created note
2311
2687
  */
2312
- async function createNote(projectId, noteableType, // 'issue' 또는 'merge_request' 타입 명시
2688
+ async function createNote(projectId, noteableType, // specifies 'issue' or 'merge_request' type
2313
2689
  noteableIid, body) {
2314
2690
  projectId = decodeURIComponent(projectId); // Decode project ID
2315
- // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능
2691
+ // ⚙️ Response type can be adjusted according to the GitLab API documentation
2316
2692
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
2317
2693
  );
2318
2694
  const response = await fetch(url.toString(), {
@@ -2360,14 +2736,18 @@ async function listDraftNotes(projectId, mergeRequestIid) {
2360
2736
  * @param {string} projectId - The ID or URL-encoded path of the project
2361
2737
  * @param {number|string} mergeRequestIid - The internal ID of the merge request
2362
2738
  * @param {string} body - The content of the draft note
2739
+ * @param {string} [inReplyToDiscussionId] - The ID of a discussion the draft note replies to
2363
2740
  * @param {MergeRequestThreadPosition} [position] - Position information for diff notes
2364
2741
  * @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing
2365
2742
  * @returns {Promise<GitLabDraftNote>} The created draft note
2366
2743
  */
2367
- async function createDraftNote(projectId, mergeRequestIid, body, position, resolveDiscussion) {
2744
+ async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscussionId, position, resolveDiscussion) {
2368
2745
  projectId = decodeURIComponent(projectId);
2369
2746
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes`);
2370
2747
  const requestBody = { note: body };
2748
+ if (inReplyToDiscussionId) {
2749
+ requestBody.in_reply_to_discussion_id = inReplyToDiscussionId;
2750
+ }
2371
2751
  if (position) {
2372
2752
  requestBody.position = position;
2373
2753
  }
@@ -3790,6 +4170,20 @@ async function markdownUpload(projectId, filePath) {
3790
4170
  const data = await response.json();
3791
4171
  return GitLabMarkdownUploadSchema.parse(data);
3792
4172
  }
4173
+ const IMAGE_MIME_TYPES = {
4174
+ ".png": "image/png",
4175
+ ".jpg": "image/jpeg",
4176
+ ".jpeg": "image/jpeg",
4177
+ ".gif": "image/gif",
4178
+ ".webp": "image/webp",
4179
+ ".svg": "image/svg+xml",
4180
+ ".bmp": "image/bmp",
4181
+ ".ico": "image/x-icon",
4182
+ };
4183
+ function getImageMimeType(filename) {
4184
+ const ext = path.extname(filename).toLowerCase();
4185
+ return IMAGE_MIME_TYPES[ext] ?? null;
4186
+ }
3793
4187
  async function downloadAttachment(projectId, secret, filename, localPath) {
3794
4188
  const effectiveProjectId = getEffectiveProjectId(projectId);
3795
4189
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
@@ -3804,12 +4198,33 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
3804
4198
  await handleGitLabError(response);
3805
4199
  }
3806
4200
  // Get the file content as buffer
3807
- const buffer = await response.arrayBuffer();
3808
- // Determine the save path
3809
- const savePath = localPath ? path.join(localPath, filename) : filename;
3810
- // Write the file to disk
3811
- fs.writeFileSync(savePath, Buffer.from(buffer));
3812
- return savePath;
4201
+ const buffer = Buffer.from(await response.arrayBuffer());
4202
+ const mimeType = getImageMimeType(filename);
4203
+ // For non-image files, always save to disk.
4204
+ // For image files, only save to disk if local_path is explicitly provided.
4205
+ if (!mimeType || localPath) {
4206
+ let savePath;
4207
+ if (localPath) {
4208
+ const normalizedLocalPath = path.normalize(localPath);
4209
+ if (path.isAbsolute(normalizedLocalPath) ||
4210
+ normalizedLocalPath === ".." ||
4211
+ normalizedLocalPath.startsWith(".." + path.sep) ||
4212
+ normalizedLocalPath.includes(path.sep + ".." + path.sep)) {
4213
+ throw new Error("Invalid local_path: directory traversal is not allowed.");
4214
+ }
4215
+ savePath = path.join(normalizedLocalPath, filename);
4216
+ }
4217
+ else {
4218
+ savePath = filename;
4219
+ }
4220
+ const dir = path.dirname(savePath);
4221
+ if (!fs.existsSync(dir)) {
4222
+ fs.mkdirSync(dir, { recursive: true });
4223
+ }
4224
+ fs.writeFileSync(savePath, buffer);
4225
+ return { buffer, filename, mimeType, savedPath: savePath };
4226
+ }
4227
+ return { buffer, filename, mimeType };
3813
4228
  }
3814
4229
  /**
3815
4230
  * List all events for the currently authenticated user
@@ -3993,59 +4408,8 @@ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
3993
4408
  await handleGitLabError(response);
3994
4409
  return await response.text();
3995
4410
  }
3996
- server.setRequestHandler(ListToolsRequestSchema, async () => {
3997
- // Apply read-only filter first
3998
- const tools0 = GITLAB_READ_ONLY_MODE
3999
- ? allTools.filter(tool => readOnlyTools.has(tool.name))
4000
- : allTools;
4001
- // Toggle wiki tools by USE_GITLAB_WIKI flag
4002
- const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
4003
- // Toggle milestone tools by USE_MILESTONE flag
4004
- const tools2 = USE_MILESTONE ? tools1 : tools1.filter(tool => !milestoneToolNames.has(tool.name));
4005
- // Toggle pipeline tools by USE_PIPELINE flag
4006
- let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
4007
- tools = GITLAB_DENIED_TOOLS_REGEX
4008
- ? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
4009
- : tools;
4010
- // <<< START: Gemini 호환성을 위해 $schema 제거 >>>
4011
- tools = tools.map(tool => {
4012
- // inputSchema가 존재하고 객체인지 확인
4013
- if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
4014
- // $schema 키가 존재하면 삭제
4015
- if ("$schema" in tool.inputSchema) {
4016
- // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장)
4017
- const modifiedSchema = { ...tool.inputSchema };
4018
- delete modifiedSchema.$schema;
4019
- return { ...tool, inputSchema: modifiedSchema };
4020
- }
4021
- }
4022
- // 변경이 필요 없으면 그대로 반환
4023
- return tool;
4024
- });
4025
- // <<< END: Gemini 호환성을 위해 $schema 제거 >>>
4026
- return {
4027
- tools, // $schema가 제거된 도구 목록 반환
4028
- };
4029
- });
4030
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
4031
- // Manually retrieve the session context using the session ID passed in the request.
4032
- // This is a robust workaround for AsyncLocalStorage context loss.
4033
- const sessionId = request.params.sessionId;
4034
- if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
4035
- const authData = authBySession[sessionId];
4036
- const sessionContext = {
4037
- sessionId,
4038
- header: authData.header,
4039
- token: authData.token,
4040
- lastUsed: authData.lastUsed,
4041
- apiUrl: authData.apiUrl,
4042
- };
4043
- // Run the handler within the retrieved context
4044
- return await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
4045
- }
4046
- // Fallback for non-remote-auth mode or if session is not found
4047
- return handleToolCall(request.params);
4048
- });
4411
+ // Request handlers are now registered inside createServer() factory function
4412
+ // to ensure each transport connection gets its own Server instance (GHSA-345p-7cg4-v4c7).
4049
4413
  /**
4050
4414
  * Filter diffs by excluded file patterns
4051
4415
  * Safely handles invalid regex patterns by logging and ignoring them
@@ -4058,7 +4422,7 @@ function filterDiffsByPatterns(diffs, excludedFilePatterns) {
4058
4422
  if (!excludedFilePatterns?.length)
4059
4423
  return diffs;
4060
4424
  const regexPatterns = excludedFilePatterns
4061
- .map((pattern) => {
4425
+ .map(pattern => {
4062
4426
  try {
4063
4427
  return new RegExp(pattern);
4064
4428
  }
@@ -4073,9 +4437,9 @@ function filterDiffsByPatterns(diffs, excludedFilePatterns) {
4073
4437
  const matchesAnyPattern = (path) => {
4074
4438
  if (!path)
4075
4439
  return false;
4076
- return regexPatterns.some((regex) => regex.test(path));
4440
+ return regexPatterns.some(regex => regex.test(path));
4077
4441
  };
4078
- return diffs.filter((diff) => !matchesAnyPattern(diff.new_path));
4442
+ return diffs.filter(diff => !matchesAnyPattern(diff.new_path));
4079
4443
  }
4080
4444
  async function handleToolCall(params) {
4081
4445
  try {
@@ -4299,7 +4663,7 @@ async function handleToolCall(params) {
4299
4663
  }
4300
4664
  case "update_issue_note": {
4301
4665
  const args = UpdateIssueNoteSchema.parse(params.arguments);
4302
- const note = await updateIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.note_id, args.body);
4666
+ const note = await updateIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.note_id, args.body, args.resolved);
4303
4667
  return {
4304
4668
  content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
4305
4669
  };
@@ -4512,8 +4876,8 @@ async function handleToolCall(params) {
4512
4876
  }
4513
4877
  case "create_draft_note": {
4514
4878
  const args = CreateDraftNoteSchema.parse(params.arguments);
4515
- const { project_id, merge_request_iid, body, position, resolve_discussion } = args;
4516
- const draftNote = await createDraftNote(project_id, merge_request_iid, body, position, resolve_discussion);
4879
+ const { project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion } = args;
4880
+ const draftNote = await createDraftNote(project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion);
4517
4881
  return {
4518
4882
  content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }],
4519
4883
  };
@@ -5049,10 +5413,26 @@ async function handleToolCall(params) {
5049
5413
  }
5050
5414
  case "download_attachment": {
5051
5415
  const args = DownloadAttachmentSchema.parse(params.arguments);
5052
- const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
5416
+ const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
5417
+ if (result.mimeType && !args.local_path) {
5418
+ // Return image inline as base64 so the AI can see it
5419
+ const base64 = result.buffer.toString("base64");
5420
+ return {
5421
+ content: [
5422
+ { type: "image", data: base64, mimeType: result.mimeType },
5423
+ {
5424
+ type: "text",
5425
+ text: JSON.stringify({ filename: result.filename, mimeType: result.mimeType }, null, 2),
5426
+ },
5427
+ ],
5428
+ };
5429
+ }
5053
5430
  return {
5054
5431
  content: [
5055
- { type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) },
5432
+ {
5433
+ type: "text",
5434
+ text: JSON.stringify({ success: true, file_path: result.savedPath }, null, 2),
5435
+ },
5056
5436
  ],
5057
5437
  };
5058
5438
  }
@@ -5176,8 +5556,9 @@ function determineTransportMode() {
5176
5556
  * Start server with stdio transport
5177
5557
  */
5178
5558
  async function startStdioServer() {
5559
+ const serverInstance = createServer();
5179
5560
  const transport = new StdioServerTransport();
5180
- await server.connect(transport);
5561
+ await serverInstance.connect(transport);
5181
5562
  }
5182
5563
  /**
5183
5564
  * Start server with traditional SSE transport
@@ -5186,12 +5567,13 @@ async function startSSEServer() {
5186
5567
  const app = express();
5187
5568
  const transports = {};
5188
5569
  app.get("/sse", async (_, res) => {
5570
+ const serverInstance = createServer();
5189
5571
  const transport = new SSEServerTransport("/messages", res);
5190
5572
  transports[transport.sessionId] = transport;
5191
5573
  res.on("close", () => {
5192
5574
  delete transports[transport.sessionId];
5193
5575
  });
5194
- await server.connect(transport);
5576
+ await serverInstance.connect(transport);
5195
5577
  });
5196
5578
  app.post("/messages", async (req, res) => {
5197
5579
  const sessionId = req.query.sessionId;
@@ -5445,8 +5827,10 @@ async function startStreamableHTTPServer() {
5445
5827
  }
5446
5828
  }
5447
5829
  };
5448
- // Connect transport to MCP server
5449
- await server.connect(transport);
5830
+ // Create a new Server instance per session to prevent
5831
+ // cross-client data leakage (GHSA-345p-7cg4-v4c7)
5832
+ const serverInstance = createServer();
5833
+ await serverInstance.connect(transport);
5450
5834
  // Handle the request - context is already set up in the outer handleRequest wrapper
5451
5835
  await transport.handleRequest(req, res, req.body);
5452
5836
  }