@zereight/mcp-gitlab 2.0.21 → 2.0.23
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 +33 -5
- package/build/index.js +284 -137
- package/build/schemas.js +43 -12
- package/build/test/test-list-merge-requests.js +106 -0
- package/build/test/utils/mock-gitlab-server.js +65 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -6,12 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
## @zereight/mcp-gitlab
|
|
8
8
|
|
|
9
|
-
[](https://smithery.ai/server/@zereight/gitlab-mcp)
|
|
10
|
-
|
|
11
9
|
GitLab MCP(Model Context Protocol) Server. **Includes bug fixes and improvements over the original GitLab MCP server.**
|
|
12
10
|
|
|
13
|
-
<a href="https://glama.ai/mcp/servers/7jwbk4r6d7"><img width="380" height="200" src="https://glama.ai/mcp/servers/7jwbk4r6d7/badge" alt="gitlab mcp MCP server" /></a>
|
|
14
|
-
|
|
15
11
|
## Usage
|
|
16
12
|
|
|
17
13
|
### Using with Claude App, Cline, Roo Code, Cursor, Kilo Code
|
|
@@ -94,6 +90,38 @@ Then configure the MCP server with OAuth:
|
|
|
94
90
|
}
|
|
95
91
|
```
|
|
96
92
|
|
|
93
|
+
#### Using CLI Arguments (for clients with env var issues)
|
|
94
|
+
|
|
95
|
+
Some MCP clients (like GitHub Copilot CLI) have issues with environment variables. Use CLI arguments instead:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"mcpServers": {
|
|
100
|
+
"gitlab": {
|
|
101
|
+
"command": "npx",
|
|
102
|
+
"args": [
|
|
103
|
+
"-y",
|
|
104
|
+
"@zereight/mcp-gitlab",
|
|
105
|
+
"--token=YOUR_GITLAB_TOKEN",
|
|
106
|
+
"--api-url=https://gitlab.com/api/v4"
|
|
107
|
+
],
|
|
108
|
+
"tools": ["*"]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Available CLI arguments:**
|
|
115
|
+
|
|
116
|
+
- `--token` - GitLab Personal Access Token (replaces `GITLAB_PERSONAL_ACCESS_TOKEN`)
|
|
117
|
+
- `--api-url` - GitLab API URL (replaces `GITLAB_API_URL`)
|
|
118
|
+
- `--read-only=true` - Enable read-only mode (replaces `GITLAB_READ_ONLY_MODE`)
|
|
119
|
+
- `--use-wiki=true` - Enable wiki API (replaces `USE_GITLAB_WIKI`)
|
|
120
|
+
- `--use-milestone=true` - Enable milestone API (replaces `USE_MILESTONE`)
|
|
121
|
+
- `--use-pipeline=true` - Enable pipeline API (replaces `USE_PIPELINE`)
|
|
122
|
+
|
|
123
|
+
CLI arguments take precedence over environment variables.
|
|
124
|
+
|
|
97
125
|
#### vscode .vscode/mcp.json
|
|
98
126
|
|
|
99
127
|
**Using OAuth2 (Non-Confidential - Recommended):**
|
|
@@ -491,7 +519,7 @@ The token is stored per session (identified by `mcp-session-id` header) and reus
|
|
|
491
519
|
67. `play_pipeline_job` - Run a manual pipeline job
|
|
492
520
|
68. `retry_pipeline_job` - Retry a failed or canceled pipeline job
|
|
493
521
|
69. `cancel_pipeline_job` - Cancel a running pipeline job
|
|
494
|
-
70. `list_merge_requests` - List merge requests in a GitLab project with filtering options
|
|
522
|
+
70. `list_merge_requests` - List merge requests globally or in a specific GitLab project with filtering options (project_id is now optional)
|
|
495
523
|
71. `list_milestones` - List milestones in a GitLab project with filtering options
|
|
496
524
|
72. `get_milestone` - Get details of a specific milestone
|
|
497
525
|
73. `create_milestone` - Create a new milestone in a GitLab project
|
package/build/index.js
CHANGED
|
@@ -1,28 +1,45 @@
|
|
|
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";
|
|
11
29
|
import { HttpProxyAgent } from "http-proxy-agent";
|
|
12
30
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
13
31
|
import nodeFetch from "node-fetch";
|
|
14
|
-
import path, { dirname } from "path";
|
|
32
|
+
import path, { dirname } from "node:path";
|
|
15
33
|
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
16
34
|
import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
17
|
-
import { fileURLToPath } from "url";
|
|
35
|
+
import { fileURLToPath, URL } from "node:url";
|
|
18
36
|
import { z } from "zod";
|
|
19
37
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
20
38
|
import { initializeOAuth } from "./oauth.js";
|
|
21
39
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
22
40
|
// Add type imports for proxy agents
|
|
23
|
-
import { Agent } from "http";
|
|
24
|
-
import { Agent as HttpsAgent } from "https";
|
|
25
|
-
import { URL } from "url";
|
|
41
|
+
import { Agent } from "node:http";
|
|
42
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
26
43
|
import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
|
|
27
44
|
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
45
|
// pipeline job schemas
|
|
@@ -32,8 +49,8 @@ GitLabDiscussionNoteSchema, // Added
|
|
|
32
49
|
GitLabDiscussionSchema,
|
|
33
50
|
// Draft Notes Schemas
|
|
34
51
|
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";
|
|
52
|
+
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, 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";
|
|
53
|
+
import { randomUUID } from "node:crypto";
|
|
37
54
|
import { pino } from "pino";
|
|
38
55
|
const logger = pino({
|
|
39
56
|
level: process.env.LOG_LEVEL || "info",
|
|
@@ -68,8 +85,8 @@ try {
|
|
|
68
85
|
SERVER_VERSION = packageJson.version || SERVER_VERSION;
|
|
69
86
|
}
|
|
70
87
|
}
|
|
71
|
-
catch
|
|
72
|
-
//
|
|
88
|
+
catch {
|
|
89
|
+
// Intentionally ignored: version read failure is non-critical
|
|
73
90
|
}
|
|
74
91
|
const server = new Server({
|
|
75
92
|
name: "better-gitlab-mcp-server",
|
|
@@ -87,9 +104,9 @@ function validateConfiguration() {
|
|
|
87
104
|
// Validate SESSION_TIMEOUT_SECONDS
|
|
88
105
|
const timeoutStr = process.env.SESSION_TIMEOUT_SECONDS;
|
|
89
106
|
if (timeoutStr) {
|
|
90
|
-
const timeout = parseInt(timeoutStr);
|
|
107
|
+
const timeout = Number.parseInt(timeoutStr, 10);
|
|
91
108
|
// Allow values >=1 for testing purposes, but recommend 60-86400 for production
|
|
92
|
-
if (isNaN(timeout) || timeout < 1 || timeout > 86400) {
|
|
109
|
+
if (Number.isNaN(timeout) || timeout < 1 || timeout > 86400) {
|
|
93
110
|
errors.push(`SESSION_TIMEOUT_SECONDS must be between 1 and 86400 seconds, got: ${timeoutStr}`);
|
|
94
111
|
}
|
|
95
112
|
if (timeout < 60) {
|
|
@@ -99,80 +116,85 @@ function validateConfiguration() {
|
|
|
99
116
|
// Validate MAX_SESSIONS
|
|
100
117
|
const maxSessionsStr = process.env.MAX_SESSIONS;
|
|
101
118
|
if (maxSessionsStr) {
|
|
102
|
-
const maxSessions = parseInt(maxSessionsStr);
|
|
103
|
-
if (isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
|
|
119
|
+
const maxSessions = Number.parseInt(maxSessionsStr, 10);
|
|
120
|
+
if (Number.isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
|
|
104
121
|
errors.push(`MAX_SESSIONS must be between 1 and 10000, got: ${maxSessionsStr}`);
|
|
105
122
|
}
|
|
106
123
|
}
|
|
107
124
|
// Validate MAX_REQUESTS_PER_MINUTE
|
|
108
125
|
const maxReqStr = process.env.MAX_REQUESTS_PER_MINUTE;
|
|
109
126
|
if (maxReqStr) {
|
|
110
|
-
const maxReq = parseInt(maxReqStr);
|
|
111
|
-
if (isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
|
|
127
|
+
const maxReq = Number.parseInt(maxReqStr, 10);
|
|
128
|
+
if (Number.isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
|
|
112
129
|
errors.push(`MAX_REQUESTS_PER_MINUTE must be between 1 and 1000, got: ${maxReqStr}`);
|
|
113
130
|
}
|
|
114
131
|
}
|
|
115
132
|
// Validate PORT
|
|
116
|
-
const portStr =
|
|
133
|
+
const portStr = getConfig('port', 'PORT');
|
|
117
134
|
if (portStr) {
|
|
118
|
-
const port = parseInt(portStr);
|
|
119
|
-
if (isNaN(port) || port < 1 || port > 65535) {
|
|
135
|
+
const port = Number.parseInt(portStr, 10);
|
|
136
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
120
137
|
errors.push(`PORT must be between 1 and 65535, got: ${portStr}`);
|
|
121
138
|
}
|
|
122
139
|
}
|
|
123
140
|
// Validate GITLAB_API_URL format
|
|
124
|
-
const apiUrls =
|
|
141
|
+
const apiUrls = getConfig('api-url', 'GITLAB_API_URL')?.split(",") || [];
|
|
125
142
|
if (apiUrls.length > 0) {
|
|
126
143
|
for (const url of apiUrls) {
|
|
127
144
|
try {
|
|
128
145
|
new URL(url.trim());
|
|
129
146
|
}
|
|
130
|
-
catch
|
|
147
|
+
catch {
|
|
131
148
|
errors.push(`GITLAB_API_URL contains an invalid URL: ${url.trim()}`);
|
|
132
149
|
}
|
|
133
150
|
}
|
|
134
151
|
}
|
|
135
152
|
// Validate auth configuration
|
|
136
|
-
const remoteAuth =
|
|
137
|
-
const useOAuth =
|
|
138
|
-
const hasToken = !!
|
|
139
|
-
const hasCookie = !!
|
|
153
|
+
const remoteAuth = getConfig('remote-auth', 'REMOTE_AUTHORIZATION') === "true";
|
|
154
|
+
const useOAuth = getConfig('use-oauth', 'GITLAB_USE_OAUTH') === "true";
|
|
155
|
+
const hasToken = !!getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
|
|
156
|
+
const hasCookie = !!getConfig('cookie-path', 'GITLAB_AUTH_COOKIE_PATH');
|
|
140
157
|
if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
|
|
141
|
-
errors.push('Either
|
|
158
|
+
errors.push('Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)');
|
|
142
159
|
}
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
const enableDynamicApiUrl = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
|
|
161
|
+
if (enableDynamicApiUrl && !remoteAuth) {
|
|
162
|
+
errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
|
|
145
163
|
}
|
|
146
164
|
if (errors.length > 0) {
|
|
147
|
-
logger.error(
|
|
165
|
+
logger.error("Configuration validation failed:");
|
|
148
166
|
errors.forEach(err => logger.error(` - ${err}`));
|
|
149
167
|
process.exit(1);
|
|
150
168
|
}
|
|
151
|
-
logger.info(
|
|
169
|
+
logger.info("Configuration validation passed");
|
|
152
170
|
}
|
|
153
|
-
const GITLAB_PERSONAL_ACCESS_TOKEN =
|
|
171
|
+
const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
|
|
154
172
|
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
|
|
173
|
+
const GITLAB_AUTH_COOKIE_PATH = getConfig('cookie-path', 'GITLAB_AUTH_COOKIE_PATH');
|
|
174
|
+
const USE_OAUTH = getConfig('use-oauth', 'GITLAB_USE_OAUTH') === "true";
|
|
175
|
+
const IS_OLD = getConfig('is-old', 'GITLAB_IS_OLD') === "true";
|
|
176
|
+
const GITLAB_READ_ONLY_MODE = getConfig('read-only', 'GITLAB_READ_ONLY_MODE') === "true";
|
|
177
|
+
const GITLAB_DENIED_TOOLS_REGEX = getConfig('denied-tools-regex', 'GITLAB_DENIED_TOOLS_REGEX')
|
|
178
|
+
? new RegExp(getConfig('denied-tools-regex', 'GITLAB_DENIED_TOOLS_REGEX'))
|
|
179
|
+
: undefined;
|
|
180
|
+
const USE_GITLAB_WIKI = getConfig('use-wiki', 'USE_GITLAB_WIKI') === "true";
|
|
181
|
+
const USE_MILESTONE = getConfig('use-milestone', 'USE_MILESTONE') === "true";
|
|
182
|
+
const USE_PIPELINE = getConfig('use-pipeline', 'USE_PIPELINE') === "true";
|
|
183
|
+
const SSE = getConfig('sse', 'SSE') === "true";
|
|
184
|
+
const STREAMABLE_HTTP = getConfig('streamable-http', 'STREAMABLE_HTTP') === "true";
|
|
185
|
+
const REMOTE_AUTHORIZATION = getConfig('remote-auth', 'REMOTE_AUTHORIZATION') === "true";
|
|
186
|
+
const ENABLE_DYNAMIC_API_URL = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
|
|
187
|
+
const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig('session-timeout', 'SESSION_TIMEOUT_SECONDS', '3600'), 10);
|
|
188
|
+
const HOST = getConfig('host', 'HOST') || '127.0.0.1';
|
|
189
|
+
const PORT = Number.parseInt(getConfig('port', 'PORT', '3002'), 10);
|
|
170
190
|
// 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 =
|
|
191
|
+
const HTTP_PROXY = getConfig('http-proxy', 'HTTP_PROXY');
|
|
192
|
+
const HTTPS_PROXY = getConfig('https-proxy', 'HTTPS_PROXY');
|
|
193
|
+
const NODE_TLS_REJECT_UNAUTHORIZED = getConfig('tls-reject-unauthorized', 'NODE_TLS_REJECT_UNAUTHORIZED');
|
|
194
|
+
const GITLAB_CA_CERT_PATH = getConfig('ca-cert-path', 'GITLAB_CA_CERT_PATH');
|
|
195
|
+
const GITLAB_POOL_MAX_SIZE = getConfig('pool-max-size', 'GITLAB_POOL_MAX_SIZE')
|
|
196
|
+
? Number.parseInt(getConfig('pool-max-size', 'GITLAB_POOL_MAX_SIZE'), 10)
|
|
197
|
+
: 100;
|
|
176
198
|
let sslOptions = undefined;
|
|
177
199
|
if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
|
|
178
200
|
sslOptions = { rejectUnauthorized: false };
|
|
@@ -204,7 +226,9 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
|
|
|
204
226
|
httpAgent = httpAgent || new Agent();
|
|
205
227
|
// Initialize the client pool for managing multiple GitLab instances
|
|
206
228
|
const clientPool = new GitLabClientPool({
|
|
207
|
-
apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
|
|
229
|
+
apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
|
|
230
|
+
.split(",")
|
|
231
|
+
.map(normalizeGitLabApiUrl),
|
|
208
232
|
httpProxy: HTTP_PROXY,
|
|
209
233
|
httpsProxy: HTTPS_PROXY,
|
|
210
234
|
rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
|
|
@@ -235,7 +259,11 @@ const createCookieJar = () => {
|
|
|
235
259
|
if (parts.length >= 7) {
|
|
236
260
|
const [domain, , path, secure, expires, name, value] = parts;
|
|
237
261
|
// Build cookie string in standard format
|
|
238
|
-
const
|
|
262
|
+
const secureFlag = secure === "TRUE" ? "; Secure" : "";
|
|
263
|
+
const expiresFlag = expires === "0"
|
|
264
|
+
? ""
|
|
265
|
+
: `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
|
|
266
|
+
const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secureFlag}${expiresFlag}`;
|
|
239
267
|
// Use tough-cookie's parse function for robust parsing
|
|
240
268
|
const cookie = parseCookie(cookieStr);
|
|
241
269
|
if (cookie) {
|
|
@@ -276,8 +304,8 @@ async function ensureSessionForRequest() {
|
|
|
276
304
|
// Small delay to ensure cookies are fully processed
|
|
277
305
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
278
306
|
}
|
|
279
|
-
catch
|
|
280
|
-
//
|
|
307
|
+
catch {
|
|
308
|
+
// Intentionally ignored: session establishment errors are non-critical
|
|
281
309
|
}
|
|
282
310
|
}
|
|
283
311
|
}
|
|
@@ -299,9 +327,9 @@ function buildAuthHeaders() {
|
|
|
299
327
|
if (REMOTE_AUTHORIZATION) {
|
|
300
328
|
const ctx = sessionAuthStore.getStore();
|
|
301
329
|
logger.debug({ context: ctx }, "buildAuthHeaders: session context");
|
|
302
|
-
if (ctx
|
|
330
|
+
if (ctx?.token) {
|
|
303
331
|
return {
|
|
304
|
-
[ctx.header]: ctx.header ===
|
|
332
|
+
[ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
|
|
305
333
|
};
|
|
306
334
|
}
|
|
307
335
|
return {}; // No auth headers if no session context
|
|
@@ -309,7 +337,7 @@ function buildAuthHeaders() {
|
|
|
309
337
|
// Standard mode: prioritize OAuth token, then fall back to environment token
|
|
310
338
|
const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
311
339
|
if (IS_OLD && token) {
|
|
312
|
-
return {
|
|
340
|
+
return { "Private-Token": String(token) };
|
|
313
341
|
}
|
|
314
342
|
if (token) {
|
|
315
343
|
return { Authorization: `Bearer ${token}` };
|
|
@@ -324,7 +352,7 @@ function buildAuthHeaders() {
|
|
|
324
352
|
function getEffectiveApiUrl() {
|
|
325
353
|
if (ENABLE_DYNAMIC_API_URL) {
|
|
326
354
|
const ctx = sessionAuthStore.getStore();
|
|
327
|
-
if (ctx
|
|
355
|
+
if (ctx?.apiUrl) {
|
|
328
356
|
return ctx.apiUrl;
|
|
329
357
|
}
|
|
330
358
|
logger.warn({ ctx }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
|
|
@@ -349,7 +377,46 @@ const getFetchConfig = () => {
|
|
|
349
377
|
agent: agent,
|
|
350
378
|
};
|
|
351
379
|
};
|
|
352
|
-
const toJSONSchema = (schema) =>
|
|
380
|
+
const toJSONSchema = (schema) => {
|
|
381
|
+
const jsonSchema = zodToJsonSchema(schema, { $refStrategy: 'none' });
|
|
382
|
+
// Post-process to fix nullable/optional fields that should truly be optional
|
|
383
|
+
function fixNullableOptional(obj) {
|
|
384
|
+
if (obj && typeof obj === 'object') {
|
|
385
|
+
// If this object has properties, process them
|
|
386
|
+
if (obj.properties) {
|
|
387
|
+
const requiredSet = new Set(obj.required || []);
|
|
388
|
+
Object.keys(obj.properties).forEach(key => {
|
|
389
|
+
const prop = obj.properties[key];
|
|
390
|
+
// Handle fields that can be null or omitted
|
|
391
|
+
// If a property has type: ["object", "null"] or anyOf with null, it should not be required
|
|
392
|
+
if (prop.anyOf && prop.anyOf.some((t) => t.type === 'null')) {
|
|
393
|
+
requiredSet.delete(key);
|
|
394
|
+
}
|
|
395
|
+
else if (Array.isArray(prop.type) && prop.type.includes('null')) {
|
|
396
|
+
requiredSet.delete(key);
|
|
397
|
+
}
|
|
398
|
+
// Recursively process nested objects
|
|
399
|
+
obj.properties[key] = fixNullableOptional(prop);
|
|
400
|
+
});
|
|
401
|
+
// Normalize the required array after processing all properties
|
|
402
|
+
if (requiredSet.size > 0) {
|
|
403
|
+
obj.required = Array.from(requiredSet);
|
|
404
|
+
}
|
|
405
|
+
else if (Object.prototype.hasOwnProperty.call(obj, 'required')) {
|
|
406
|
+
delete obj.required;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Process anyOf/allOf/oneOf
|
|
410
|
+
['anyOf', 'allOf', 'oneOf'].forEach(combiner => {
|
|
411
|
+
if (obj[combiner]) {
|
|
412
|
+
obj[combiner] = obj[combiner].map(fixNullableOptional);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return obj;
|
|
417
|
+
}
|
|
418
|
+
return fixNullableOptional(jsonSchema);
|
|
419
|
+
};
|
|
353
420
|
// Define all available tools
|
|
354
421
|
const allTools = [
|
|
355
422
|
{
|
|
@@ -422,6 +489,16 @@ const allTools = [
|
|
|
422
489
|
description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
|
|
423
490
|
inputSchema: toJSONSchema(ListMergeRequestDiffsSchema),
|
|
424
491
|
},
|
|
492
|
+
{
|
|
493
|
+
name: "list_merge_request_versions",
|
|
494
|
+
description: "List all versions of a merge request",
|
|
495
|
+
inputSchema: toJSONSchema(ListMergeRequestVersionsSchema),
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "get_merge_request_version",
|
|
499
|
+
description: "Get a specific version of a merge request",
|
|
500
|
+
inputSchema: toJSONSchema(GetMergeRequestVersionSchema),
|
|
501
|
+
},
|
|
425
502
|
{
|
|
426
503
|
name: "get_branch_diffs",
|
|
427
504
|
description: "Get the changes/diffs between two branches or commits in a GitLab project",
|
|
@@ -443,7 +520,7 @@ const allTools = [
|
|
|
443
520
|
inputSchema: toJSONSchema(CreateMergeRequestThreadSchema),
|
|
444
521
|
},
|
|
445
522
|
{
|
|
446
|
-
name:
|
|
523
|
+
name: "resolve_merge_request_thread",
|
|
447
524
|
description: "Resolve a thread on a merge request",
|
|
448
525
|
inputSchema: toJSONSchema(ResolveMergeRequestThreadSchema),
|
|
449
526
|
},
|
|
@@ -483,7 +560,7 @@ const allTools = [
|
|
|
483
560
|
inputSchema: toJSONSchema(GetMergeRequestNoteSchema),
|
|
484
561
|
},
|
|
485
562
|
{
|
|
486
|
-
name:
|
|
563
|
+
name: "get_merge_request_notes",
|
|
487
564
|
description: "List notes for a merge request",
|
|
488
565
|
inputSchema: toJSONSchema(GetMergeRequestNotesSchema),
|
|
489
566
|
},
|
|
@@ -739,7 +816,7 @@ const allTools = [
|
|
|
739
816
|
},
|
|
740
817
|
{
|
|
741
818
|
name: "list_merge_requests",
|
|
742
|
-
description: "List merge requests
|
|
819
|
+
description: "List merge requests. Without project_id, lists MRs assigned to the authenticated user by default (use scope='all' for all accessible MRs). With project_id, lists MRs for that specific project.",
|
|
743
820
|
inputSchema: toJSONSchema(ListMergeRequestsSchema),
|
|
744
821
|
},
|
|
745
822
|
{
|
|
@@ -869,12 +946,14 @@ const allTools = [
|
|
|
869
946
|
},
|
|
870
947
|
];
|
|
871
948
|
// Define which tools are read-only
|
|
872
|
-
const readOnlyTools = [
|
|
949
|
+
const readOnlyTools = new Set([
|
|
873
950
|
"search_repositories",
|
|
874
951
|
"execute_graphql",
|
|
875
952
|
"get_file_contents",
|
|
876
953
|
"get_merge_request",
|
|
877
954
|
"get_merge_request_diffs",
|
|
955
|
+
"list_merge_request_versions",
|
|
956
|
+
"get_merge_request_version",
|
|
878
957
|
"get_branch_diffs",
|
|
879
958
|
"mr_discussions",
|
|
880
959
|
"list_issues",
|
|
@@ -919,18 +998,18 @@ const readOnlyTools = [
|
|
|
919
998
|
"list_releases",
|
|
920
999
|
"get_release",
|
|
921
1000
|
"download_release_asset",
|
|
922
|
-
];
|
|
1001
|
+
]);
|
|
923
1002
|
// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
|
|
924
|
-
const wikiToolNames = [
|
|
1003
|
+
const wikiToolNames = new Set([
|
|
925
1004
|
"list_wiki_pages",
|
|
926
1005
|
"get_wiki_page",
|
|
927
1006
|
"create_wiki_page",
|
|
928
1007
|
"update_wiki_page",
|
|
929
1008
|
"delete_wiki_page",
|
|
930
1009
|
"upload_wiki_attachment",
|
|
931
|
-
];
|
|
1010
|
+
]);
|
|
932
1011
|
// Define which tools are related to milestones and can be toggled by USE_MILESTONE
|
|
933
|
-
const milestoneToolNames = [
|
|
1012
|
+
const milestoneToolNames = new Set([
|
|
934
1013
|
"list_milestones",
|
|
935
1014
|
"get_milestone",
|
|
936
1015
|
"create_milestone",
|
|
@@ -940,9 +1019,9 @@ const milestoneToolNames = [
|
|
|
940
1019
|
"get_milestone_merge_requests",
|
|
941
1020
|
"promote_milestone",
|
|
942
1021
|
"get_milestone_burndown_events",
|
|
943
|
-
];
|
|
1022
|
+
]);
|
|
944
1023
|
// Define which tools are related to pipelines and can be toggled by USE_PIPELINE
|
|
945
|
-
const pipelineToolNames = [
|
|
1024
|
+
const pipelineToolNames = new Set([
|
|
946
1025
|
"list_pipelines",
|
|
947
1026
|
"get_pipeline",
|
|
948
1027
|
"list_pipeline_jobs",
|
|
@@ -955,7 +1034,7 @@ const pipelineToolNames = [
|
|
|
955
1034
|
"play_pipeline_job",
|
|
956
1035
|
"retry_pipeline_job",
|
|
957
1036
|
"cancel_pipeline_job",
|
|
958
|
-
];
|
|
1037
|
+
]);
|
|
959
1038
|
/**
|
|
960
1039
|
* Smart URL handling for GitLab API
|
|
961
1040
|
*
|
|
@@ -976,11 +1055,17 @@ function normalizeGitLabApiUrl(url) {
|
|
|
976
1055
|
return normalizedUrl;
|
|
977
1056
|
}
|
|
978
1057
|
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
979
|
-
const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
|
|
1058
|
+
const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
|
|
1059
|
+
.split(",")
|
|
1060
|
+
.map(normalizeGitLabApiUrl);
|
|
980
1061
|
const GITLAB_API_URL = GITLAB_API_URLS[0];
|
|
981
1062
|
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
|
|
982
|
-
const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(
|
|
983
|
-
|
|
1063
|
+
const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(",")
|
|
1064
|
+
.map(id => id.trim())
|
|
1065
|
+
.filter(Boolean) || [];
|
|
1066
|
+
const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE
|
|
1067
|
+
? Number.parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE, 10)
|
|
1068
|
+
: 20;
|
|
984
1069
|
// Validate authentication configuration
|
|
985
1070
|
if (REMOTE_AUTHORIZATION) {
|
|
986
1071
|
// Remote authorization mode: token comes from HTTP headers
|
|
@@ -1037,11 +1122,11 @@ function getEffectiveProjectId(projectId) {
|
|
|
1037
1122
|
}
|
|
1038
1123
|
// If a project ID is provided, check if it's in the whitelist
|
|
1039
1124
|
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(
|
|
1125
|
+
throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}`);
|
|
1041
1126
|
}
|
|
1042
1127
|
// If no project ID provided but we have multiple allowed projects, require an explicit choice
|
|
1043
1128
|
if (!projectId && GITLAB_ALLOWED_PROJECT_IDS.length > 1) {
|
|
1044
|
-
throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(
|
|
1129
|
+
throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}). Please specify a project ID.`);
|
|
1045
1130
|
}
|
|
1046
1131
|
return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
|
|
1047
1132
|
}
|
|
@@ -1229,15 +1314,19 @@ async function listIssues(projectId, options = {}) {
|
|
|
1229
1314
|
return z.array(GitLabIssueSchema).parse(data);
|
|
1230
1315
|
}
|
|
1231
1316
|
/**
|
|
1232
|
-
* List merge requests
|
|
1317
|
+
* List merge requests globally or for a specific GitLab project
|
|
1233
1318
|
*
|
|
1234
|
-
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
1319
|
+
* @param {string} [projectId] - The ID or URL-encoded path of the project.
|
|
1320
|
+
* If omitted, lists MRs assigned to the authenticated user by default.
|
|
1235
1321
|
* @param {Object} options - Optional filtering parameters
|
|
1236
1322
|
* @returns {Promise<GitLabMergeRequest[]>} List of merge requests
|
|
1237
1323
|
*/
|
|
1238
1324
|
async function listMergeRequests(projectId, options = {}) {
|
|
1239
|
-
|
|
1240
|
-
const
|
|
1325
|
+
const decodedProjectId = projectId ? decodeURIComponent(projectId) : undefined;
|
|
1326
|
+
const endpoint = decodedProjectId
|
|
1327
|
+
? `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(decodedProjectId))}/merge_requests`
|
|
1328
|
+
: `${getEffectiveApiUrl()}/merge_requests`;
|
|
1329
|
+
const url = new URL(endpoint);
|
|
1241
1330
|
// Add all query parameters
|
|
1242
1331
|
Object.entries(options).forEach(([key, value]) => {
|
|
1243
1332
|
if (value !== undefined) {
|
|
@@ -1469,18 +1558,22 @@ async function listDiscussions(projectId, resourceType, resourceIid, options = {
|
|
|
1469
1558
|
// Extract pagination headers
|
|
1470
1559
|
const pagination = {
|
|
1471
1560
|
x_next_page: response.headers.get("x-next-page")
|
|
1472
|
-
? parseInt(response.headers.get("x-next-page"))
|
|
1561
|
+
? Number.parseInt(response.headers.get("x-next-page"), 10)
|
|
1473
1562
|
: null,
|
|
1474
|
-
x_page: response.headers.get("x-page")
|
|
1563
|
+
x_page: response.headers.get("x-page")
|
|
1564
|
+
? Number.parseInt(response.headers.get("x-page"), 10)
|
|
1565
|
+
: undefined,
|
|
1475
1566
|
x_per_page: response.headers.get("x-per-page")
|
|
1476
|
-
? parseInt(response.headers.get("x-per-page"))
|
|
1567
|
+
? Number.parseInt(response.headers.get("x-per-page"), 10)
|
|
1477
1568
|
: undefined,
|
|
1478
1569
|
x_prev_page: response.headers.get("x-prev-page")
|
|
1479
|
-
? parseInt(response.headers.get("x-prev-page"))
|
|
1570
|
+
? Number.parseInt(response.headers.get("x-prev-page"), 10)
|
|
1571
|
+
: null,
|
|
1572
|
+
x_total: response.headers.get("x-total")
|
|
1573
|
+
? Number.parseInt(response.headers.get("x-total"), 10)
|
|
1480
1574
|
: null,
|
|
1481
|
-
x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")) : null,
|
|
1482
1575
|
x_total_pages: response.headers.get("x-total-pages")
|
|
1483
|
-
? parseInt(response.headers.get("x-total-pages"))
|
|
1576
|
+
? Number.parseInt(response.headers.get("x-total-pages"), 10)
|
|
1484
1577
|
: null,
|
|
1485
1578
|
};
|
|
1486
1579
|
return PaginatedDiscussionsResponseSchema.parse({
|
|
@@ -1877,10 +1970,10 @@ async function searchProjects(query, page = 1, perPage = 20) {
|
|
|
1877
1970
|
const totalCount = response.headers.get("x-total");
|
|
1878
1971
|
const totalPages = response.headers.get("x-total-pages");
|
|
1879
1972
|
// GitLab API doesn't return these headers for results > 10,000
|
|
1880
|
-
const count = totalCount ? parseInt(totalCount) : projects.length;
|
|
1973
|
+
const count = totalCount ? Number.parseInt(totalCount, 10) : projects.length;
|
|
1881
1974
|
return GitLabSearchResponseSchema.parse({
|
|
1882
1975
|
count,
|
|
1883
|
-
total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage),
|
|
1976
|
+
total_pages: totalPages ? Number.parseInt(totalPages, 10) : Math.ceil(count / perPage),
|
|
1884
1977
|
current_page: page,
|
|
1885
1978
|
items: projects,
|
|
1886
1979
|
});
|
|
@@ -1902,7 +1995,7 @@ async function createRepository(options) {
|
|
|
1902
1995
|
visibility: options.visibility,
|
|
1903
1996
|
initialize_with_readme: options.initialize_with_readme,
|
|
1904
1997
|
default_branch: "main",
|
|
1905
|
-
path: options.name.toLowerCase().
|
|
1998
|
+
path: options.name.toLowerCase().replaceAll(/\s+/g, "-"),
|
|
1906
1999
|
}),
|
|
1907
2000
|
});
|
|
1908
2001
|
if (!response.ok) {
|
|
@@ -2246,7 +2339,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
2246
2339
|
}
|
|
2247
2340
|
// Handle empty response (204 No Content) or successful response
|
|
2248
2341
|
const responseText = await response.text();
|
|
2249
|
-
if (!responseText || responseText.trim() ===
|
|
2342
|
+
if (!responseText || responseText.trim() === "") {
|
|
2250
2343
|
// Return a success indicator for empty responses
|
|
2251
2344
|
return {
|
|
2252
2345
|
id: draftNoteId.toString(),
|
|
@@ -2256,7 +2349,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
2256
2349
|
updated_at: new Date().toISOString(),
|
|
2257
2350
|
system: false,
|
|
2258
2351
|
noteable_id: mergeRequestIid.toString(),
|
|
2259
|
-
noteable_type: "MergeRequest"
|
|
2352
|
+
noteable_type: "MergeRequest",
|
|
2260
2353
|
};
|
|
2261
2354
|
}
|
|
2262
2355
|
try {
|
|
@@ -2275,7 +2368,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
2275
2368
|
updated_at: new Date().toISOString(),
|
|
2276
2369
|
system: false,
|
|
2277
2370
|
noteable_id: mergeRequestIid.toString(),
|
|
2278
|
-
noteable_type: "MergeRequest"
|
|
2371
|
+
noteable_type: "MergeRequest",
|
|
2279
2372
|
};
|
|
2280
2373
|
}
|
|
2281
2374
|
}
|
|
@@ -2299,7 +2392,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
|
|
|
2299
2392
|
}
|
|
2300
2393
|
// Handle empty response (204 No Content) or successful response
|
|
2301
2394
|
const responseText = await response.text();
|
|
2302
|
-
if (!responseText || responseText.trim() ===
|
|
2395
|
+
if (!responseText || responseText.trim() === "") {
|
|
2303
2396
|
// Return empty array for successful bulk publish with no content
|
|
2304
2397
|
return [];
|
|
2305
2398
|
}
|
|
@@ -2350,7 +2443,6 @@ async function createMergeRequestThread(projectId, mergeRequestIid, body, positi
|
|
|
2350
2443
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
2351
2444
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions`);
|
|
2352
2445
|
const payload = { body };
|
|
2353
|
-
// Add optional parameters if provided
|
|
2354
2446
|
if (position) {
|
|
2355
2447
|
payload.position = position;
|
|
2356
2448
|
}
|
|
@@ -2366,6 +2458,46 @@ async function createMergeRequestThread(projectId, mergeRequestIid, body, positi
|
|
|
2366
2458
|
const data = await response.json();
|
|
2367
2459
|
return GitLabDiscussionSchema.parse(data);
|
|
2368
2460
|
}
|
|
2461
|
+
/**
|
|
2462
|
+
* List all versions of a merge request
|
|
2463
|
+
* 병합 요청의 모든 버전 목록 조회
|
|
2464
|
+
*
|
|
2465
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2466
|
+
* @param {number} mergeRequestIid - The internal ID of the merge request
|
|
2467
|
+
* @returns {Promise<GitLabMergeRequestVersion[]>} List of merge request versions
|
|
2468
|
+
*/
|
|
2469
|
+
async function listMergeRequestVersions(projectId, mergeRequestIid) {
|
|
2470
|
+
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
2471
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/versions`);
|
|
2472
|
+
const response = await fetch(url.toString(), {
|
|
2473
|
+
...getFetchConfig(),
|
|
2474
|
+
});
|
|
2475
|
+
await handleGitLabError(response);
|
|
2476
|
+
const data = await response.json();
|
|
2477
|
+
return z.array(GitLabMergeRequestVersionSchema).parse(data);
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Get a specific version of a merge request
|
|
2481
|
+
* 병합 요청의 특정 버전 상세 정보 조회
|
|
2482
|
+
*
|
|
2483
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2484
|
+
* @param {number} mergeRequestIid - The internal ID of the merge request
|
|
2485
|
+
* @param {number} versionId - The ID of the version
|
|
2486
|
+
* @returns {Promise<GitLabMergeRequestVersionDetail>} The merge request version details
|
|
2487
|
+
*/
|
|
2488
|
+
async function getMergeRequestVersion(projectId, mergeRequestIid, versionId, unidiff) {
|
|
2489
|
+
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
2490
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/versions/${versionId}`);
|
|
2491
|
+
if (unidiff !== undefined) {
|
|
2492
|
+
url.searchParams.append("unidiff", String(unidiff));
|
|
2493
|
+
}
|
|
2494
|
+
const response = await fetch(url.toString(), {
|
|
2495
|
+
...getFetchConfig(),
|
|
2496
|
+
});
|
|
2497
|
+
await handleGitLabError(response);
|
|
2498
|
+
const data = await response.json();
|
|
2499
|
+
return GitLabMergeRequestVersionDetailSchema.parse(data);
|
|
2500
|
+
}
|
|
2369
2501
|
/**
|
|
2370
2502
|
* List all namespaces
|
|
2371
2503
|
* 사용 가능한 모든 네임스페이스 목록 조회
|
|
@@ -3023,7 +3155,7 @@ async function cancelPipelineJob(projectId, jobId, force) {
|
|
|
3023
3155
|
projectId = decodeURIComponent(projectId);
|
|
3024
3156
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/cancel`);
|
|
3025
3157
|
if (force !== undefined) {
|
|
3026
|
-
url.searchParams.append(
|
|
3158
|
+
url.searchParams.append("force", force.toString());
|
|
3027
3159
|
}
|
|
3028
3160
|
const response = await fetch(url.toString(), {
|
|
3029
3161
|
...getFetchConfig(),
|
|
@@ -3740,24 +3872,19 @@ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
|
|
|
3740
3872
|
return await response.text();
|
|
3741
3873
|
}
|
|
3742
3874
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3743
|
-
// In remote auth mode, retrieve session context from AsyncLocalStorage
|
|
3744
|
-
// This ensures the context is available even when called from SDK's async chains
|
|
3745
|
-
const sessionContext = REMOTE_AUTHORIZATION ? sessionAuthStore.getStore() : null;
|
|
3746
3875
|
// Apply read-only filter first
|
|
3747
3876
|
const tools0 = GITLAB_READ_ONLY_MODE
|
|
3748
|
-
? allTools.filter(tool => readOnlyTools.
|
|
3877
|
+
? allTools.filter(tool => readOnlyTools.has(tool.name))
|
|
3749
3878
|
: allTools;
|
|
3750
3879
|
// Toggle wiki tools by USE_GITLAB_WIKI flag
|
|
3751
|
-
const tools1 = USE_GITLAB_WIKI
|
|
3752
|
-
? tools0
|
|
3753
|
-
: tools0.filter(tool => !wikiToolNames.includes(tool.name));
|
|
3880
|
+
const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
|
|
3754
3881
|
// Toggle milestone tools by USE_MILESTONE flag
|
|
3755
|
-
const tools2 = USE_MILESTONE
|
|
3756
|
-
? tools1
|
|
3757
|
-
: tools1.filter(tool => !milestoneToolNames.includes(tool.name));
|
|
3882
|
+
const tools2 = USE_MILESTONE ? tools1 : tools1.filter(tool => !milestoneToolNames.has(tool.name));
|
|
3758
3883
|
// Toggle pipeline tools by USE_PIPELINE flag
|
|
3759
|
-
let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.
|
|
3760
|
-
tools = GITLAB_DENIED_TOOLS_REGEX
|
|
3884
|
+
let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
|
|
3885
|
+
tools = GITLAB_DENIED_TOOLS_REGEX
|
|
3886
|
+
? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
|
|
3887
|
+
: tools;
|
|
3761
3888
|
// <<< START: Gemini 호환성을 위해 $schema 제거 >>>
|
|
3762
3889
|
tools = tools.map(tool => {
|
|
3763
3890
|
// inputSchema가 존재하고 객체인지 확인
|
|
@@ -4006,21 +4133,21 @@ async function handleToolCall(params) {
|
|
|
4006
4133
|
content: [{ type: "text", text: "Merge request note deleted successfully" }],
|
|
4007
4134
|
};
|
|
4008
4135
|
}
|
|
4009
|
-
case
|
|
4136
|
+
case "get_merge_request_note": {
|
|
4010
4137
|
const args = GetMergeRequestNoteSchema.parse(params.arguments);
|
|
4011
4138
|
const note = await getMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id);
|
|
4012
4139
|
return {
|
|
4013
4140
|
content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
|
|
4014
4141
|
};
|
|
4015
4142
|
}
|
|
4016
|
-
case
|
|
4143
|
+
case "get_merge_request_notes": {
|
|
4017
4144
|
const args = GetMergeRequestNotesSchema.parse(params.arguments);
|
|
4018
4145
|
const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
|
|
4019
4146
|
return {
|
|
4020
4147
|
content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
|
|
4021
4148
|
};
|
|
4022
4149
|
}
|
|
4023
|
-
case
|
|
4150
|
+
case "update_merge_request_note": {
|
|
4024
4151
|
const args = UpdateMergeRequestNoteSchema.parse(params.arguments);
|
|
4025
4152
|
const note = await updateMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id, args.body);
|
|
4026
4153
|
return {
|
|
@@ -4062,6 +4189,20 @@ async function handleToolCall(params) {
|
|
|
4062
4189
|
content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
|
|
4063
4190
|
};
|
|
4064
4191
|
}
|
|
4192
|
+
case "list_merge_request_versions": {
|
|
4193
|
+
const args = ListMergeRequestVersionsSchema.parse(params.arguments);
|
|
4194
|
+
const versions = await listMergeRequestVersions(args.project_id, args.merge_request_iid);
|
|
4195
|
+
return {
|
|
4196
|
+
content: [{ type: "text", text: JSON.stringify(versions, null, 2) }],
|
|
4197
|
+
};
|
|
4198
|
+
}
|
|
4199
|
+
case "get_merge_request_version": {
|
|
4200
|
+
const args = GetMergeRequestVersionSchema.parse(params.arguments);
|
|
4201
|
+
const version = await getMergeRequestVersion(args.project_id, args.merge_request_iid, args.version_id, args.unidiff);
|
|
4202
|
+
return {
|
|
4203
|
+
content: [{ type: "text", text: JSON.stringify(version, null, 2) }],
|
|
4204
|
+
};
|
|
4205
|
+
}
|
|
4065
4206
|
case "update_merge_request": {
|
|
4066
4207
|
const args = UpdateMergeRequestSchema.parse(params.arguments);
|
|
4067
4208
|
const { project_id, merge_request_iid, source_branch, ...options } = args;
|
|
@@ -4745,7 +4886,9 @@ async function handleToolCall(params) {
|
|
|
4745
4886
|
const args = DownloadAttachmentSchema.parse(params.arguments);
|
|
4746
4887
|
const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
|
|
4747
4888
|
return {
|
|
4748
|
-
content: [
|
|
4889
|
+
content: [
|
|
4890
|
+
{ type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) },
|
|
4891
|
+
],
|
|
4749
4892
|
};
|
|
4750
4893
|
}
|
|
4751
4894
|
case "list_events": {
|
|
@@ -4917,8 +5060,8 @@ async function startStreamableHTTPServer() {
|
|
|
4917
5060
|
const streamableTransports = {};
|
|
4918
5061
|
const authTimeouts = {};
|
|
4919
5062
|
// Configuration and limits
|
|
4920
|
-
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ||
|
|
4921
|
-
const MAX_REQUESTS_PER_MINUTE = parseInt(process.env.MAX_REQUESTS_PER_MINUTE ||
|
|
5063
|
+
const MAX_SESSIONS = Number.parseInt(process.env.MAX_SESSIONS || "1000", 10);
|
|
5064
|
+
const MAX_REQUESTS_PER_MINUTE = Number.parseInt(process.env.MAX_REQUESTS_PER_MINUTE || "60", 10);
|
|
4922
5065
|
// Metrics tracking
|
|
4923
5066
|
const metrics = {
|
|
4924
5067
|
activeSessions: 0,
|
|
@@ -4938,7 +5081,7 @@ async function startStreamableHTTPServer() {
|
|
|
4938
5081
|
// GitLab PAT format: glpat-xxxxx (min 20 chars)
|
|
4939
5082
|
if (token.length < 20)
|
|
4940
5083
|
return false;
|
|
4941
|
-
if (!/^[a-zA-Z0-9_
|
|
5084
|
+
if (!/^[-a-zA-Z0-9_.]+$/.test(token))
|
|
4942
5085
|
return false;
|
|
4943
5086
|
return true;
|
|
4944
5087
|
};
|
|
@@ -4963,9 +5106,9 @@ async function startStreamableHTTPServer() {
|
|
|
4963
5106
|
* Returns null if no auth found or invalid format
|
|
4964
5107
|
*/
|
|
4965
5108
|
const parseAuthHeaders = (req) => {
|
|
4966
|
-
const authHeader = req.headers[
|
|
4967
|
-
const privateToken = req.headers[
|
|
4968
|
-
const dynamicApiUrl = req.headers[
|
|
5109
|
+
const authHeader = req.headers["authorization"] || "";
|
|
5110
|
+
const privateToken = req.headers["private-token"] || "";
|
|
5111
|
+
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
|
|
4969
5112
|
let apiUrl = GITLAB_API_URL; // Default API URL
|
|
4970
5113
|
// Only process dynamic URL if the feature is enabled
|
|
4971
5114
|
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
|
|
@@ -4973,7 +5116,7 @@ async function startStreamableHTTPServer() {
|
|
|
4973
5116
|
new URL(dynamicApiUrl); // Ensure it's a valid URL format
|
|
4974
5117
|
apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
|
|
4975
5118
|
}
|
|
4976
|
-
catch
|
|
5119
|
+
catch {
|
|
4977
5120
|
logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. Auth will fail.`);
|
|
4978
5121
|
return null; // Reject if URL is malformed
|
|
4979
5122
|
}
|
|
@@ -4983,13 +5126,16 @@ async function startStreamableHTTPServer() {
|
|
|
4983
5126
|
let header = null;
|
|
4984
5127
|
if (privateToken) {
|
|
4985
5128
|
token = privateToken.trim();
|
|
4986
|
-
header =
|
|
5129
|
+
header = "Private-Token";
|
|
4987
5130
|
}
|
|
4988
5131
|
else if (authHeader) {
|
|
4989
|
-
|
|
5132
|
+
// Use \S+ instead of .+ to prevent ReDoS attacks
|
|
5133
|
+
// \S+ only matches non-whitespace, so trim() is technically unnecessary,
|
|
5134
|
+
// but we keep it for defensive coding and backward compatibility
|
|
5135
|
+
const match = /^Bearer\s+(\S+)$/i.exec(authHeader);
|
|
4990
5136
|
if (match) {
|
|
4991
5137
|
token = match[1].trim();
|
|
4992
|
-
header =
|
|
5138
|
+
header = "Authorization";
|
|
4993
5139
|
}
|
|
4994
5140
|
}
|
|
4995
5141
|
// Validate token and return AuthData object
|
|
@@ -5044,8 +5190,8 @@ async function startStreamableHTTPServer() {
|
|
|
5044
5190
|
if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
|
|
5045
5191
|
metrics.rejectedByRateLimit++;
|
|
5046
5192
|
res.status(429).json({
|
|
5047
|
-
error:
|
|
5048
|
-
message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed
|
|
5193
|
+
error: "Rate limit exceeded",
|
|
5194
|
+
message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed`,
|
|
5049
5195
|
});
|
|
5050
5196
|
return;
|
|
5051
5197
|
}
|
|
@@ -5053,8 +5199,8 @@ async function startStreamableHTTPServer() {
|
|
|
5053
5199
|
if (!sessionId && Object.keys(streamableTransports).length >= MAX_SESSIONS) {
|
|
5054
5200
|
metrics.rejectedByCapacity++;
|
|
5055
5201
|
res.status(503).json({
|
|
5056
|
-
error:
|
|
5057
|
-
message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later
|
|
5202
|
+
error: "Server capacity reached",
|
|
5203
|
+
message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later.`,
|
|
5058
5204
|
});
|
|
5059
5205
|
return;
|
|
5060
5206
|
}
|
|
@@ -5066,8 +5212,8 @@ async function startStreamableHTTPServer() {
|
|
|
5066
5212
|
if (!authData) {
|
|
5067
5213
|
metrics.authFailures++;
|
|
5068
5214
|
res.status(401).json({
|
|
5069
|
-
error:
|
|
5070
|
-
message:
|
|
5215
|
+
error: "Missing Authorization or Private-Token header",
|
|
5216
|
+
message: "Remote authorization is enabled. Please provide Authorization or Private-Token header.",
|
|
5071
5217
|
});
|
|
5072
5218
|
return;
|
|
5073
5219
|
}
|
|
@@ -5156,7 +5302,7 @@ async function startStreamableHTTPServer() {
|
|
|
5156
5302
|
header: authData.header,
|
|
5157
5303
|
token: authData.token,
|
|
5158
5304
|
lastUsed: authData.lastUsed,
|
|
5159
|
-
apiUrl: authData.apiUrl
|
|
5305
|
+
apiUrl: authData.apiUrl,
|
|
5160
5306
|
};
|
|
5161
5307
|
// Run the entire request handling within AsyncLocalStorage context
|
|
5162
5308
|
await sessionAuthStore.run(ctx, handleRequest);
|
|
@@ -5171,7 +5317,7 @@ async function startStreamableHTTPServer() {
|
|
|
5171
5317
|
res.setHeader("Allow", "POST, DELETE");
|
|
5172
5318
|
res.status(405).json({
|
|
5173
5319
|
error: "Method Not Allowed",
|
|
5174
|
-
message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server."
|
|
5320
|
+
message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server.",
|
|
5175
5321
|
});
|
|
5176
5322
|
});
|
|
5177
5323
|
// Metrics endpoint
|
|
@@ -5187,14 +5333,14 @@ async function startStreamableHTTPServer() {
|
|
|
5187
5333
|
maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
|
|
5188
5334
|
sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
|
|
5189
5335
|
remoteAuthEnabled: REMOTE_AUTHORIZATION,
|
|
5190
|
-
}
|
|
5336
|
+
},
|
|
5191
5337
|
});
|
|
5192
5338
|
});
|
|
5193
5339
|
// Health check endpoint
|
|
5194
5340
|
app.get("/health", (_req, res) => {
|
|
5195
5341
|
const isHealthy = Object.keys(streamableTransports).length < MAX_SESSIONS;
|
|
5196
5342
|
res.status(isHealthy ? 200 : 503).json({
|
|
5197
|
-
status: isHealthy ?
|
|
5343
|
+
status: isHealthy ? "healthy" : "degraded",
|
|
5198
5344
|
activeSessions: Object.keys(streamableTransports).length,
|
|
5199
5345
|
maxSessions: MAX_SESSIONS,
|
|
5200
5346
|
uptime: process.uptime(),
|
|
@@ -5238,7 +5384,7 @@ async function startStreamableHTTPServer() {
|
|
|
5238
5384
|
logger.info(`${signal} received, starting graceful shutdown...`);
|
|
5239
5385
|
// Stop accepting new connections
|
|
5240
5386
|
httpServer.close(() => {
|
|
5241
|
-
logger.info(
|
|
5387
|
+
logger.info("HTTP server closed");
|
|
5242
5388
|
});
|
|
5243
5389
|
// Close all active sessions
|
|
5244
5390
|
const sessionIds = Object.keys(streamableTransports);
|
|
@@ -5263,12 +5409,12 @@ async function startStreamableHTTPServer() {
|
|
|
5263
5409
|
Object.keys(authTimeouts).forEach(sessionId => {
|
|
5264
5410
|
clearAuthTimeout(sessionId);
|
|
5265
5411
|
});
|
|
5266
|
-
logger.info(
|
|
5412
|
+
logger.info("Graceful shutdown complete");
|
|
5267
5413
|
process.exit(0);
|
|
5268
5414
|
};
|
|
5269
5415
|
// Register signal handlers
|
|
5270
|
-
process.on(
|
|
5271
|
-
process.on(
|
|
5416
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
5417
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
5272
5418
|
}
|
|
5273
5419
|
/**
|
|
5274
5420
|
* Initialize server with specific transport mode
|
|
@@ -5289,10 +5435,11 @@ async function initializeServerByTransportMode(mode) {
|
|
|
5289
5435
|
logger.warn("Starting GitLab MCP Server with Streamable HTTP transport");
|
|
5290
5436
|
await startStreamableHTTPServer();
|
|
5291
5437
|
break;
|
|
5292
|
-
default:
|
|
5438
|
+
default: {
|
|
5293
5439
|
// This should never happen with proper enum usage, but TypeScript requires it
|
|
5294
5440
|
const exhaustiveCheck = mode;
|
|
5295
5441
|
throw new Error(`Unknown transport mode: ${exhaustiveCheck}`);
|
|
5442
|
+
}
|
|
5296
5443
|
}
|
|
5297
5444
|
}
|
|
5298
5445
|
/**
|
package/build/schemas.js
CHANGED
|
@@ -792,12 +792,12 @@ export const GitLabDiscussionNoteSchema = z.object({
|
|
|
792
792
|
position: z
|
|
793
793
|
.object({
|
|
794
794
|
// Only present for DiffNote
|
|
795
|
-
base_sha: z.string().optional(),
|
|
796
|
-
start_sha: z.string().optional(),
|
|
797
|
-
head_sha: z.string().optional(),
|
|
795
|
+
base_sha: z.string().nullable().optional(),
|
|
796
|
+
start_sha: z.string().nullable().optional(),
|
|
797
|
+
head_sha: z.string().nullable().optional(),
|
|
798
798
|
old_path: z.string().nullable().optional().describe("File path before change"),
|
|
799
799
|
new_path: z.string().nullable().optional().describe("File path after change"),
|
|
800
|
-
position_type: z.enum(["text", "image", "file"]).optional(),
|
|
800
|
+
position_type: z.enum(["text", "image", "file"]).nullable().optional(),
|
|
801
801
|
new_line: z
|
|
802
802
|
.number()
|
|
803
803
|
.nullable()
|
|
@@ -808,11 +808,11 @@ export const GitLabDiscussionNoteSchema = z.object({
|
|
|
808
808
|
.nullable()
|
|
809
809
|
.optional()
|
|
810
810
|
.describe("Line number in the original file (before changes). Used for deleted lines and context lines. Null for newly added lines."),
|
|
811
|
-
line_range: LineRangeSchema.nullable().optional(), //
|
|
812
|
-
width: z.number().optional(), // For image diff notes
|
|
813
|
-
height: z.number().optional(), // For image diff notes
|
|
814
|
-
x: z.number().optional(), // For image diff notes
|
|
815
|
-
y: z.number().optional(), // For image diff notes
|
|
811
|
+
line_range: LineRangeSchema.nullable().optional(), // Accept any value for line_range including null
|
|
812
|
+
width: z.number().nullable().optional(), // For image diff notes
|
|
813
|
+
height: z.number().nullable().optional(), // For image diff notes
|
|
814
|
+
x: z.number().nullable().optional(), // For image diff notes
|
|
815
|
+
y: z.number().nullable().optional(), // For image diff notes
|
|
816
816
|
})
|
|
817
817
|
.passthrough() // Allow additional fields
|
|
818
818
|
.optional(),
|
|
@@ -1049,6 +1049,16 @@ export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
|
|
|
1049
1049
|
.optional()
|
|
1050
1050
|
.describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
|
|
1051
1051
|
});
|
|
1052
|
+
// Merge Request Versions API operation schemas
|
|
1053
|
+
export const ListMergeRequestVersionsSchema = ProjectParamsSchema.extend({
|
|
1054
|
+
merge_request_iid: z.coerce.string().describe("The internal ID of the merge request"),
|
|
1055
|
+
});
|
|
1056
|
+
export const GetMergeRequestVersionSchema = ListMergeRequestVersionsSchema.extend({
|
|
1057
|
+
version_id: z.coerce.string().describe("The ID of the merge request diff version"),
|
|
1058
|
+
unidiff: z.boolean()
|
|
1059
|
+
.optional()
|
|
1060
|
+
.describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
|
|
1061
|
+
});
|
|
1052
1062
|
export const CreateNoteSchema = z.object({
|
|
1053
1063
|
project_id: z.coerce.string().describe("Project ID or namespace/project_path"),
|
|
1054
1064
|
noteable_type: z
|
|
@@ -1105,7 +1115,7 @@ export const ListIssuesSchema = z
|
|
|
1105
1115
|
// Merge Requests API operation schemas
|
|
1106
1116
|
export const ListMergeRequestsSchema = z
|
|
1107
1117
|
.object({
|
|
1108
|
-
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
1118
|
+
project_id: z.coerce.string().optional().describe("Project ID or URL-encoded path (optional - if not provided, lists all merge requests the user has access to)"),
|
|
1109
1119
|
assignee_id: z.coerce
|
|
1110
1120
|
.string()
|
|
1111
1121
|
.optional()
|
|
@@ -1418,6 +1428,7 @@ export const MergeRequestThreadPositionCreateSchema = z.object({
|
|
|
1418
1428
|
x: z.number().optional().describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
|
|
1419
1429
|
y: z.number().optional().describe("IMAGE DIFFS ONLY: Y coordinate on the image (for position_type='image')."),
|
|
1420
1430
|
});
|
|
1431
|
+
// Schema for creating/sending position to GitLab API (stricter)
|
|
1421
1432
|
export const MergeRequestThreadPositionSchema = z.object({
|
|
1422
1433
|
base_sha: z
|
|
1423
1434
|
.string()
|
|
@@ -1454,18 +1465,22 @@ export const MergeRequestThreadPositionSchema = z.object({
|
|
|
1454
1465
|
line_range: LineRangeSchema.nullable().optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
|
|
1455
1466
|
width: z
|
|
1456
1467
|
.number()
|
|
1468
|
+
.nullable()
|
|
1457
1469
|
.optional()
|
|
1458
1470
|
.describe("IMAGE DIFFS ONLY: Width of the image (for position_type='image')."),
|
|
1459
1471
|
height: z
|
|
1460
1472
|
.number()
|
|
1473
|
+
.nullable()
|
|
1461
1474
|
.optional()
|
|
1462
1475
|
.describe("IMAGE DIFFS ONLY: Height of the image (for position_type='image')."),
|
|
1463
1476
|
x: z
|
|
1464
1477
|
.number()
|
|
1478
|
+
.nullable()
|
|
1465
1479
|
.optional()
|
|
1466
1480
|
.describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
|
|
1467
1481
|
y: z
|
|
1468
1482
|
.number()
|
|
1483
|
+
.nullable()
|
|
1469
1484
|
.optional()
|
|
1470
1485
|
.describe("IMAGE DIFFS ONLY: Y coordinate on the image (for position_type='image')."),
|
|
1471
1486
|
});
|
|
@@ -1502,7 +1517,7 @@ export const ListDraftNotesSchema = ProjectParamsSchema.extend({
|
|
|
1502
1517
|
export const CreateDraftNoteSchema = ProjectParamsSchema.extend({
|
|
1503
1518
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
1504
1519
|
body: z.string().describe("The content of the draft note"),
|
|
1505
|
-
position:
|
|
1520
|
+
position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
|
|
1506
1521
|
resolve_discussion: z.boolean().optional().describe("Whether to resolve the discussion when publishing"),
|
|
1507
1522
|
});
|
|
1508
1523
|
// Update draft note schema
|
|
@@ -1510,7 +1525,7 @@ export const UpdateDraftNoteSchema = ProjectParamsSchema.extend({
|
|
|
1510
1525
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
1511
1526
|
draft_note_id: z.coerce.string().describe("The ID of the draft note"),
|
|
1512
1527
|
body: z.string().optional().describe("The content of the draft note"),
|
|
1513
|
-
position:
|
|
1528
|
+
position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
|
|
1514
1529
|
resolve_discussion: z.boolean().optional().describe("Whether to resolve the discussion when publishing"),
|
|
1515
1530
|
});
|
|
1516
1531
|
// Delete draft note schema
|
|
@@ -1785,6 +1800,22 @@ export const GetProjectEventsSchema = z.object({
|
|
|
1785
1800
|
page: z.number().optional().describe("Returns the specified results page. Default: 1"),
|
|
1786
1801
|
per_page: z.number().optional().describe("Number of results per page. Default: 20"),
|
|
1787
1802
|
});
|
|
1803
|
+
// Merge Request Versions schemas - Response schemas based on GitLab API documentation
|
|
1804
|
+
export const GitLabMergeRequestVersionSchema = z.object({
|
|
1805
|
+
id: z.number(),
|
|
1806
|
+
head_commit_sha: z.string(),
|
|
1807
|
+
base_commit_sha: z.string(),
|
|
1808
|
+
start_commit_sha: z.string(),
|
|
1809
|
+
created_at: z.string(),
|
|
1810
|
+
merge_request_id: z.number(),
|
|
1811
|
+
state: z.string(),
|
|
1812
|
+
real_size: z.string(),
|
|
1813
|
+
patch_id_sha: z.string(),
|
|
1814
|
+
});
|
|
1815
|
+
export const GitLabMergeRequestVersionDetailSchema = GitLabMergeRequestVersionSchema.extend({
|
|
1816
|
+
commits: z.array(GitLabCommitSchema),
|
|
1817
|
+
diffs: z.array(GitLabDiffSchema),
|
|
1818
|
+
});
|
|
1788
1819
|
// GraphQL generic execution schema
|
|
1789
1820
|
export const ExecuteGraphQLSchema = z.object({
|
|
1790
1821
|
query: z.string().describe("GraphQL query string"),
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
|
|
5
|
+
const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
6
|
+
const TEST_PROJECT_ID = '123';
|
|
7
|
+
// Helper to run the MCP tool
|
|
8
|
+
async function callListMergeRequests(args = {}, env) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const proc = spawn('node', ['build/index.js'], {
|
|
11
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
12
|
+
env: {
|
|
13
|
+
...process.env,
|
|
14
|
+
...env,
|
|
15
|
+
GITLAB_READ_ONLY_MODE: 'true'
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
let output = '';
|
|
19
|
+
let errorOutput = '';
|
|
20
|
+
proc.stdout?.on('data', d => output += d);
|
|
21
|
+
proc.stderr?.on('data', d => errorOutput += d);
|
|
22
|
+
proc.on('close', (code) => {
|
|
23
|
+
if (code !== 0)
|
|
24
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
25
|
+
// Find the JSON line in stdout
|
|
26
|
+
const line = output.split('\n').find(l => l.startsWith('{'));
|
|
27
|
+
if (!line)
|
|
28
|
+
return reject(new Error('No JSON output found'));
|
|
29
|
+
try {
|
|
30
|
+
const response = JSON.parse(line);
|
|
31
|
+
if (response.error) {
|
|
32
|
+
reject(response.error);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// Parse the tool result content
|
|
36
|
+
const content = response.result?.content?.[0]?.text;
|
|
37
|
+
if (content) {
|
|
38
|
+
try {
|
|
39
|
+
resolve(JSON.parse(content));
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// Fallback for direct result (if changed in future) or empty
|
|
47
|
+
resolve(response.result);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
reject(e);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
proc.stdin?.end(JSON.stringify({
|
|
56
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
57
|
+
params: { name: "list_merge_requests", arguments: args }
|
|
58
|
+
}) + '\n');
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
describe('list_merge_requests', () => {
|
|
62
|
+
let mockGitLab;
|
|
63
|
+
let mockGitLabUrl;
|
|
64
|
+
before(async () => {
|
|
65
|
+
const mockPort = await findMockServerPort(9000);
|
|
66
|
+
mockGitLab = new MockGitLabServer({
|
|
67
|
+
port: mockPort,
|
|
68
|
+
validTokens: [MOCK_TOKEN]
|
|
69
|
+
});
|
|
70
|
+
await mockGitLab.start();
|
|
71
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
72
|
+
});
|
|
73
|
+
after(async () => {
|
|
74
|
+
await mockGitLab.stop();
|
|
75
|
+
});
|
|
76
|
+
test('lists global merge requests (no project_id)', async () => {
|
|
77
|
+
const mrs = await callListMergeRequests({}, {
|
|
78
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
79
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
80
|
+
});
|
|
81
|
+
assert.ok(Array.isArray(mrs), 'Response should be an array');
|
|
82
|
+
assert.strictEqual(mrs.length, 2, 'Should return 2 mock MRs');
|
|
83
|
+
// Schema coerces project_id to string
|
|
84
|
+
assert.strictEqual(String(mrs[0].project_id), '123', 'MR should have correct project_id');
|
|
85
|
+
});
|
|
86
|
+
test('lists project-specific merge requests', async () => {
|
|
87
|
+
const mrs = await callListMergeRequests({ project_id: TEST_PROJECT_ID }, {
|
|
88
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
89
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
90
|
+
});
|
|
91
|
+
assert.ok(Array.isArray(mrs), 'Response should be an array');
|
|
92
|
+
assert.strictEqual(mrs.length, 2, 'Should return 2 mock MRs');
|
|
93
|
+
assert.strictEqual(mrs[0].title, 'Test MR 1');
|
|
94
|
+
});
|
|
95
|
+
test('filters global merge requests', async () => {
|
|
96
|
+
// Note: The mock server returns static data, so filtering won't actually filter the results
|
|
97
|
+
// unless we implement filtering logic in the mock.
|
|
98
|
+
// But we can verify the call succeeds.
|
|
99
|
+
const mrs = await callListMergeRequests({ state: 'opened' }, {
|
|
100
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
101
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
102
|
+
});
|
|
103
|
+
assert.ok(Array.isArray(mrs), 'Response should be an array');
|
|
104
|
+
assert.strictEqual(mrs.length, 2, 'Should return 2 mock MRs');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -139,15 +139,71 @@ export class MockGitLabServer {
|
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
|
+
// GET /api/v4/merge_requests - List all merge requests (global)
|
|
143
|
+
this.app.get('/api/v4/merge_requests', (req, res) => {
|
|
144
|
+
res.json([
|
|
145
|
+
{
|
|
146
|
+
id: 1,
|
|
147
|
+
iid: 1,
|
|
148
|
+
project_id: 123,
|
|
149
|
+
title: 'Test MR 1',
|
|
150
|
+
description: 'Description for MR 1',
|
|
151
|
+
state: 'opened',
|
|
152
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
153
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
154
|
+
merged_at: null,
|
|
155
|
+
closed_at: null,
|
|
156
|
+
target_branch: 'main',
|
|
157
|
+
source_branch: 'feature-1',
|
|
158
|
+
web_url: 'https://gitlab.mock/project/123/merge_requests/1',
|
|
159
|
+
merge_commit_sha: null,
|
|
160
|
+
author: {
|
|
161
|
+
id: 1,
|
|
162
|
+
username: 'test-user',
|
|
163
|
+
name: 'Test User'
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 2,
|
|
168
|
+
iid: 2,
|
|
169
|
+
project_id: 123,
|
|
170
|
+
title: 'Test MR 2',
|
|
171
|
+
description: 'Description for MR 2',
|
|
172
|
+
state: 'merged',
|
|
173
|
+
created_at: '2024-01-02T00:00:00Z',
|
|
174
|
+
updated_at: '2024-01-03T00:00:00Z',
|
|
175
|
+
merged_at: '2024-01-03T00:00:00Z',
|
|
176
|
+
closed_at: null,
|
|
177
|
+
target_branch: 'main',
|
|
178
|
+
source_branch: 'feature-2',
|
|
179
|
+
web_url: 'https://gitlab.mock/project/123/merge_requests/2',
|
|
180
|
+
merge_commit_sha: 'abcdef1234567890',
|
|
181
|
+
author: {
|
|
182
|
+
id: 1,
|
|
183
|
+
username: 'test-user',
|
|
184
|
+
name: 'Test User'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
142
189
|
// GET /api/v4/projects/:projectId/merge_requests - List merge requests
|
|
143
190
|
this.app.get('/api/v4/projects/:projectId/merge_requests', (req, res) => {
|
|
144
191
|
res.json([
|
|
145
192
|
{
|
|
146
193
|
id: 1,
|
|
147
194
|
iid: 1,
|
|
195
|
+
project_id: 123,
|
|
148
196
|
title: 'Test MR 1',
|
|
197
|
+
description: 'Description for MR 1',
|
|
149
198
|
state: 'opened',
|
|
150
199
|
created_at: '2024-01-01T00:00:00Z',
|
|
200
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
201
|
+
merged_at: null,
|
|
202
|
+
closed_at: null,
|
|
203
|
+
target_branch: 'main',
|
|
204
|
+
source_branch: 'feature-1',
|
|
205
|
+
web_url: 'https://gitlab.mock/project/123/merge_requests/1',
|
|
206
|
+
merge_commit_sha: null,
|
|
151
207
|
author: {
|
|
152
208
|
id: 1,
|
|
153
209
|
username: 'test-user',
|
|
@@ -157,9 +213,18 @@ export class MockGitLabServer {
|
|
|
157
213
|
{
|
|
158
214
|
id: 2,
|
|
159
215
|
iid: 2,
|
|
216
|
+
project_id: 123,
|
|
160
217
|
title: 'Test MR 2',
|
|
218
|
+
description: 'Description for MR 2',
|
|
161
219
|
state: 'merged',
|
|
162
220
|
created_at: '2024-01-02T00:00:00Z',
|
|
221
|
+
updated_at: '2024-01-03T00:00:00Z',
|
|
222
|
+
merged_at: '2024-01-03T00:00:00Z',
|
|
223
|
+
closed_at: null,
|
|
224
|
+
target_branch: 'main',
|
|
225
|
+
source_branch: 'feature-2',
|
|
226
|
+
web_url: 'https://gitlab.mock/project/123/merge_requests/2',
|
|
227
|
+
merge_commit_sha: 'abcdef1234567890',
|
|
163
228
|
author: {
|
|
164
229
|
id: 1,
|
|
165
230
|
username: 'test-user',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.23",
|
|
4
4
|
"description": "MCP server for using the GitLab API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "zereight",
|
|
@@ -27,13 +27,14 @@
|
|
|
27
27
|
"watch": "tsc --watch",
|
|
28
28
|
"deploy": "npm publish --access public",
|
|
29
29
|
"changelog": "auto-changelog -p",
|
|
30
|
-
"test": "
|
|
31
|
-
"test:
|
|
30
|
+
"test": "npm run test:all",
|
|
31
|
+
"test:all": "npm run build && npm run test:mock && npm run test:live",
|
|
32
|
+
"test:mock": "npx tsx --test test/remote-auth-simple-test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts",
|
|
33
|
+
"test:live": "node test/validate-api.js && tsx test/readonly-mcp-tests.ts",
|
|
32
34
|
"test:remote-auth": "npm run build && npx tsx --test test/remote-auth-simple-test.ts",
|
|
33
|
-
"test:server": "npm run build && node build/test/test-all-transport-server.js",
|
|
34
35
|
"test:mcp:readonly": "tsx test/readonly-mcp-tests.ts",
|
|
35
36
|
"test:oauth": "tsx test/oauth-tests.ts",
|
|
36
|
-
"test:
|
|
37
|
+
"test:list-merge-requests": "npm run build && tsx test/test-list-merge-requests.ts",
|
|
37
38
|
"lint": "eslint . --ext .ts",
|
|
38
39
|
"lint:fix": "eslint . --ext .ts --fix",
|
|
39
40
|
"format": "prettier --write \"**/*.{js,ts,json,md}\"",
|