gd-gitlab-mcp 0.1.0
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 +176 -0
- package/dist/config/index.js +7 -0
- package/dist/server.js +923 -0
- package/dist/services/gitlab.service.js +477 -0
- package/dist/services/review.service.js +35 -0
- package/dist/tools/getDiff.tool.js +10 -0
- package/dist/tools/listMR.tool.js +10 -0
- package/dist/tools/reviewMR.tool.js +23 -0
- package/dist/utils/index.js +17 -0
- package/package.json +29 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
require("dotenv/config");
|
|
7
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
10
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
11
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
12
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
13
|
+
const gitlab_service_1 = require("./services/gitlab.service");
|
|
14
|
+
const review_service_1 = require("./services/review.service");
|
|
15
|
+
const utils_1 = require("./utils");
|
|
16
|
+
const config_1 = __importDefault(require("./config"));
|
|
17
|
+
// ─── MCP server factory ───────────────────────────────────────────────────────
|
|
18
|
+
function buildMcpServer(gitlab) {
|
|
19
|
+
const reviewer = new review_service_1.ReviewService(gitlab);
|
|
20
|
+
const server = new index_js_1.Server({ name: 'gitlab-mcp-server', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
21
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
22
|
+
tools: [
|
|
23
|
+
{
|
|
24
|
+
name: 'search_projects',
|
|
25
|
+
description: 'Search GitLab projects by name/path and return candidate projects with IDs',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
query: { type: 'string', description: 'Project keyword, e.g. greatday-server' },
|
|
30
|
+
perPage: { type: 'number', description: 'Max project results (default 20)' },
|
|
31
|
+
},
|
|
32
|
+
required: ['query'],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'resolve_project',
|
|
37
|
+
description: 'Resolve project query to the best matching GitLab project (returns project ID)',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
queryOrId: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Project ID or name/path keyword, e.g. greatday-server',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ['queryOrId'],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'list_user_repositories',
|
|
51
|
+
description: 'List repositories by access scope for current authenticated user',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
perPage: { type: 'number', description: 'Max results per page (default 50, max 100)' },
|
|
56
|
+
page: { type: 'number', description: 'Page number (default 1)' },
|
|
57
|
+
scope: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
enum: ['owned', 'membership', 'all_accessible'],
|
|
60
|
+
description: 'Repository scope (default owned)',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'list_merge_requests',
|
|
67
|
+
description: 'List merge requests by projectId or projectQuery with optional filters',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
72
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
73
|
+
state: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
enum: ['opened', 'closed', 'locked', 'merged', 'all'],
|
|
76
|
+
description: 'MR state filter (default opened)',
|
|
77
|
+
},
|
|
78
|
+
sourceBranch: { type: 'string', description: 'Filter by source branch' },
|
|
79
|
+
targetBranch: { type: 'string', description: 'Filter by target branch' },
|
|
80
|
+
authorUsername: { type: 'string', description: 'Filter by author username' },
|
|
81
|
+
reviewerUsername: { type: 'string', description: 'Filter by reviewer username' },
|
|
82
|
+
search: { type: 'string', description: 'Search term for MR title/description' },
|
|
83
|
+
perPage: { type: 'number', description: 'Max results per page (max 100)' },
|
|
84
|
+
page: { type: 'number', description: 'Page number' },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'get_merge_request_diff',
|
|
90
|
+
description: 'Get diff changes from a specific GitLab merge request by projectId or projectQuery',
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {
|
|
94
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
95
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
96
|
+
mergeRequestIid: { type: 'string', description: 'Merge request IID' },
|
|
97
|
+
},
|
|
98
|
+
required: ['mergeRequestIid'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'get_merge_request',
|
|
103
|
+
description: 'Get detailed information for a specific merge request, including approvals',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
108
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
109
|
+
mergeRequestIid: { type: 'string', description: 'Merge request IID' },
|
|
110
|
+
},
|
|
111
|
+
required: ['mergeRequestIid'],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'merge_readiness_check',
|
|
116
|
+
description: 'Check whether a specific merge request is ready to merge',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
121
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
122
|
+
mergeRequestIid: { type: 'string', description: 'Merge request IID' },
|
|
123
|
+
},
|
|
124
|
+
required: ['mergeRequestIid'],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'run_staging_pipeline',
|
|
129
|
+
description: 'Run pipeline for specific branch or current branch (from MR/default branch) with STAGING=1..15',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
134
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
135
|
+
staging: { type: 'number', description: 'Staging target number (1-15)' },
|
|
136
|
+
ref: { type: 'string', description: 'Branch/ref to run pipeline on' },
|
|
137
|
+
mergeRequestIid: {
|
|
138
|
+
type: 'string',
|
|
139
|
+
description: 'Optional MR IID to derive current branch from source branch',
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
required: ['staging'],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'check_pipeline_status',
|
|
147
|
+
description: 'Check pipeline status by pipelineId, or latest pipeline from ref/current branch',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
152
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
153
|
+
pipelineId: { type: 'string', description: 'Pipeline ID to check directly' },
|
|
154
|
+
ref: { type: 'string', description: 'Branch/ref to check latest pipeline from' },
|
|
155
|
+
mergeRequestIid: { type: 'string', description: 'Optional MR IID to derive source branch' },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'list_pipeline_jobs',
|
|
161
|
+
description: 'List jobs in a pipeline, optionally filtered by status scope',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
166
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
167
|
+
pipelineId: { type: 'string', description: 'Pipeline ID (required)' },
|
|
168
|
+
scope: {
|
|
169
|
+
type: 'array',
|
|
170
|
+
items: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
enum: ['created', 'pending', 'running', 'failed', 'success', 'canceled', 'skipped', 'manual'],
|
|
173
|
+
},
|
|
174
|
+
description: 'Filter jobs by status scope (default: all)',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
required: ['pipelineId'],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'retry_pipeline_job',
|
|
182
|
+
description: 'Retry a specific failed or canceled pipeline job by job ID',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: {
|
|
186
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
187
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
188
|
+
jobId: { type: 'string', description: 'Job ID to retry' },
|
|
189
|
+
},
|
|
190
|
+
required: ['jobId'],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'get_mr_discussions',
|
|
195
|
+
description: 'Get all discussion threads on a merge request, optionally filtered by resolved status',
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: {
|
|
199
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
200
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
201
|
+
mergeRequestIid: { type: 'string', description: 'Merge request IID' },
|
|
202
|
+
unresolvedOnly: {
|
|
203
|
+
type: 'boolean',
|
|
204
|
+
description: 'Return only unresolved/blocking discussions (default false)',
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
required: ['mergeRequestIid'],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'merge_request_action',
|
|
212
|
+
description: 'Unified merge request action: comment, review, approve, merge, or reject',
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
217
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
218
|
+
mergeRequestIid: { type: 'string', description: 'Merge request IID' },
|
|
219
|
+
action: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
enum: ['comment', 'review', 'approve', 'merge', 'reject'],
|
|
222
|
+
description: 'Action to perform on the merge request',
|
|
223
|
+
},
|
|
224
|
+
body: { type: 'string', description: 'Comment body for comment action' },
|
|
225
|
+
postComment: {
|
|
226
|
+
type: 'boolean',
|
|
227
|
+
description: 'For review action: whether to post generated review comment (default true)',
|
|
228
|
+
},
|
|
229
|
+
notes: {
|
|
230
|
+
type: 'string',
|
|
231
|
+
description: 'Optional notes for approve, required notes for reject',
|
|
232
|
+
},
|
|
233
|
+
sha: { type: 'string', description: 'Optional head SHA for approve safety check' },
|
|
234
|
+
revokeOwnApproval: {
|
|
235
|
+
type: 'boolean',
|
|
236
|
+
description: 'For reject: revoke your own previous approval (default true)',
|
|
237
|
+
},
|
|
238
|
+
closeMergeRequest: {
|
|
239
|
+
type: 'boolean',
|
|
240
|
+
description: 'For reject: close merge request (default true)',
|
|
241
|
+
},
|
|
242
|
+
squash: { type: 'boolean', description: 'For merge: squash commits when merging' },
|
|
243
|
+
shouldRemoveSourceBranch: {
|
|
244
|
+
type: 'boolean',
|
|
245
|
+
description: 'For merge: remove source branch after merge',
|
|
246
|
+
},
|
|
247
|
+
mergeWhenPipelineSucceeds: {
|
|
248
|
+
type: 'boolean',
|
|
249
|
+
description: 'For merge: merge automatically when pipeline succeeds',
|
|
250
|
+
},
|
|
251
|
+
mergeCommitMessage: { type: 'string', description: 'For merge: custom merge commit message' },
|
|
252
|
+
squashCommitMessage: { type: 'string', description: 'For merge: custom squash commit message' },
|
|
253
|
+
},
|
|
254
|
+
required: ['mergeRequestIid', 'action'],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: 'open_merge_request',
|
|
259
|
+
description: 'Open a new merge request from a specific source branch to a target branch',
|
|
260
|
+
inputSchema: {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: {
|
|
263
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
264
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
265
|
+
sourceBranch: { type: 'string', description: 'Source branch name' },
|
|
266
|
+
targetBranch: { type: 'string', description: 'Target branch name' },
|
|
267
|
+
title: { type: 'string', description: 'Merge request title' },
|
|
268
|
+
description: { type: 'string', description: 'Merge request description' },
|
|
269
|
+
removeSourceBranch: { type: 'boolean', description: 'Remove source branch when merged' },
|
|
270
|
+
draft: { type: 'boolean', description: 'Create MR as draft' },
|
|
271
|
+
squash: { type: 'boolean', description: 'Enable squash on MR' },
|
|
272
|
+
},
|
|
273
|
+
required: ['sourceBranch', 'targetBranch', 'title'],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: 'merge_merge_request',
|
|
278
|
+
description: 'Merge a specific GitLab merge request',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
283
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
284
|
+
mergeRequestIid: { type: 'string', description: 'Merge request IID' },
|
|
285
|
+
squash: { type: 'boolean', description: 'Squash commits when merging' },
|
|
286
|
+
shouldRemoveSourceBranch: { type: 'boolean', description: 'Remove source branch after merge' },
|
|
287
|
+
mergeWhenPipelineSucceeds: { type: 'boolean', description: 'Merge when pipeline succeeds' },
|
|
288
|
+
sha: { type: 'string', description: 'Expected latest commit SHA' },
|
|
289
|
+
mergeCommitMessage: { type: 'string', description: 'Custom merge commit message' },
|
|
290
|
+
squashCommitMessage: { type: 'string', description: 'Custom squash commit message' },
|
|
291
|
+
},
|
|
292
|
+
required: ['mergeRequestIid'],
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: 'reject_merge_request',
|
|
297
|
+
description: 'Reject a specific GitLab merge request by posting notes and closing it',
|
|
298
|
+
inputSchema: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
properties: {
|
|
301
|
+
projectId: { type: 'string', description: 'GitLab project ID' },
|
|
302
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
303
|
+
mergeRequestIid: { type: 'string', description: 'Merge request IID' },
|
|
304
|
+
notes: { type: 'string', description: 'Rejection note/comment (required)' },
|
|
305
|
+
revokeOwnApproval: {
|
|
306
|
+
type: 'boolean',
|
|
307
|
+
description: 'Revoke own approval first if exists (default true)',
|
|
308
|
+
},
|
|
309
|
+
closeMergeRequest: {
|
|
310
|
+
type: 'boolean',
|
|
311
|
+
description: 'Close merge request after rejection (default true)',
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
required: ['mergeRequestIid', 'notes'],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'check_project_merge_requests',
|
|
319
|
+
description: 'Resolve project by name/path then list opened merge requests in one call',
|
|
320
|
+
inputSchema: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
projectQuery: { type: 'string', description: 'Project keyword/path, e.g. greatday-server' },
|
|
324
|
+
},
|
|
325
|
+
required: ['projectQuery'],
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
}));
|
|
330
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
331
|
+
const name = request.params.name;
|
|
332
|
+
const args = (request.params.arguments ?? {});
|
|
333
|
+
const resolveProjectId = async () => {
|
|
334
|
+
if (args.projectId)
|
|
335
|
+
return String(args.projectId);
|
|
336
|
+
if (args.projectQuery) {
|
|
337
|
+
const project = await gitlab.resolveProject(String(args.projectQuery));
|
|
338
|
+
if (!project)
|
|
339
|
+
throw new Error(`Project not found: ${String(args.projectQuery)}`);
|
|
340
|
+
return String(project.id);
|
|
341
|
+
}
|
|
342
|
+
throw new Error('Either projectId or projectQuery is required');
|
|
343
|
+
};
|
|
344
|
+
// Wrap all tool handlers — convert GitLabApiError to user-friendly MCP error
|
|
345
|
+
try {
|
|
346
|
+
return await handleTool(name, args, resolveProjectId, gitlab, reviewer);
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
if (err instanceof gitlab_service_1.GitLabApiError) {
|
|
350
|
+
const msg = err.detail ? `${err.message} (${err.detail})` : err.message;
|
|
351
|
+
throw new Error(`[GitLab ${err.status}] ${msg}`);
|
|
352
|
+
}
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
return server;
|
|
357
|
+
}
|
|
358
|
+
async function handleTool(name, args, resolveProjectId, gitlab, reviewer) {
|
|
359
|
+
if (name === 'search_projects') {
|
|
360
|
+
const query = String(args.query ?? '').trim();
|
|
361
|
+
if (!query)
|
|
362
|
+
throw new Error('query is required');
|
|
363
|
+
const perPageRaw = Number(args.perPage ?? 20);
|
|
364
|
+
const perPage = Number.isFinite(perPageRaw) ? Math.max(1, Math.min(100, perPageRaw)) : 20;
|
|
365
|
+
const projects = await gitlab.searchProjects(query, perPage);
|
|
366
|
+
return { content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }] };
|
|
367
|
+
}
|
|
368
|
+
if (name === 'resolve_project') {
|
|
369
|
+
const queryOrId = String(args.queryOrId ?? '').trim();
|
|
370
|
+
if (!queryOrId)
|
|
371
|
+
throw new Error('queryOrId is required');
|
|
372
|
+
const project = await gitlab.resolveProject(queryOrId);
|
|
373
|
+
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
|
|
374
|
+
}
|
|
375
|
+
if (name === 'list_user_repositories') {
|
|
376
|
+
const perPage = Number.isFinite(Number(args.perPage))
|
|
377
|
+
? Math.max(1, Math.min(100, Math.floor(Number(args.perPage ?? 50))))
|
|
378
|
+
: 50;
|
|
379
|
+
const page = Number.isFinite(Number(args.page))
|
|
380
|
+
? Math.max(1, Math.floor(Number(args.page ?? 1)))
|
|
381
|
+
: 1;
|
|
382
|
+
const scopeRaw = String(args.scope ?? 'owned').trim();
|
|
383
|
+
const scope = ['owned', 'membership', 'all_accessible'].includes(scopeRaw) ? scopeRaw : 'owned';
|
|
384
|
+
const repositories = await gitlab.listUserRepositories({ perPage, page, scope });
|
|
385
|
+
return {
|
|
386
|
+
content: [{ type: 'text', text: JSON.stringify({ perPage, page, scope, repositories }, null, 2) }],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (name === 'list_merge_requests') {
|
|
390
|
+
const projectId = await resolveProjectId();
|
|
391
|
+
const stateRaw = args.state === undefined ? undefined : String(args.state).trim();
|
|
392
|
+
const state = stateRaw && ['opened', 'closed', 'locked', 'merged', 'all'].includes(stateRaw)
|
|
393
|
+
? stateRaw
|
|
394
|
+
: undefined;
|
|
395
|
+
const perPageRaw = Number(args.perPage);
|
|
396
|
+
const perPage = Number.isFinite(perPageRaw) ? Math.max(1, Math.min(100, Math.floor(perPageRaw))) : undefined;
|
|
397
|
+
const pageRaw = Number(args.page);
|
|
398
|
+
const page = Number.isFinite(pageRaw) ? Math.max(1, Math.floor(pageRaw)) : undefined;
|
|
399
|
+
const mrs = await gitlab.listMergeRequests(projectId, {
|
|
400
|
+
state,
|
|
401
|
+
sourceBranch: args.sourceBranch === undefined ? undefined : String(args.sourceBranch).trim() || undefined,
|
|
402
|
+
targetBranch: args.targetBranch === undefined ? undefined : String(args.targetBranch).trim() || undefined,
|
|
403
|
+
authorUsername: args.authorUsername === undefined ? undefined : String(args.authorUsername).trim() || undefined,
|
|
404
|
+
reviewerUsername: args.reviewerUsername === undefined ? undefined : String(args.reviewerUsername).trim() || undefined,
|
|
405
|
+
search: args.search === undefined ? undefined : String(args.search).trim() || undefined,
|
|
406
|
+
perPage,
|
|
407
|
+
page,
|
|
408
|
+
});
|
|
409
|
+
return { content: [{ type: 'text', text: JSON.stringify(mrs, null, 2) }] };
|
|
410
|
+
}
|
|
411
|
+
if (name === 'get_merge_request_diff') {
|
|
412
|
+
const projectId = await resolveProjectId();
|
|
413
|
+
const mergeRequestIid = String(args.mergeRequestIid ?? '');
|
|
414
|
+
if (!mergeRequestIid)
|
|
415
|
+
throw new Error('mergeRequestIid is required');
|
|
416
|
+
const changes = await gitlab.getMergeRequestChanges(projectId, mergeRequestIid);
|
|
417
|
+
return { content: [{ type: 'text', text: JSON.stringify(changes, null, 2) }] };
|
|
418
|
+
}
|
|
419
|
+
if (name === 'get_merge_request') {
|
|
420
|
+
const projectId = await resolveProjectId();
|
|
421
|
+
const mergeRequestIid = String(args.mergeRequestIid ?? '').trim();
|
|
422
|
+
if (!mergeRequestIid)
|
|
423
|
+
throw new Error('mergeRequestIid is required');
|
|
424
|
+
const detail = await gitlab.getMergeRequestDetail(projectId, mergeRequestIid);
|
|
425
|
+
return { content: [{ type: 'text', text: JSON.stringify(detail, null, 2) }] };
|
|
426
|
+
}
|
|
427
|
+
if (name === 'merge_readiness_check') {
|
|
428
|
+
const projectId = await resolveProjectId();
|
|
429
|
+
const mergeRequestIid = String(args.mergeRequestIid ?? '').trim();
|
|
430
|
+
if (!mergeRequestIid)
|
|
431
|
+
throw new Error('mergeRequestIid is required');
|
|
432
|
+
const readiness = await gitlab.getMergeReadiness(projectId, mergeRequestIid);
|
|
433
|
+
return { content: [{ type: 'text', text: JSON.stringify(readiness, null, 2) }] };
|
|
434
|
+
}
|
|
435
|
+
if (name === 'run_staging_pipeline') {
|
|
436
|
+
const projectId = await resolveProjectId();
|
|
437
|
+
const staging = Number(args.staging);
|
|
438
|
+
if (!Number.isInteger(staging) || staging < 1 || staging > 15) {
|
|
439
|
+
throw new Error('staging must be an integer between 1 and 15');
|
|
440
|
+
}
|
|
441
|
+
const ref = args.ref === undefined ? undefined : String(args.ref).trim() || undefined;
|
|
442
|
+
const mergeRequestIid = args.mergeRequestIid === undefined ? undefined : String(args.mergeRequestIid).trim() || undefined;
|
|
443
|
+
const result = await gitlab.runStagingPipeline(projectId, staging, { ref, mergeRequestIid });
|
|
444
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
445
|
+
}
|
|
446
|
+
if (name === 'check_pipeline_status') {
|
|
447
|
+
const projectId = await resolveProjectId();
|
|
448
|
+
const pipelineId = args.pipelineId === undefined ? undefined : String(args.pipelineId).trim() || undefined;
|
|
449
|
+
const ref = args.ref === undefined ? undefined : String(args.ref).trim() || undefined;
|
|
450
|
+
const mergeRequestIid = args.mergeRequestIid === undefined ? undefined : String(args.mergeRequestIid).trim() || undefined;
|
|
451
|
+
const result = await gitlab.getPipelineStatus(projectId, { pipelineId, ref, mergeRequestIid });
|
|
452
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
453
|
+
}
|
|
454
|
+
if (name === 'list_pipeline_jobs') {
|
|
455
|
+
const projectId = await resolveProjectId();
|
|
456
|
+
const pipelineId = String(args.pipelineId ?? '').trim();
|
|
457
|
+
if (!pipelineId)
|
|
458
|
+
throw new Error('pipelineId is required');
|
|
459
|
+
const scope = Array.isArray(args.scope)
|
|
460
|
+
? args.scope.filter((s) => typeof s === 'string')
|
|
461
|
+
: undefined;
|
|
462
|
+
const jobs = await gitlab.listPipelineJobs(projectId, pipelineId, scope);
|
|
463
|
+
return { content: [{ type: 'text', text: JSON.stringify({ projectId, pipelineId, total: jobs.length, jobs }, null, 2) }] };
|
|
464
|
+
}
|
|
465
|
+
if (name === 'retry_pipeline_job') {
|
|
466
|
+
const projectId = await resolveProjectId();
|
|
467
|
+
const jobId = String(args.jobId ?? '').trim();
|
|
468
|
+
if (!jobId)
|
|
469
|
+
throw new Error('jobId is required');
|
|
470
|
+
const job = await gitlab.retryJob(projectId, jobId);
|
|
471
|
+
return { content: [{ type: 'text', text: JSON.stringify({ projectId, jobId, retriedJob: job }, null, 2) }] };
|
|
472
|
+
}
|
|
473
|
+
if (name === 'get_mr_discussions') {
|
|
474
|
+
const projectId = await resolveProjectId();
|
|
475
|
+
const mergeRequestIid = String(args.mergeRequestIid ?? '').trim();
|
|
476
|
+
if (!mergeRequestIid)
|
|
477
|
+
throw new Error('mergeRequestIid is required');
|
|
478
|
+
const unresolvedOnly = Boolean(args.unresolvedOnly ?? false);
|
|
479
|
+
let discussions = await gitlab.getMergeRequestDiscussions(projectId, mergeRequestIid);
|
|
480
|
+
if (unresolvedOnly) {
|
|
481
|
+
discussions = discussions.filter((d) => d.notes?.some((n) => n.resolvable && !n.resolved && !n.system));
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
content: [
|
|
485
|
+
{
|
|
486
|
+
type: 'text',
|
|
487
|
+
text: JSON.stringify({ projectId, mergeRequestIid, unresolvedOnly, total: discussions.length, discussions }, null, 2),
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (name === 'open_merge_request') {
|
|
493
|
+
const projectId = await resolveProjectId();
|
|
494
|
+
const sourceBranch = String(args.sourceBranch ?? '').trim();
|
|
495
|
+
const targetBranch = String(args.targetBranch ?? '').trim();
|
|
496
|
+
const title = String(args.title ?? '').trim();
|
|
497
|
+
const description = String(args.description ?? '').trim();
|
|
498
|
+
if (!sourceBranch)
|
|
499
|
+
throw new Error('sourceBranch is required');
|
|
500
|
+
if (!targetBranch)
|
|
501
|
+
throw new Error('targetBranch is required');
|
|
502
|
+
if (!title)
|
|
503
|
+
throw new Error('title is required');
|
|
504
|
+
const opened = await gitlab.createMergeRequest(projectId, {
|
|
505
|
+
sourceBranch,
|
|
506
|
+
targetBranch,
|
|
507
|
+
title,
|
|
508
|
+
description: description || undefined,
|
|
509
|
+
removeSourceBranch: args.removeSourceBranch === undefined ? undefined : Boolean(args.removeSourceBranch),
|
|
510
|
+
draft: args.draft === undefined ? undefined : Boolean(args.draft),
|
|
511
|
+
squash: args.squash === undefined ? undefined : Boolean(args.squash),
|
|
512
|
+
});
|
|
513
|
+
return { content: [{ type: 'text', text: JSON.stringify({ projectId, opened }, null, 2) }] };
|
|
514
|
+
}
|
|
515
|
+
if (name === 'merge_merge_request') {
|
|
516
|
+
const projectId = await resolveProjectId();
|
|
517
|
+
const mergeRequestIid = String(args.mergeRequestIid ?? '').trim();
|
|
518
|
+
if (!mergeRequestIid)
|
|
519
|
+
throw new Error('mergeRequestIid is required');
|
|
520
|
+
const merged = await gitlab.mergeMergeRequest(projectId, mergeRequestIid, {
|
|
521
|
+
squash: args.squash === undefined ? undefined : Boolean(args.squash),
|
|
522
|
+
shouldRemoveSourceBranch: args.shouldRemoveSourceBranch === undefined ? undefined : Boolean(args.shouldRemoveSourceBranch),
|
|
523
|
+
mergeWhenPipelineSucceeds: args.mergeWhenPipelineSucceeds === undefined ? undefined : Boolean(args.mergeWhenPipelineSucceeds),
|
|
524
|
+
sha: args.sha === undefined ? undefined : String(args.sha).trim() || undefined,
|
|
525
|
+
mergeCommitMessage: args.mergeCommitMessage === undefined ? undefined : String(args.mergeCommitMessage).trim() || undefined,
|
|
526
|
+
squashCommitMessage: args.squashCommitMessage === undefined ? undefined : String(args.squashCommitMessage).trim() || undefined,
|
|
527
|
+
});
|
|
528
|
+
return { content: [{ type: 'text', text: JSON.stringify({ projectId, mergeRequestIid, merged }, null, 2) }] };
|
|
529
|
+
}
|
|
530
|
+
if (name === 'reject_merge_request') {
|
|
531
|
+
const projectId = await resolveProjectId();
|
|
532
|
+
const mergeRequestIid = String(args.mergeRequestIid ?? '').trim();
|
|
533
|
+
const notes = String(args.notes ?? '').trim();
|
|
534
|
+
if (!mergeRequestIid)
|
|
535
|
+
throw new Error('mergeRequestIid is required');
|
|
536
|
+
if (!notes)
|
|
537
|
+
throw new Error('notes is required');
|
|
538
|
+
const note = await gitlab.commentMergeRequest(projectId, mergeRequestIid, notes);
|
|
539
|
+
const revokeOwnApproval = args.revokeOwnApproval === undefined ? true : Boolean(args.revokeOwnApproval);
|
|
540
|
+
const closeMergeRequest = args.closeMergeRequest === undefined ? true : Boolean(args.closeMergeRequest);
|
|
541
|
+
let unapproved = null;
|
|
542
|
+
let unapproveWarning = null;
|
|
543
|
+
if (revokeOwnApproval) {
|
|
544
|
+
try {
|
|
545
|
+
unapproved = await gitlab.unapproveMergeRequest(projectId, mergeRequestIid);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
unapproveWarning = error instanceof Error ? error.message : 'Failed to unapprove merge request';
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
let closed = null;
|
|
552
|
+
let closeWarning = null;
|
|
553
|
+
if (closeMergeRequest) {
|
|
554
|
+
try {
|
|
555
|
+
closed = await gitlab.closeMergeRequest(projectId, mergeRequestIid);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
closeWarning = error instanceof Error ? error.message : 'Failed to close merge request';
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
content: [
|
|
563
|
+
{
|
|
564
|
+
type: 'text',
|
|
565
|
+
text: JSON.stringify({ projectId, mergeRequestIid, note, revokeOwnApproval, unapproved, unapproveWarning, closeMergeRequest, closed, closeWarning }, null, 2),
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (name === 'merge_request_action') {
|
|
571
|
+
const projectId = await resolveProjectId();
|
|
572
|
+
const mergeRequestIid = String(args.mergeRequestIid ?? '').trim();
|
|
573
|
+
if (!mergeRequestIid)
|
|
574
|
+
throw new Error('mergeRequestIid is required');
|
|
575
|
+
const action = String(args.action ?? '').toLowerCase();
|
|
576
|
+
if (action === 'comment') {
|
|
577
|
+
const body = String(args.body ?? '').trim();
|
|
578
|
+
if (!body)
|
|
579
|
+
throw new Error('body is required for comment action');
|
|
580
|
+
const note = await gitlab.commentMergeRequest(projectId, mergeRequestIid, body);
|
|
581
|
+
return { content: [{ type: 'text', text: JSON.stringify({ action, projectId, mergeRequestIid, note }, null, 2) }] };
|
|
582
|
+
}
|
|
583
|
+
if (action === 'review') {
|
|
584
|
+
const changes = await gitlab.getMergeRequestChanges(projectId, mergeRequestIid);
|
|
585
|
+
const combinedDiff = (changes || []).map((c) => c.diff || '').join('\n');
|
|
586
|
+
const generated = await reviewer.analyzeDiff(combinedDiff);
|
|
587
|
+
const postComment = args.postComment === undefined ? true : Boolean(args.postComment);
|
|
588
|
+
let note = null;
|
|
589
|
+
if (postComment)
|
|
590
|
+
note = await gitlab.commentMergeRequest(projectId, mergeRequestIid, generated);
|
|
591
|
+
return {
|
|
592
|
+
content: [
|
|
593
|
+
{
|
|
594
|
+
type: 'text',
|
|
595
|
+
text: JSON.stringify({ action, projectId, mergeRequestIid, postComment, generatedComment: generated, note }, null, 2),
|
|
596
|
+
},
|
|
597
|
+
],
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
if (action === 'approve') {
|
|
601
|
+
const notes = String(args.notes ?? '').trim();
|
|
602
|
+
const sha = args.sha === undefined ? undefined : String(args.sha).trim();
|
|
603
|
+
if (notes)
|
|
604
|
+
await gitlab.commentMergeRequest(projectId, mergeRequestIid, notes);
|
|
605
|
+
const approved = await gitlab.approveMergeRequest(projectId, mergeRequestIid, sha || undefined);
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: 'text',
|
|
610
|
+
text: JSON.stringify({ action, projectId, mergeRequestIid, notePosted: Boolean(notes), approved }, null, 2),
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
if (action === 'merge') {
|
|
616
|
+
const merged = await gitlab.mergeMergeRequest(projectId, mergeRequestIid, {
|
|
617
|
+
squash: args.squash === undefined ? undefined : Boolean(args.squash),
|
|
618
|
+
shouldRemoveSourceBranch: args.shouldRemoveSourceBranch === undefined ? undefined : Boolean(args.shouldRemoveSourceBranch),
|
|
619
|
+
mergeWhenPipelineSucceeds: args.mergeWhenPipelineSucceeds === undefined ? undefined : Boolean(args.mergeWhenPipelineSucceeds),
|
|
620
|
+
sha: args.sha === undefined ? undefined : String(args.sha).trim() || undefined,
|
|
621
|
+
mergeCommitMessage: args.mergeCommitMessage === undefined ? undefined : String(args.mergeCommitMessage).trim() || undefined,
|
|
622
|
+
squashCommitMessage: args.squashCommitMessage === undefined ? undefined : String(args.squashCommitMessage).trim() || undefined,
|
|
623
|
+
});
|
|
624
|
+
return { content: [{ type: 'text', text: JSON.stringify({ action, projectId, mergeRequestIid, merged }, null, 2) }] };
|
|
625
|
+
}
|
|
626
|
+
if (action === 'reject') {
|
|
627
|
+
const notes = String(args.notes ?? '').trim();
|
|
628
|
+
if (!notes)
|
|
629
|
+
throw new Error('notes is required for reject action');
|
|
630
|
+
const note = await gitlab.commentMergeRequest(projectId, mergeRequestIid, notes);
|
|
631
|
+
const revokeOwnApproval = args.revokeOwnApproval === undefined ? true : Boolean(args.revokeOwnApproval);
|
|
632
|
+
const closeMergeRequest = args.closeMergeRequest === undefined ? true : Boolean(args.closeMergeRequest);
|
|
633
|
+
let unapproved = null;
|
|
634
|
+
let unapproveWarning = null;
|
|
635
|
+
if (revokeOwnApproval) {
|
|
636
|
+
try {
|
|
637
|
+
unapproved = await gitlab.unapproveMergeRequest(projectId, mergeRequestIid);
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
unapproveWarning = error instanceof Error ? error.message : 'Failed to unapprove merge request';
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
let closed = null;
|
|
644
|
+
let closeWarning = null;
|
|
645
|
+
if (closeMergeRequest) {
|
|
646
|
+
try {
|
|
647
|
+
closed = await gitlab.closeMergeRequest(projectId, mergeRequestIid);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
closeWarning = error instanceof Error ? error.message : 'Failed to close merge request';
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
content: [
|
|
655
|
+
{
|
|
656
|
+
type: 'text',
|
|
657
|
+
text: JSON.stringify({ action, projectId, mergeRequestIid, note, revokeOwnApproval, unapproved, unapproveWarning, closeMergeRequest, closed, closeWarning }, null, 2),
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
throw new Error('action must be one of: comment, review, approve, merge, reject');
|
|
663
|
+
}
|
|
664
|
+
if (name === 'check_project_merge_requests') {
|
|
665
|
+
const projectQuery = String(args.projectQuery ?? '').trim();
|
|
666
|
+
if (!projectQuery)
|
|
667
|
+
throw new Error('projectQuery is required');
|
|
668
|
+
const payload = await gitlab.listMergeRequestsByProjectQuery(projectQuery);
|
|
669
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
670
|
+
}
|
|
671
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
672
|
+
}
|
|
673
|
+
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
674
|
+
async function parseJsonBody(req) {
|
|
675
|
+
const chunks = [];
|
|
676
|
+
for await (const chunk of req) {
|
|
677
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
678
|
+
}
|
|
679
|
+
if (chunks.length === 0)
|
|
680
|
+
return undefined;
|
|
681
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
682
|
+
if (!raw.trim())
|
|
683
|
+
return undefined;
|
|
684
|
+
return JSON.parse(raw);
|
|
685
|
+
}
|
|
686
|
+
function getUserPatFromRequest(req) {
|
|
687
|
+
const headerKey = config_1.default.userPatHeader.toLowerCase();
|
|
688
|
+
const fromConfiguredHeader = req.headers[headerKey];
|
|
689
|
+
if (typeof fromConfiguredHeader === 'string' && fromConfiguredHeader.trim()) {
|
|
690
|
+
return fromConfiguredHeader.trim();
|
|
691
|
+
}
|
|
692
|
+
const authHeader = req.headers.authorization;
|
|
693
|
+
if (typeof authHeader === 'string') {
|
|
694
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
695
|
+
if (match?.[1])
|
|
696
|
+
return match[1].trim();
|
|
697
|
+
}
|
|
698
|
+
return undefined;
|
|
699
|
+
}
|
|
700
|
+
// ─── HTTP server ──────────────────────────────────────────────────────────────
|
|
701
|
+
async function startHttp() {
|
|
702
|
+
const port = config_1.default.port;
|
|
703
|
+
const SESSION_GRACE_MS = 60000; // keep session 60s after transport close
|
|
704
|
+
const SESSION_TTL_MS = 30 * 60000; // evict idle sessions after 30 min
|
|
705
|
+
const sessions = new Map();
|
|
706
|
+
const cleanupTimer = setInterval(() => {
|
|
707
|
+
const now = Date.now();
|
|
708
|
+
let cleaned = 0;
|
|
709
|
+
for (const [sid, entry] of sessions) {
|
|
710
|
+
if (now - entry.lastActivity > SESSION_TTL_MS) {
|
|
711
|
+
if (entry.gracePeriodTimer)
|
|
712
|
+
clearTimeout(entry.gracePeriodTimer);
|
|
713
|
+
sessions.delete(sid);
|
|
714
|
+
cleaned++;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (cleaned > 0)
|
|
718
|
+
(0, utils_1.logger)('info', `Cleaned up ${cleaned} expired session(s), active: ${sessions.size}`);
|
|
719
|
+
}, 60000);
|
|
720
|
+
cleanupTimer.unref();
|
|
721
|
+
const httpServer = node_http_1.default.createServer(async (req, res) => {
|
|
722
|
+
try {
|
|
723
|
+
if (!req.url || !req.method) {
|
|
724
|
+
res.statusCode = 400;
|
|
725
|
+
res.end('Bad request');
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
// ── Health check ────────────────────────────────────────────────────────
|
|
729
|
+
if (req.url === '/health') {
|
|
730
|
+
const activeSessions = [...sessions.values()].filter((e) => e.alive).length;
|
|
731
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
732
|
+
res.end(JSON.stringify({
|
|
733
|
+
status: 'ok',
|
|
734
|
+
uptime: Math.floor(process.uptime()),
|
|
735
|
+
sessions: { total: sessions.size, active: activeSessions },
|
|
736
|
+
}));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// ── Root info ───────────────────────────────────────────────────────────
|
|
740
|
+
if (req.url === '/') {
|
|
741
|
+
const activeSessions = [...sessions.values()].filter((e) => e.alive).length;
|
|
742
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
|
743
|
+
res.end([
|
|
744
|
+
'gitlab-mcp-server http mode running',
|
|
745
|
+
'POST /mcp — MCP requests',
|
|
746
|
+
'GET /health — Health check',
|
|
747
|
+
`PAT header: ${config_1.default.userPatHeader} (or Authorization: Bearer <PAT>)`,
|
|
748
|
+
`Active sessions: ${activeSessions}/${sessions.size}`,
|
|
749
|
+
].join('\n') + '\n');
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (req.url !== '/mcp') {
|
|
753
|
+
res.statusCode = 404;
|
|
754
|
+
res.end('Not found');
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const sessionIdHeader = req.headers['mcp-session-id'];
|
|
758
|
+
const sessionId = Array.isArray(sessionIdHeader) ? sessionIdHeader[0] : sessionIdHeader;
|
|
759
|
+
// ── POST ────────────────────────────────────────────────────────────────
|
|
760
|
+
if (req.method === 'POST') {
|
|
761
|
+
const body = await parseJsonBody(req);
|
|
762
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
763
|
+
const entry = sessions.get(sessionId);
|
|
764
|
+
entry.lastActivity = Date.now();
|
|
765
|
+
if (!entry.alive) {
|
|
766
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
767
|
+
res.end(JSON.stringify({
|
|
768
|
+
jsonrpc: '2.0',
|
|
769
|
+
error: { code: -32001, message: 'Session closed, please reinitialize' },
|
|
770
|
+
id: null,
|
|
771
|
+
}));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
await entry.transport.handleRequest(req, res, body);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if ((0, types_js_1.isInitializeRequest)(body)) {
|
|
778
|
+
const userPat = getUserPatFromRequest(req);
|
|
779
|
+
if (!userPat) {
|
|
780
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
781
|
+
res.end(JSON.stringify({
|
|
782
|
+
jsonrpc: '2.0',
|
|
783
|
+
error: {
|
|
784
|
+
code: -32001,
|
|
785
|
+
message: `User PAT required. Provide ${config_1.default.userPatHeader} header or Authorization: Bearer <PAT>`,
|
|
786
|
+
},
|
|
787
|
+
id: null,
|
|
788
|
+
}));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const gitlab = new gitlab_service_1.GitLabService(userPat);
|
|
792
|
+
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
793
|
+
sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
|
|
794
|
+
onsessioninitialized: (sid) => {
|
|
795
|
+
sessions.set(sid, { transport, lastActivity: Date.now(), alive: true });
|
|
796
|
+
(0, utils_1.logger)('info', `Session ${sid} initialized (total: ${sessions.size})`);
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
transport.onclose = () => {
|
|
800
|
+
const sid = transport.sessionId;
|
|
801
|
+
if (!sid)
|
|
802
|
+
return;
|
|
803
|
+
const entry = sessions.get(sid);
|
|
804
|
+
if (!entry)
|
|
805
|
+
return;
|
|
806
|
+
entry.alive = false;
|
|
807
|
+
if (entry.gracePeriodTimer)
|
|
808
|
+
clearTimeout(entry.gracePeriodTimer);
|
|
809
|
+
entry.gracePeriodTimer = setTimeout(() => {
|
|
810
|
+
sessions.delete(sid);
|
|
811
|
+
(0, utils_1.logger)('info', `Session ${sid} removed after grace period (total: ${sessions.size})`);
|
|
812
|
+
}, SESSION_GRACE_MS);
|
|
813
|
+
(0, utils_1.logger)('info', `Session ${sid} transport closed, grace period ${SESSION_GRACE_MS / 1000}s started`);
|
|
814
|
+
};
|
|
815
|
+
const sessionServer = buildMcpServer(gitlab);
|
|
816
|
+
await sessionServer.connect(transport);
|
|
817
|
+
await transport.handleRequest(req, res, body);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
821
|
+
res.end(JSON.stringify({
|
|
822
|
+
jsonrpc: '2.0',
|
|
823
|
+
error: { code: -32000, message: 'Bad Request: invalid or missing MCP session' },
|
|
824
|
+
id: null,
|
|
825
|
+
}));
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
// ── GET / DELETE ────────────────────────────────────────────────────────
|
|
829
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
830
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
831
|
+
res.statusCode = 400;
|
|
832
|
+
res.end('Invalid or missing session ID');
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const entry = sessions.get(sessionId);
|
|
836
|
+
entry.lastActivity = Date.now();
|
|
837
|
+
await entry.transport.handleRequest(req, res);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
res.statusCode = 405;
|
|
841
|
+
res.end('Method not allowed');
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
(0, utils_1.logger)('error', 'HTTP MCP handler error', error);
|
|
845
|
+
if (!res.headersSent) {
|
|
846
|
+
res.statusCode = 500;
|
|
847
|
+
res.end('Internal Server Error');
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
// Prevent Node.js from closing idle connections before the proxy timeout
|
|
852
|
+
httpServer.keepAliveTimeout = 75000;
|
|
853
|
+
httpServer.headersTimeout = 80000;
|
|
854
|
+
// ── Graceful shutdown ───────────────────────────────────────────────────────
|
|
855
|
+
const shutdown = (signal) => {
|
|
856
|
+
(0, utils_1.logger)('info', `Received ${signal}, shutting down gracefully...`);
|
|
857
|
+
clearInterval(cleanupTimer);
|
|
858
|
+
for (const [, entry] of sessions) {
|
|
859
|
+
if (entry.gracePeriodTimer)
|
|
860
|
+
clearTimeout(entry.gracePeriodTimer);
|
|
861
|
+
}
|
|
862
|
+
sessions.clear();
|
|
863
|
+
httpServer.close(() => {
|
|
864
|
+
(0, utils_1.logger)('info', 'HTTP server closed');
|
|
865
|
+
process.exit(0);
|
|
866
|
+
});
|
|
867
|
+
// Force exit if graceful shutdown takes too long
|
|
868
|
+
setTimeout(() => {
|
|
869
|
+
(0, utils_1.logger)('warn', 'Forced exit after shutdown timeout');
|
|
870
|
+
process.exit(1);
|
|
871
|
+
}, 10000).unref();
|
|
872
|
+
};
|
|
873
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
874
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
875
|
+
httpServer.listen(port, () => {
|
|
876
|
+
(0, utils_1.logger)('info', `GitLab MCP server running on http://localhost:${port}/mcp`);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
// ─── Stdio mode ───────────────────────────────────────────────────────────────
|
|
880
|
+
async function startStdio() {
|
|
881
|
+
const token = process.env.GITLAB_TOKEN;
|
|
882
|
+
if (!token)
|
|
883
|
+
throw new Error('GITLAB_TOKEN is required in environment for stdio mode');
|
|
884
|
+
const gitlab = new gitlab_service_1.GitLabService(token);
|
|
885
|
+
const server = buildMcpServer(gitlab);
|
|
886
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
887
|
+
await server.connect(transport);
|
|
888
|
+
// Keep process alive; transport handles stdin/stdout lifecycle
|
|
889
|
+
process.once('SIGTERM', () => process.exit(0));
|
|
890
|
+
process.once('SIGINT', () => process.exit(0));
|
|
891
|
+
}
|
|
892
|
+
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
893
|
+
function validateBaseUrl() {
|
|
894
|
+
const baseUrl = process.env.GITLAB_BASE_URL;
|
|
895
|
+
if (!baseUrl)
|
|
896
|
+
throw new Error('GITLAB_BASE_URL is required in environment');
|
|
897
|
+
try {
|
|
898
|
+
const url = new URL(baseUrl);
|
|
899
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
900
|
+
throw new Error('GITLAB_BASE_URL must use http or https protocol');
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
catch (err) {
|
|
904
|
+
if (err instanceof TypeError) {
|
|
905
|
+
throw new Error(`GITLAB_BASE_URL is not a valid URL: "${baseUrl}"`);
|
|
906
|
+
}
|
|
907
|
+
throw err;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
async function start() {
|
|
911
|
+
const isStdio = process.argv.includes('--stdio');
|
|
912
|
+
validateBaseUrl();
|
|
913
|
+
if (isStdio) {
|
|
914
|
+
await startStdio();
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
await startHttp();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
start().catch((error) => {
|
|
921
|
+
(0, utils_1.logger)('error', 'Failed to start MCP server', error);
|
|
922
|
+
process.exit(1);
|
|
923
|
+
});
|