@zereight/mcp-gitlab 2.0.33 → 2.0.34

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 CHANGED
@@ -336,7 +336,7 @@ docker run -i --rm \
336
336
  - `GITLAB_OAUTH_REDIRECT_URI`: The OAuth callback URL. Default: `http://127.0.0.1:8888/callback`
337
337
  - `GITLAB_OAUTH_TOKEN_PATH`: Custom path to store the OAuth token. Default: `~/.gitlab-mcp-token.json`
338
338
  - `REMOTE_AUTHORIZATION`: When set to 'true', enables remote per-session authorization via HTTP headers. In this mode:
339
- - The server accepts GitLab PAT tokens from HTTP headers (`Authorization: Bearer <token>` or `Private-Token: <token>`) on a per-session basis
339
+ - The server accepts GitLab PAT tokens from HTTP headers (`Authorization: Bearer <token>`, `Private-Token: <token>` or `Job-Token: <token>`) on a per-session basis
340
340
  - `GITLAB_PERSONAL_ACCESS_TOKEN` environment variable is **not required** and ignored
341
341
  - Only works with **Streamable HTTP transport** (`STREAMABLE_HTTP=true`) because session management was already handled by the transport layer
342
342
  - **SSE transport is disabled** - attempting to use SSE with remote authorization will cause the server to exit with an error
@@ -400,6 +400,7 @@ docker run -i --rm \
400
400
  - `SSE`: When set to 'true', enables the Server-Sent Events transport.
401
401
  - `STREAMABLE_HTTP`: When set to 'true', enables the Streamable HTTP transport. If both **SSE** and **STREAMABLE_HTTP** are set to 'true', the server will prioritize Streamable HTTP over SSE transport.
402
402
  - `GITLAB_COMMIT_FILES_PER_PAGE`: The number of files per page that GitLab returns for commit diffs. This value should match the server-side GitLab setting. Adjust this if your GitLab instance uses a custom per-page value for commit diffs.
403
+ - `GITLAB_REPO_FILE_ENCODING`: Encoding for repository file create/update and related commit payloads sent to the GitLab API. Use `text` (default) or `base64`. Equivalent CLI: `--repo-file-encoding=text|base64`.
403
404
 
404
405
  #### Performance & Security Configuration
405
406
 
@@ -407,6 +408,16 @@ docker run -i --rm \
407
408
  - `MAX_SESSIONS`: Maximum number of concurrent sessions allowed. Default: `1000`. Valid range: 1-10000. When limit is reached, new connections are rejected with HTTP 503.
408
409
  - `MAX_REQUESTS_PER_MINUTE`: Rate limit per session in requests per minute. Default: `60`. Valid range: 1-1000. Exceeded requests return HTTP 429.
409
410
  - `PORT`: Server port. Default: `3002`. Valid range: 1-65535.
411
+ - `HTTP_PROXY`: HTTP proxy server URL for outgoing requests. Example: `http://proxy.example.com:8080`. Supports HTTP/HTTPS and SOCKS proxies (URLs starting with `socks://` or `socks5://`). CLI arg: `--http-proxy`
412
+ - `HTTPS_PROXY`: HTTPS proxy server URL for outgoing requests. Example: `https://proxy.example.com:8080`. Supports HTTP/HTTPS and SOCKS proxies. CLI arg: `--https-proxy`
413
+ - `NO_PROXY`: Comma-separated list of hosts that should bypass the proxy. Supports:
414
+ - Exact hostname matches (e.g., `localhost`, `gitlab.internal.com`)
415
+ - Domain suffix matches (e.g., `.internal.com` matches any subdomain)
416
+ - IP addresses (e.g., `127.0.0.1`, `192.168.1.1`)
417
+ - Port-specific matches (e.g., `example.com:443`)
418
+ - Wildcard `*` to bypass proxy for all hosts
419
+ - Example: `NO_PROXY=localhost,127.0.0.1,.internal.com`
420
+ - CLI arg: `--no-proxy`
410
421
 
411
422
  #### Monitoring Endpoints
412
423
 
@@ -4,6 +4,64 @@ import { HttpProxyAgent } from "http-proxy-agent";
4
4
  import { HttpsProxyAgent } from "https-proxy-agent";
5
5
  import { SocksProxyAgent } from "socks-proxy-agent";
6
6
  import fs from "fs";
7
+ /**
8
+ * Checks if a URL should bypass the proxy based on NO_PROXY patterns.
9
+ * Supports:
10
+ * - Exact hostname matches (e.g., "localhost", "gitlab.example.com")
11
+ * - Domain suffix matches (e.g., ".example.com" matches "gitlab.example.com")
12
+ * - IP addresses (e.g., "127.0.0.1", "192.168.1.1")
13
+ * - Wildcard "*" to bypass all proxies
14
+ * - Port-specific matches (e.g., "example.com:8080")
15
+ *
16
+ * @param url The URL to check
17
+ * @param noProxy Comma-separated list of patterns from NO_PROXY
18
+ * @returns true if the URL should bypass the proxy, false otherwise
19
+ */
20
+ function shouldBypassProxy(url, noProxy) {
21
+ if (!noProxy) {
22
+ return false;
23
+ }
24
+ // Parse URL to get hostname and port
25
+ let hostname;
26
+ let port;
27
+ let protocol;
28
+ try {
29
+ const parsedUrl = new URL(url);
30
+ hostname = parsedUrl.hostname.toLowerCase();
31
+ protocol = parsedUrl.protocol;
32
+ // Use explicit port if provided, otherwise use default port based on protocol
33
+ port = parsedUrl.port || (protocol === 'https:' ? '443' : '80');
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ // Split NO_PROXY into patterns and trim whitespace
39
+ const patterns = noProxy.split(',').map(p => p.trim().toLowerCase()).filter(p => p.length > 0);
40
+ for (const pattern of patterns) {
41
+ // Wildcard matches everything
42
+ if (pattern === '*') {
43
+ return true;
44
+ }
45
+ // Handle port-specific patterns (e.g., "example.com:8080")
46
+ const [patternHost, patternPort] = pattern.split(':');
47
+ // If pattern specifies a port, check if it matches
48
+ if (patternPort && port !== patternPort) {
49
+ continue;
50
+ }
51
+ // Check for domain suffix match (e.g., ".example.com")
52
+ if (patternHost.startsWith('.')) {
53
+ const suffix = patternHost.substring(1);
54
+ if (hostname === suffix || hostname.endsWith('.' + suffix)) {
55
+ return true;
56
+ }
57
+ }
58
+ // Check for exact hostname match
59
+ else if (hostname === patternHost) {
60
+ return true;
61
+ }
62
+ }
63
+ return false;
64
+ }
7
65
  /**
8
66
  * Manages a pool of HTTP/HTTPS agents for different GitLab API URLs.
9
67
  * This allows the server to efficiently handle requests to multiple GitLab instances
@@ -23,7 +81,7 @@ export class GitLabClientPool {
23
81
  * @returns A `ClientAgents` object containing the configured agents.
24
82
  */
25
83
  createAgentsForUrl(apiUrl) {
26
- const { httpProxy, httpsProxy, rejectUnauthorized, caCertPath } = this.options;
84
+ const { httpProxy, httpsProxy, noProxy, rejectUnauthorized, caCertPath } = this.options;
27
85
  const url = new URL(apiUrl);
28
86
  let sslOptions = {};
29
87
  if (rejectUnauthorized === false) {
@@ -38,10 +96,12 @@ export class GitLabClientPool {
38
96
  throw new Error(`Failed to read CA certificate: ${caCertPath}`);
39
97
  }
40
98
  }
99
+ // Check if this URL should bypass the proxy
100
+ const bypassProxy = shouldBypassProxy(apiUrl, noProxy);
41
101
  let httpAgent;
42
102
  let httpsAgent;
43
- // Configure HTTP agent with proxy if specified
44
- if (httpProxy) {
103
+ // Configure HTTP agent with proxy if specified and not bypassed
104
+ if (httpProxy && !bypassProxy) {
45
105
  httpAgent = httpProxy.startsWith("socks")
46
106
  ? new SocksProxyAgent(httpProxy)
47
107
  : new HttpProxyAgent(httpProxy);
@@ -49,8 +109,8 @@ export class GitLabClientPool {
49
109
  else {
50
110
  httpAgent = new Agent({ keepAlive: true });
51
111
  }
52
- // Configure HTTPS agent with proxy and SSL options if specified
53
- if (httpsProxy) {
112
+ // Configure HTTPS agent with proxy and SSL options if specified and not bypassed
113
+ if (httpsProxy && !bypassProxy) {
54
114
  httpsAgent = httpsProxy.startsWith("socks")
55
115
  // The `as any` cast is used here to bypass a TypeScript type mismatch error.
56
116
  // The `socks-proxy-agent` documentation indicates that TLS options like
@@ -72,6 +132,31 @@ export class GitLabClientPool {
72
132
  * @returns The corresponding `Agent` for the URL's protocol.
73
133
  */
74
134
  getOrCreateAgentForUrl(apiUrl) {
135
+ const agents = this.getOrCreateAgentsForUrl(apiUrl);
136
+ const url = new URL(apiUrl);
137
+ return url.protocol === "https:" ? agents.httpsAgent : agents.httpAgent;
138
+ }
139
+ /**
140
+ * Returns an agent-selection function for use with node-fetch's `agent` option.
141
+ * The returned function picks the correct HTTP or HTTPS agent based on the
142
+ * request URL's protocol. This is critical for self-hosted GitLab instances
143
+ * where the server may redirect between HTTP and HTTPS (e.g., when
144
+ * `external_url` differs from the actual internal protocol).
145
+ * @param apiUrl The base API URL used to look up or create the agent pair.
146
+ * @returns A function `(parsedURL: URL) => Agent` suitable for node-fetch.
147
+ */
148
+ getAgentFunctionForUrl(apiUrl) {
149
+ const agents = this.getOrCreateAgentsForUrl(apiUrl);
150
+ return (parsedURL) => {
151
+ return parsedURL.protocol === "https:" ? agents.httpsAgent : agents.httpAgent;
152
+ };
153
+ }
154
+ /**
155
+ * Ensures agents exist for the given API URL and returns the pair.
156
+ * @param apiUrl The full URL of the request.
157
+ * @returns The `ClientAgents` (both HTTP and HTTPS agents) for the URL.
158
+ */
159
+ getOrCreateAgentsForUrl(apiUrl) {
75
160
  const url = new URL(apiUrl);
76
161
  const baseUrl = `${url.protocol}//${url.host}${url.pathname.substring(0, url.pathname.lastIndexOf('/api/v4') + '/api/v4'.length)}`;
77
162
  if (!this.clients.has(baseUrl)) {
@@ -86,7 +171,7 @@ export class GitLabClientPool {
86
171
  // This should not happen given the logic above, but it satisfies TypeScript
87
172
  throw new Error(`Failed to create or get client for URL: ${baseUrl}`);
88
173
  }
89
- return url.protocol === "https:" ? agents.httpsAgent : agents.httpAgent;
174
+ return agents;
90
175
  }
91
176
  /**
92
177
  * Retrieves the client agents for a specific base API URL.
@@ -110,4 +195,21 @@ export class GitLabClientPool {
110
195
  }
111
196
  return this.clients.get(defaultUrl);
112
197
  }
198
+ /**
199
+ * Destroy all pooled agents and clear pool state.
200
+ * This should be called on graceful shutdown so sockets are closed
201
+ * and the process can exit cleanly.
202
+ */
203
+ closeAll() {
204
+ for (const [, agents] of this.clients) {
205
+ const destroyIfSupported = (agent) => {
206
+ if (agent && typeof agent.destroy === "function") {
207
+ agent.destroy();
208
+ }
209
+ };
210
+ destroyIfSupported(agents.httpAgent);
211
+ destroyIfSupported(agents.httpsAgent);
212
+ }
213
+ this.clients.clear();
214
+ }
113
215
  }
package/build/index.js CHANGED
@@ -50,7 +50,7 @@ GitLabDiscussionNoteSchema, // Added
50
50
  GitLabDiscussionSchema,
51
51
  // Draft Notes Schemas
52
52
  GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
53
- ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalsResponseSchema, 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, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
53
+ ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, 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, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
54
54
  import { randomUUID } from "node:crypto";
55
55
  import { pino } from "pino";
56
56
  const logger = pino({
@@ -314,6 +314,7 @@ const PORT = Number.parseInt(getConfig("port", "PORT", "3002"), 10);
314
314
  // Add proxy configuration
315
315
  const HTTP_PROXY = getConfig("http-proxy", "HTTP_PROXY");
316
316
  const HTTPS_PROXY = getConfig("https-proxy", "HTTPS_PROXY");
317
+ const NO_PROXY = getConfig("no-proxy", "NO_PROXY");
317
318
  const NODE_TLS_REJECT_UNAUTHORIZED = getConfig("tls-reject-unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED");
318
319
  const GITLAB_CA_CERT_PATH = getConfig("ca-cert-path", "GITLAB_CA_CERT_PATH");
319
320
  const GITLAB_POOL_MAX_SIZE = getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE")
@@ -355,6 +356,7 @@ const clientPool = new GitLabClientPool({
355
356
  .map(normalizeGitLabApiUrl),
356
357
  httpProxy: HTTP_PROXY,
357
358
  httpsProxy: HTTPS_PROXY,
359
+ noProxy: NO_PROXY,
358
360
  rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
359
361
  caCertPath: GITLAB_CA_CERT_PATH,
360
362
  poolMaxSize: GITLAB_POOL_MAX_SIZE,
@@ -535,7 +537,7 @@ function getEffectiveApiUrl() {
535
537
  */
536
538
  const getFetchConfig = () => {
537
539
  const effectiveApiUrl = getEffectiveApiUrl();
538
- const agent = clientPool.getOrCreateAgentForUrl(effectiveApiUrl);
540
+ const agent = clientPool.getAgentFunctionForUrl(effectiveApiUrl);
539
541
  return {
540
542
  headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
541
543
  agent: agent,
@@ -603,6 +605,11 @@ const allTools = [
603
605
  description: "Get merge request approval details including approvers (uses approval_state when available, falls back to approvals endpoint)",
604
606
  inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
605
607
  },
608
+ {
609
+ name: "get_merge_request_conflicts",
610
+ description: "Get the conflicts of a merge request in a GitLab project",
611
+ inputSchema: toJSONSchema(GetMergeRequestConflictsSchema),
612
+ },
606
613
  {
607
614
  name: "execute_graphql",
608
615
  description: "Execute a GitLab GraphQL query",
@@ -1235,6 +1242,7 @@ const readOnlyTools = new Set([
1235
1242
  "get_release",
1236
1243
  "download_release_asset",
1237
1244
  "get_merge_request_approval_state",
1245
+ "get_merge_request_conflicts",
1238
1246
  "list_webhooks",
1239
1247
  "list_webhook_events",
1240
1248
  "get_webhook_event",
@@ -1291,6 +1299,7 @@ const TOOLSET_DEFINITIONS = [
1291
1299
  "approve_merge_request",
1292
1300
  "unapprove_merge_request",
1293
1301
  "get_merge_request_approval_state",
1302
+ "get_merge_request_conflicts",
1294
1303
  "get_merge_request",
1295
1304
  "get_merge_request_diffs",
1296
1305
  "list_merge_request_diffs",
@@ -1583,6 +1592,9 @@ const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split
1583
1592
  const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE
1584
1593
  ? Number.parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE, 10)
1585
1594
  : 20;
1595
+ const GITLAB_REPO_FILE_ENCODING = getConfig("repo-file-encoding", "GITLAB_REPO_FILE_ENCODING", "text") === "base64"
1596
+ ? "base64"
1597
+ : "text";
1586
1598
  // Validate authentication configuration
1587
1599
  if (REMOTE_AUTHORIZATION) {
1588
1600
  // Remote authorization mode: token comes from HTTP headers
@@ -1598,7 +1610,7 @@ if (REMOTE_AUTHORIZATION) {
1598
1610
  }
1599
1611
  logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
1600
1612
  }
1601
- else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1613
+ else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1602
1614
  // Standard mode: token must be in environment (unless using OAuth)
1603
1615
  logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
1604
1616
  logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
@@ -1647,7 +1659,14 @@ function getEffectiveProjectId(projectId) {
1647
1659
  }
1648
1660
  return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
1649
1661
  }
1650
- return GITLAB_PROJECT_ID || projectId;
1662
+ // Prioritize the passed projectId over GITLAB_PROJECT_ID to allow querying different projects
1663
+ if (projectId) {
1664
+ return projectId;
1665
+ }
1666
+ if (GITLAB_PROJECT_ID) {
1667
+ return GITLAB_PROJECT_ID;
1668
+ }
1669
+ throw new Error("No project ID provided and GITLAB_PROJECT_ID is not set");
1651
1670
  }
1652
1671
  /**
1653
1672
  * Create a fork of a GitLab project
@@ -2344,6 +2363,12 @@ async function updateMergeRequestNote(projectId, mergeRequestIid, noteId, body)
2344
2363
  const data = await response.json();
2345
2364
  return GitLabDiscussionNoteSchema.parse(data);
2346
2365
  }
2366
+ function encodeRepoFilePayloadContent(content) {
2367
+ if (GITLAB_REPO_FILE_ENCODING === "base64") {
2368
+ return Buffer.from(content).toString("base64");
2369
+ }
2370
+ return content;
2371
+ }
2347
2372
  /**
2348
2373
  * Create or update a file in a GitLab project
2349
2374
  * 파일 생성 또는 업데이트
@@ -2362,9 +2387,9 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
2362
2387
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}`);
2363
2388
  const body = {
2364
2389
  branch,
2365
- content,
2390
+ content: encodeRepoFilePayloadContent(content),
2366
2391
  commit_message: commitMessage,
2367
- encoding: "text",
2392
+ encoding: GITLAB_REPO_FILE_ENCODING,
2368
2393
  ...(previousPath ? { previous_path: previousPath } : {}),
2369
2394
  };
2370
2395
  // Check if file exists
@@ -2436,8 +2461,8 @@ async function createTree(projectId, files, ref) {
2436
2461
  body: JSON.stringify({
2437
2462
  files: files.map(file => ({
2438
2463
  file_path: file.path,
2439
- content: file.content,
2440
- encoding: "text",
2464
+ content: encodeRepoFilePayloadContent(file.content),
2465
+ encoding: GITLAB_REPO_FILE_ENCODING,
2441
2466
  })),
2442
2467
  }),
2443
2468
  });
@@ -2474,8 +2499,8 @@ async function createCommit(projectId, message, branch, actions) {
2474
2499
  actions: actions.map(action => ({
2475
2500
  action: "create",
2476
2501
  file_path: action.path,
2477
- content: action.content,
2478
- encoding: "text",
2502
+ content: encodeRepoFilePayloadContent(action.content),
2503
+ encoding: GITLAB_REPO_FILE_ENCODING,
2479
2504
  })),
2480
2505
  }),
2481
2506
  });
@@ -3007,6 +3032,23 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
3007
3032
  source_endpoint: "approval_state",
3008
3033
  };
3009
3034
  }
3035
+ /**
3036
+ * Get the conflicts of a merge request
3037
+ *
3038
+ * @param {string} projectId - The ID or URL-encoded path of the project
3039
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
3040
+ * @returns {Promise<Record<string, unknown>>} The merge request conflicts
3041
+ */
3042
+ async function getMergeRequestConflicts(projectId, mergeRequestIid) {
3043
+ projectId = decodeURIComponent(projectId);
3044
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/conflicts`);
3045
+ const response = await fetch(url.toString(), {
3046
+ ...getFetchConfig(),
3047
+ method: "GET",
3048
+ });
3049
+ await handleGitLabError(response);
3050
+ return (await response.json());
3051
+ }
3010
3052
  async function getMergeRequestApprovalsFallback(projectId, mergeRequestIid) {
3011
3053
  const approvalsUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approvals`);
3012
3054
  const approvalsResponse = await fetch(approvalsUrl.toString(), {
@@ -3088,7 +3130,7 @@ noteableIid, body) {
3088
3130
  * @returns {Promise<GitLabDraftNote[]>} Array of draft notes
3089
3131
  */
3090
3132
  async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
3091
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}`);
3133
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}`, { ...getFetchConfig() });
3092
3134
  if (!response.ok) {
3093
3135
  const errorText = await response.text();
3094
3136
  throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
@@ -4181,11 +4223,8 @@ async function createPipeline(projectId, ref, variables, inputs) {
4181
4223
  body.inputs = inputs;
4182
4224
  }
4183
4225
  const response = await fetch(url.toString(), {
4226
+ ...getFetchConfig(),
4184
4227
  method: "POST",
4185
- headers: {
4186
- ...BASE_HEADERS,
4187
- ...buildAuthHeaders(),
4188
- },
4189
4228
  body: JSON.stringify(body),
4190
4229
  });
4191
4230
  await handleGitLabError(response);
@@ -4203,11 +4242,8 @@ async function retryPipeline(projectId, pipelineId) {
4203
4242
  projectId = decodeURIComponent(projectId); // Decode project ID
4204
4243
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
4205
4244
  const response = await fetch(url.toString(), {
4245
+ ...getFetchConfig(),
4206
4246
  method: "POST",
4207
- headers: {
4208
- ...BASE_HEADERS,
4209
- ...buildAuthHeaders(),
4210
- },
4211
4247
  });
4212
4248
  await handleGitLabError(response);
4213
4249
  const data = await response.json();
@@ -4224,11 +4260,8 @@ async function cancelPipeline(projectId, pipelineId) {
4224
4260
  projectId = decodeURIComponent(projectId); // Decode project ID
4225
4261
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
4226
4262
  const response = await fetch(url.toString(), {
4263
+ ...getFetchConfig(),
4227
4264
  method: "POST",
4228
- headers: {
4229
- ...BASE_HEADERS,
4230
- ...buildAuthHeaders(),
4231
- },
4232
4265
  });
4233
4266
  await handleGitLabError(response);
4234
4267
  const data = await response.json();
@@ -4319,13 +4352,7 @@ async function getRepositoryTree(options) {
4319
4352
  queryParams.append("page_token", options.page_token);
4320
4353
  if (options.pagination)
4321
4354
  queryParams.append("pagination", options.pagination);
4322
- const headers = {
4323
- ...BASE_HEADERS,
4324
- ...buildAuthHeaders(),
4325
- };
4326
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
4327
- headers,
4328
- });
4355
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
4329
4356
  if (response.status === 404) {
4330
4357
  throw new Error("Repository or path not found");
4331
4358
  }
@@ -4786,15 +4813,12 @@ async function markdownUpload(projectId, filePath) {
4786
4813
  contentType: "application/octet-stream",
4787
4814
  });
4788
4815
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads`);
4816
+ const defaultFetchConfig = getFetchConfig();
4817
+ delete defaultFetchConfig.headers["Content-Type"]; // Let form-data set the correct Content-Type with boundary
4789
4818
  const response = await fetch(url.toString(), {
4819
+ ...defaultFetchConfig,
4790
4820
  method: "POST",
4791
- headers: {
4792
- ...BASE_HEADERS,
4793
- ...buildAuthHeaders(),
4794
- // Remove Content-Type header to let form-data set it with boundary
4795
- "Content-Type": undefined,
4796
- },
4797
- body: form,
4821
+ body: form
4798
4822
  });
4799
4823
  if (!response.ok) {
4800
4824
  await handleGitLabError(response);
@@ -4820,11 +4844,8 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
4820
4844
  const effectiveProjectId = getEffectiveProjectId(projectId);
4821
4845
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
4822
4846
  const response = await fetch(url.toString(), {
4847
+ ...getFetchConfig(),
4823
4848
  method: "GET",
4824
- headers: {
4825
- ...BASE_HEADERS,
4826
- ...buildAuthHeaders(),
4827
- },
4828
4849
  });
4829
4850
  if (!response.ok) {
4830
4851
  await handleGitLabError(response);
@@ -4871,13 +4892,7 @@ async function listEvents(options = {}) {
4871
4892
  url.searchParams.append(key, value.toString());
4872
4893
  }
4873
4894
  });
4874
- const response = await fetch(url.toString(), {
4875
- method: "GET",
4876
- headers: {
4877
- ...BASE_HEADERS,
4878
- ...buildAuthHeaders(),
4879
- },
4880
- });
4895
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4881
4896
  if (!response.ok) {
4882
4897
  await handleGitLabError(response);
4883
4898
  }
@@ -4899,13 +4914,7 @@ async function getProjectEvents(projectId, options = {}) {
4899
4914
  url.searchParams.append(key, value.toString());
4900
4915
  }
4901
4916
  });
4902
- const response = await fetch(url.toString(), {
4903
- method: "GET",
4904
- headers: {
4905
- ...BASE_HEADERS,
4906
- ...buildAuthHeaders(),
4907
- },
4908
- });
4917
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4909
4918
  if (!response.ok) {
4910
4919
  await handleGitLabError(response);
4911
4920
  }
@@ -5396,6 +5405,13 @@ async function handleToolCall(params) {
5396
5405
  content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
5397
5406
  };
5398
5407
  }
5408
+ case "get_merge_request_conflicts": {
5409
+ const args = GetMergeRequestConflictsSchema.parse(params.arguments);
5410
+ const conflicts = await getMergeRequestConflicts(args.project_id, args.merge_request_iid);
5411
+ return {
5412
+ content: [{ type: "text", text: JSON.stringify(conflicts, null, 2) }],
5413
+ };
5414
+ }
5399
5415
  case "mr_discussions": {
5400
5416
  const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
5401
5417
  const { project_id, merge_request_iid, ...options } = args;
@@ -6306,6 +6322,10 @@ function determineTransportMode() {
6306
6322
  async function startStdioServer() {
6307
6323
  const serverInstance = createServer();
6308
6324
  const transport = new StdioServerTransport();
6325
+ transport.onclose = () => {
6326
+ logger.info("Stdio transport closed, releasing client pool");
6327
+ clientPool.closeAll();
6328
+ };
6309
6329
  await serverInstance.connect(transport);
6310
6330
  }
6311
6331
  /**
@@ -6314,6 +6334,7 @@ async function startStdioServer() {
6314
6334
  async function startSSEServer() {
6315
6335
  const app = express();
6316
6336
  const transports = {};
6337
+ let shuttingDown = false;
6317
6338
  app.get("/sse", async (_, res) => {
6318
6339
  const serverInstance = createServer();
6319
6340
  const transport = new SSEServerTransport("/messages", res);
@@ -6340,12 +6361,35 @@ async function startSSEServer() {
6340
6361
  transport: TransportMode.SSE,
6341
6362
  });
6342
6363
  });
6343
- app.listen(Number(PORT), HOST, () => {
6364
+ const httpServer = app.listen(Number(PORT), HOST, () => {
6344
6365
  logger.info(`GitLab MCP Server running with SSE transport`);
6345
6366
  const colorGreen = "\x1b[32m";
6346
6367
  const colorReset = "\x1b[0m";
6347
6368
  logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/sse${colorReset}`);
6348
6369
  });
6370
+ const shutdown = async (signal) => {
6371
+ if (shuttingDown)
6372
+ return;
6373
+ shuttingDown = true;
6374
+ logger.info(`${signal} received, shutting down SSE server...`);
6375
+ httpServer.close(() => logger.info("SSE HTTP server closed"));
6376
+ await Promise.allSettled(Object.values(transports).map(async (transport) => {
6377
+ try {
6378
+ await transport.close();
6379
+ }
6380
+ catch (error) {
6381
+ logger.error("Error closing SSE transport:", error);
6382
+ }
6383
+ }));
6384
+ clientPool.closeAll();
6385
+ process.exit(0);
6386
+ };
6387
+ process.on("SIGTERM", () => {
6388
+ void shutdown("SIGTERM");
6389
+ });
6390
+ process.on("SIGINT", () => {
6391
+ void shutdown("SIGINT");
6392
+ });
6349
6393
  }
6350
6394
  /**
6351
6395
  * Start server with Streamable HTTP transport
@@ -6399,10 +6443,12 @@ async function startStreamableHTTPServer() {
6399
6443
  /**
6400
6444
  * Parse authentication from request headers
6401
6445
  * Returns null if no auth found or invalid format
6446
+ * Supports: JOB-TOKEN header, Private-Token header, Authorization Bearer header
6402
6447
  */
6403
6448
  const parseAuthHeaders = (req) => {
6404
6449
  const authHeader = req.headers["authorization"] || "";
6405
6450
  const privateToken = req.headers["private-token"] || "";
6451
+ const jobToken = req.headers["job-token"] || "";
6406
6452
  const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
6407
6453
  let apiUrl = GITLAB_API_URL; // Default API URL
6408
6454
  // Only process dynamic URL if the feature is enabled
@@ -6419,7 +6465,11 @@ async function startStreamableHTTPServer() {
6419
6465
  // Extract token
6420
6466
  let token = null;
6421
6467
  let header = null;
6422
- if (privateToken) {
6468
+ if (jobToken) {
6469
+ token = jobToken.trim();
6470
+ header = "JOB-TOKEN";
6471
+ }
6472
+ else if (privateToken) {
6423
6473
  token = privateToken.trim();
6424
6474
  header = "Private-Token";
6425
6475
  }
@@ -6507,8 +6557,8 @@ async function startStreamableHTTPServer() {
6507
6557
  if (!authData) {
6508
6558
  metrics.authFailures++;
6509
6559
  res.status(401).json({
6510
- error: "Missing Authorization or Private-Token header",
6511
- message: "Remote authorization is enabled. Please provide Authorization or Private-Token header.",
6560
+ error: "Missing Authorization, Private-Token, or JOB-TOKEN header",
6561
+ message: "Remote authorization is enabled. Please provide Authorization, Private-Token, or JOB-TOKEN header.",
6512
6562
  });
6513
6563
  return;
6514
6564
  }
@@ -6706,6 +6756,7 @@ async function startStreamableHTTPServer() {
6706
6756
  Object.keys(authTimeouts).forEach(sessionId => {
6707
6757
  clearAuthTimeout(sessionId);
6708
6758
  });
6759
+ clientPool.closeAll();
6709
6760
  logger.info("Graceful shutdown complete");
6710
6761
  process.exit(0);
6711
6762
  };
package/build/oauth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import * as crypto from "crypto";
1
2
  import * as fs from "fs";
2
3
  import * as os from "os";
3
4
  import * as path from "path";
@@ -10,7 +11,11 @@ import { pino } from "pino";
10
11
  const logger = pino({
11
12
  name: "gitlab-mcp-oauth",
12
13
  level: process.env.LOG_LEVEL || "info",
13
- });
14
+ }, pino.destination(2));
15
+ function escapeHtml(str) {
16
+ const map = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
17
+ return String(str).replace(/[&<>"']/g, c => map[c] || c);
18
+ }
14
19
  // Track pending auth requests across multiple MCP instances
15
20
  const pendingAuthRequests = new Map();
16
21
  /**
@@ -225,7 +230,7 @@ export class GitLabOAuth {
225
230
  */
226
231
  async startOAuthFlow() {
227
232
  const callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888");
228
- const requestId = Math.random().toString(36).substring(7);
233
+ const requestId = crypto.randomUUID();
229
234
  // Check if port is already in use
230
235
  const portInUse = await isPortInUse(callbackPort);
231
236
  if (portInUse) {
@@ -250,7 +255,7 @@ export class GitLabOAuth {
250
255
  const requestIdToOAuthInstance = new Map();
251
256
  return new Promise((resolve, reject) => {
252
257
  // Create initial request
253
- const state = Math.random().toString(36).substring(7);
258
+ const state = crypto.randomUUID();
254
259
  stateToRequestId.set(state, initialRequestId);
255
260
  requestIdToOAuthInstance.set(initialRequestId, this);
256
261
  const timeout = setTimeout(() => {
@@ -271,7 +276,7 @@ export class GitLabOAuth {
271
276
  }
272
277
  logger.info(`Received auth request from another instance: ${newRequestId}`);
273
278
  // Create a new OAuth flow for this request
274
- const newState = Math.random().toString(36).substring(7);
279
+ const newState = crypto.randomUUID();
275
280
  stateToRequestId.set(newState, newRequestId);
276
281
  // Store a reference to use the same OAuth config
277
282
  requestIdToOAuthInstance.set(newRequestId, this);
@@ -315,7 +320,7 @@ export class GitLabOAuth {
315
320
  <html>
316
321
  <body>
317
322
  <h1>Authentication Failed</h1>
318
- <p>Error: ${error}</p>
323
+ <p>Error: ${escapeHtml(String(error))}</p>
319
324
  <p>You can close this window.</p>
320
325
  </body>
321
326
  </html>
@@ -523,7 +528,7 @@ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
523
528
  clientSecret,
524
529
  redirectUri,
525
530
  gitlabUrl,
526
- scopes: ["api"],
531
+ scopes: [process.env.GITLAB_READ_ONLY_MODE === "true" ? "read_api" : "api"],
527
532
  tokenStoragePath,
528
533
  });
529
534
  // Single call: triggers browser flow if needed, or reads cached token
package/build/schemas.js CHANGED
@@ -1333,6 +1333,9 @@ export const GitLabMergeRequestApprovalStateSchema = z.object({
1333
1333
  export const GetMergeRequestApprovalStateSchema = ProjectParamsSchema.extend({
1334
1334
  merge_request_iid: z.coerce.string().describe("The IID of the merge request"),
1335
1335
  });
1336
+ export const GetMergeRequestConflictsSchema = ProjectParamsSchema.extend({
1337
+ merge_request_iid: z.coerce.string().describe("The IID of the merge request"),
1338
+ });
1336
1339
  export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1337
1340
  view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"),
1338
1341
  excluded_file_patterns: z