@zereight/mcp-gitlab 1.0.68 → 1.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -129,6 +129,34 @@ docker run -i --rm \
129
129
  }
130
130
  ```
131
131
 
132
+
133
+ - streamable-http
134
+
135
+
136
+ ```shell
137
+ docker run -i --rm \
138
+ -e GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token \
139
+ -e GITLAB_API_URL="https://gitlab.com/api/v4" \
140
+ -e GITLAB_READ_ONLY_MODE=true \
141
+ -e USE_GITLAB_WIKI=true \
142
+ -e USE_MILESTONE=true \
143
+ -e USE_PIPELINE=true \
144
+ -e STREAMABLE_HTTP=true \
145
+ -p 3333:3002 \
146
+ iwakitakuma/gitlab-mcp
147
+ ```
148
+
149
+ ```json
150
+ {
151
+ "mcpServers": {
152
+ "GitLab communication server": {
153
+ "url": "http://localhost:3333/mcp"
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+
132
160
  #### Docker Image Push
133
161
 
134
162
  ```shell
@@ -145,6 +173,8 @@ $ sh scripts/image_push.sh docker_user_name
145
173
  - `USE_MILESTONE`: When set to 'true', enables the milestone-related tools (list_milestones, get_milestone, create_milestone, edit_milestone, delete_milestone, get_milestone_issue, get_milestone_merge_requests, promote_milestone, get_milestone_burndown_events). By default, milestone features are disabled.
146
174
  - `USE_PIPELINE`: When set to 'true', enables the pipeline-related tools (list_pipelines, get_pipeline, list_pipeline_jobs, get_pipeline_job, get_pipeline_job_output, create_pipeline, retry_pipeline, cancel_pipeline). By default, pipeline features are disabled.
147
175
  - `GITLAB_AUTH_COOKIE_PATH`: Path to an authentication cookie file for GitLab instances that require cookie-based authentication. When provided, the cookie will be included in all GitLab API requests.
176
+ - `SSE`: When set to 'true', enables the Server-Sent Events transport.
177
+ - `STREAMABLE_HTTP`: When set to 'true', enables the Streamable HTTP transport. If both **SSE** and **STREAMABLE_HTTP** are set to 'true', the server will prioritize Streamable HTTP over SSE transport.
148
178
 
149
179
  [![Star History Chart](https://api.star-history.com/svg?repos=zereight/gitlab-mcp&type=Date)](https://www.star-history.com/#zereight/gitlab-mcp&Date)
150
180
 
package/build/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
6
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
7
  import nodeFetch from "node-fetch";
7
8
  import fetchCookie from "fetch-cookie";
@@ -28,6 +29,28 @@ GitLabDiscussionNoteSchema, // Added
28
29
  GitLabDiscussionSchema, PaginatedDiscussionsResponseSchema, UpdateMergeRequestNoteSchema, // Added
29
30
  CreateMergeRequestNoteSchema, // Added
30
31
  ListMergeRequestDiscussionsSchema, UpdateIssueNoteSchema, CreateIssueNoteSchema, ListMergeRequestsSchema, GitLabMilestonesSchema, ListProjectMilestonesSchema, GetProjectMilestoneSchema, CreateProjectMilestoneSchema, EditProjectMilestoneSchema, DeleteProjectMilestoneSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, PromoteProjectMilestoneSchema, GetMilestoneBurndownEventsSchema, GitLabCompareResultSchema, GetBranchDiffsSchema, ListCommitsSchema, GetCommitSchema, GetCommitDiffSchema, ListMergeRequestDiffsSchema, } from "./schemas.js";
32
+ import { formatBoolean } from "./utils.js";
33
+ import { randomUUID } from "crypto";
34
+ import { pino } from 'pino';
35
+ const logger = pino({
36
+ level: process.env.LOG_LEVEL || 'info',
37
+ transport: {
38
+ target: 'pino-pretty',
39
+ options: {
40
+ colorize: true,
41
+ levelFirst: true,
42
+ },
43
+ },
44
+ });
45
+ /**
46
+ * Available transport modes for MCP server
47
+ */
48
+ var TransportMode;
49
+ (function (TransportMode) {
50
+ TransportMode["STDIO"] = "stdio";
51
+ TransportMode["SSE"] = "sse";
52
+ TransportMode["STREAMABLE_HTTP"] = "streamable-http";
53
+ })(TransportMode || (TransportMode = {}));
31
54
  /**
32
55
  * Read version from package.json
33
56
  */
@@ -60,6 +83,9 @@ const USE_GITLAB_WIKI = process.env.USE_GITLAB_WIKI === "true";
60
83
  const USE_MILESTONE = process.env.USE_MILESTONE === "true";
61
84
  const USE_PIPELINE = process.env.USE_PIPELINE === "true";
62
85
  const SSE = process.env.SSE === "true";
86
+ const STREAMABLE_HTTP = process.env.STREAMABLE_HTTP === "true";
87
+ const HOST = process.env.HOST || '0.0.0.0';
88
+ const PORT = process.env.PORT || 3002;
63
89
  // Add proxy configuration
64
90
  const HTTP_PROXY = process.env.HTTP_PROXY;
65
91
  const HTTPS_PROXY = process.env.HTTPS_PROXY;
@@ -130,7 +156,7 @@ const createCookieJar = () => {
130
156
  return jar;
131
157
  }
132
158
  catch (error) {
133
- console.error("Error loading cookie file:", error);
159
+ logger.error("Error loading cookie file:", error);
134
160
  return null;
135
161
  }
136
162
  };
@@ -628,7 +654,7 @@ function normalizeGitLabApiUrl(url) {
628
654
  const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || "");
629
655
  const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
630
656
  if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
631
- console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
657
+ logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
632
658
  process.exit(1);
633
659
  }
634
660
  /**
@@ -643,8 +669,8 @@ async function handleGitLabError(response) {
643
669
  const errorBody = await response.text();
644
670
  // Check specifically for Rate Limit error
645
671
  if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) {
646
- console.error("GitLab API Rate Limit Exceeded:", errorBody);
647
- console.log("User API Key Rate limit exceeded. Please try again later.");
672
+ logger.error("GitLab API Rate Limit Exceeded:", errorBody);
673
+ logger.error("User API Key Rate limit exceeded. Please try again later.");
648
674
  throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`);
649
675
  }
650
676
  else {
@@ -1032,7 +1058,7 @@ async function createMergeRequest(projectId, options) {
1032
1058
  labels: options.labels?.join(","),
1033
1059
  allow_collaboration: options.allow_collaboration,
1034
1060
  draft: options.draft,
1035
- remove_source_branch: options.remove_source_branch,
1061
+ remove_source_branch: formatBoolean(options.remove_source_branch),
1036
1062
  squash: options.squash,
1037
1063
  }),
1038
1064
  });
@@ -2162,10 +2188,7 @@ async function createPipeline(projectId, ref, variables) {
2162
2188
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline`);
2163
2189
  const body = { ref };
2164
2190
  if (variables && variables.length > 0) {
2165
- body.variables = variables.reduce((acc, { key, value }) => {
2166
- acc[key] = value;
2167
- return acc;
2168
- }, {});
2191
+ body.variables = variables;
2169
2192
  }
2170
2193
  const response = await fetch(url.toString(), {
2171
2194
  method: "POST",
@@ -2242,7 +2265,7 @@ async function getRepositoryTree(options) {
2242
2265
  else {
2243
2266
  headers["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`;
2244
2267
  }
2245
- const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(options.project_id)}/repository/tree?${queryParams.toString()}`, {
2268
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
2246
2269
  headers,
2247
2270
  });
2248
2271
  if (response.status === 404) {
@@ -2442,7 +2465,7 @@ async function getUser(username) {
2442
2465
  return null;
2443
2466
  }
2444
2467
  catch (error) {
2445
- console.error(`Error fetching user by username '${username}':`, error);
2468
+ logger.error(`Error fetching user by username '${username}':`, error);
2446
2469
  return null;
2447
2470
  }
2448
2471
  }
@@ -2461,7 +2484,7 @@ async function getUsers(usernames) {
2461
2484
  users[username] = user;
2462
2485
  }
2463
2486
  catch (error) {
2464
- console.error(`Error processing username '${username}':`, error);
2487
+ logger.error(`Error processing username '${username}':`, error);
2465
2488
  users[username] = null;
2466
2489
  }
2467
2490
  }
@@ -2607,7 +2630,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2607
2630
  };
2608
2631
  }
2609
2632
  catch (forkError) {
2610
- console.error("Error forking repository:", forkError);
2633
+ logger.error("Error forking repository:", forkError);
2611
2634
  let forkErrorMessage = "Failed to fork repository";
2612
2635
  if (forkError instanceof Error) {
2613
2636
  forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`;
@@ -2828,7 +2851,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2828
2851
  }
2829
2852
  case "get_project": {
2830
2853
  const args = GetProjectSchema.parse(request.params.arguments);
2831
- const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(args.project_id)}`);
2854
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(args.project_id))}`);
2832
2855
  const response = await fetch(url.toString(), {
2833
2856
  ...DEFAULT_FETCH_CONFIG,
2834
2857
  });
@@ -3291,58 +3314,178 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3291
3314
  }
3292
3315
  });
3293
3316
  /**
3294
- * Initialize and run the server
3295
- * 서버 초기화 및 실행
3317
+ * Color constants for terminal output
3296
3318
  */
3297
- async function runServer() {
3298
- try {
3299
- // Server startup banner removed - inappropriate use of console.error for logging
3300
- // Server version banner removed - inappropriate use of console.error for logging
3301
- // API URL banner removed - inappropriate use of console.error for logging
3302
- // Server startup banner removed - inappropriate use of console.error for logging
3303
- if (!SSE) {
3304
- const transport = new StdioServerTransport();
3305
- await server.connect(transport);
3319
+ const colorGreen = "\x1b[32m";
3320
+ const colorReset = "\x1b[0m";
3321
+ /**
3322
+ * Determine the transport mode based on environment variables and availability
3323
+ *
3324
+ * Transport mode priority (highest to lowest):
3325
+ * 1. STREAMABLE_HTTP
3326
+ * 2. SSE
3327
+ * 3. STDIO
3328
+ */
3329
+ function determineTransportMode() {
3330
+ // Check for streamable-http support (highest priority)
3331
+ if (STREAMABLE_HTTP) {
3332
+ return TransportMode.STREAMABLE_HTTP;
3333
+ }
3334
+ // Check for SSE support (medium priority)
3335
+ if (SSE) {
3336
+ return TransportMode.SSE;
3337
+ }
3338
+ // Default to stdio (lowest priority)
3339
+ return TransportMode.STDIO;
3340
+ }
3341
+ /**
3342
+ * Start server with stdio transport
3343
+ */
3344
+ async function startStdioServer() {
3345
+ const transport = new StdioServerTransport();
3346
+ await server.connect(transport);
3347
+ }
3348
+ /**
3349
+ * Start server with traditional SSE transport
3350
+ */
3351
+ async function startSSEServer() {
3352
+ const app = express();
3353
+ const transports = {};
3354
+ app.get("/sse", async (_, res) => {
3355
+ const transport = new SSEServerTransport("/messages", res);
3356
+ transports[transport.sessionId] = transport;
3357
+ res.on("close", () => {
3358
+ delete transports[transport.sessionId];
3359
+ });
3360
+ await server.connect(transport);
3361
+ });
3362
+ app.post("/messages", async (req, res) => {
3363
+ const sessionId = req.query.sessionId;
3364
+ const transport = transports[sessionId];
3365
+ if (transport) {
3366
+ await transport.handlePostMessage(req, res);
3306
3367
  }
3307
3368
  else {
3308
- const app = express();
3309
- const transports = {};
3310
- app.get("/sse", async (_, res) => {
3311
- const transport = new SSEServerTransport("/messages", res);
3312
- transports[transport.sessionId] = transport;
3313
- res.on("close", () => {
3314
- delete transports[transport.sessionId];
3369
+ res.status(400).send("No transport found for sessionId");
3370
+ }
3371
+ });
3372
+ app.get("/health", (_, res) => {
3373
+ res.status(200).json({
3374
+ status: "healthy",
3375
+ version: SERVER_VERSION,
3376
+ transport: TransportMode.SSE
3377
+ });
3378
+ });
3379
+ app.listen(Number(PORT), HOST, () => {
3380
+ logger.info(`GitLab MCP Server running with SSE transport`);
3381
+ const colorGreen = "\x1b[32m";
3382
+ const colorReset = "\x1b[0m";
3383
+ logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/sse${colorReset}`);
3384
+ });
3385
+ }
3386
+ /**
3387
+ * Start server with Streamable HTTP transport
3388
+ */
3389
+ async function startStreamableHTTPServer() {
3390
+ const app = express();
3391
+ const streamableTransports = {};
3392
+ // Configure Express middleware
3393
+ app.use(express.json());
3394
+ // Streamable HTTP endpoint - handles both session creation and message handling
3395
+ app.post('/mcp', async (req, res) => {
3396
+ const sessionId = req.headers['mcp-session-id'];
3397
+ try {
3398
+ let transport;
3399
+ if (sessionId && streamableTransports[sessionId]) {
3400
+ // Reuse existing transport for ongoing session
3401
+ transport = streamableTransports[sessionId];
3402
+ await transport.handleRequest(req, res, req.body);
3403
+ }
3404
+ else {
3405
+ // Create new transport for new session
3406
+ transport = new StreamableHTTPServerTransport({
3407
+ sessionIdGenerator: () => randomUUID(),
3408
+ onsessioninitialized: (newSessionId) => {
3409
+ streamableTransports[newSessionId] = transport;
3410
+ logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
3411
+ }
3315
3412
  });
3413
+ // Set up cleanup handler when transport closes
3414
+ transport.onclose = () => {
3415
+ const sid = transport.sessionId;
3416
+ if (sid && streamableTransports[sid]) {
3417
+ logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
3418
+ delete streamableTransports[sid];
3419
+ }
3420
+ };
3421
+ // Connect transport to MCP server before handling the request
3316
3422
  await server.connect(transport);
3317
- });
3318
- app.post("/messages", async (req, res) => {
3319
- const sessionId = req.query.sessionId;
3320
- const transport = transports[sessionId];
3321
- if (transport) {
3322
- await transport.handlePostMessage(req, res);
3323
- }
3324
- else {
3325
- res.status(400).send("No transport found for sessionId");
3326
- }
3327
- });
3328
- app.get("/health", (_, res) => {
3329
- res.status(200).json({
3330
- status: "healthy",
3331
- version: process.env.npm_package_version || "unknown",
3332
- });
3333
- });
3334
- const PORT = process.env.PORT || 3002;
3335
- app.listen(PORT, () => {
3336
- console.log(`Server is running on port ${PORT}`);
3423
+ await transport.handleRequest(req, res, req.body);
3424
+ }
3425
+ }
3426
+ catch (error) {
3427
+ logger.error('Streamable HTTP error:', error);
3428
+ res.status(500).json({
3429
+ error: 'Internal server error',
3430
+ message: error instanceof Error ? error.message : 'Unknown error'
3337
3431
  });
3338
3432
  }
3433
+ });
3434
+ // Health check endpoint
3435
+ app.get("/health", (_, res) => {
3436
+ res.status(200).json({
3437
+ status: "healthy",
3438
+ version: SERVER_VERSION,
3439
+ transport: TransportMode.STREAMABLE_HTTP,
3440
+ activeSessions: Object.keys(streamableTransports).length
3441
+ });
3442
+ });
3443
+ // Start server
3444
+ app.listen(Number(PORT), HOST, () => {
3445
+ logger.info(`GitLab MCP Server running with Streamable HTTP transport`);
3446
+ logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/mcp${colorReset}`);
3447
+ });
3448
+ }
3449
+ /**
3450
+ * Initialize server with specific transport mode
3451
+ * Handle transport-specific initialization logic
3452
+ */
3453
+ async function initializeServerByTransportMode(mode) {
3454
+ logger.info('Initializing server with transport mode:', mode);
3455
+ switch (mode) {
3456
+ case TransportMode.STDIO:
3457
+ logger.warn('Starting GitLab MCP Server with stdio transport');
3458
+ await startStdioServer();
3459
+ break;
3460
+ case TransportMode.SSE:
3461
+ logger.warn('Starting GitLab MCP Server with SSE transport');
3462
+ await startSSEServer();
3463
+ break;
3464
+ case TransportMode.STREAMABLE_HTTP:
3465
+ logger.warn('Starting GitLab MCP Server with Streamable HTTP transport');
3466
+ await startStreamableHTTPServer();
3467
+ break;
3468
+ default:
3469
+ // This should never happen with proper enum usage, but TypeScript requires it
3470
+ const exhaustiveCheck = mode;
3471
+ throw new Error(`Unknown transport mode: ${exhaustiveCheck}`);
3472
+ }
3473
+ }
3474
+ /**
3475
+ * Initialize and run the server
3476
+ * Main entry point for server startup
3477
+ */
3478
+ async function runServer() {
3479
+ try {
3480
+ const transportMode = determineTransportMode();
3481
+ await initializeServerByTransportMode(transportMode);
3339
3482
  }
3340
3483
  catch (error) {
3341
- console.error("Error initializing server:", error);
3484
+ logger.error("Error initializing server:", error);
3342
3485
  process.exit(1);
3343
3486
  }
3344
3487
  }
3345
3488
  runServer().catch(error => {
3346
- console.error("Fatal error in main():", error);
3489
+ logger.error("Fatal error in main():", error);
3347
3490
  process.exit(1);
3348
3491
  });
package/build/schemas.js CHANGED
@@ -632,7 +632,7 @@ export const GitLabDiscussionNoteSchema = z.object({
632
632
  noteable_id: z.number(),
633
633
  noteable_type: z.enum(["Issue", "MergeRequest", "Snippet", "Commit", "Epic"]),
634
634
  project_id: z.number().optional(), // Optional for group-level discussions like Epics
635
- noteable_iid: z.number().nullable(),
635
+ noteable_iid: z.coerce.number().nullable(),
636
636
  resolvable: z.boolean().optional(),
637
637
  resolved: z.boolean().optional(),
638
638
  resolved_by: GitLabUserSchema.nullable().optional(),
@@ -842,7 +842,7 @@ export const CreateNoteSchema = z.object({
842
842
  noteable_type: z
843
843
  .enum(["issue", "merge_request"])
844
844
  .describe("Type of noteable (issue or merge_request)"),
845
- noteable_iid: z.number().describe("IID of the issue or merge request"),
845
+ noteable_iid: z.coerce.number().describe("IID of the issue or merge request"),
846
846
  body: z.string().describe("Note content"),
847
847
  });
848
848
  // Issues API operation schemas
@@ -0,0 +1,34 @@
1
+ /**
2
+ * MCP Client Interface and error classes for testing
3
+ */
4
+ /**
5
+ * Base error class for MCP client errors
6
+ */
7
+ export class MCPClientError extends Error {
8
+ cause;
9
+ constructor(message, cause) {
10
+ super(message);
11
+ this.cause = cause;
12
+ this.name = 'MCPClientError';
13
+ }
14
+ }
15
+ /**
16
+ * Connection error for MCP clients
17
+ */
18
+ export class MCPConnectionError extends MCPClientError {
19
+ constructor(message, cause) {
20
+ super(message, cause);
21
+ this.name = 'MCPConnectionError';
22
+ }
23
+ }
24
+ /**
25
+ * Tool call error for MCP clients
26
+ */
27
+ export class MCPToolCallError extends MCPClientError {
28
+ toolName;
29
+ constructor(message, toolName, cause) {
30
+ super(message, cause);
31
+ this.toolName = toolName;
32
+ this.name = 'MCPToolCallError';
33
+ }
34
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * SSE MCP Client for testing
3
+ */
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
6
+ import { MCPConnectionError, MCPToolCallError } from "./client.js";
7
+ export class SSETestClient {
8
+ client;
9
+ transport = null;
10
+ constructor() {
11
+ this.client = new Client({ name: "test-client", version: "1.0.0" });
12
+ }
13
+ /**
14
+ * Connect to MCP server via SSE
15
+ */
16
+ async connect(url) {
17
+ if (this.transport) {
18
+ throw new MCPConnectionError('Client is already connected');
19
+ }
20
+ try {
21
+ this.transport = new SSEClientTransport(new URL(url));
22
+ await this.client.connect(this.transport);
23
+ }
24
+ catch (error) {
25
+ this.transport = null;
26
+ throw new MCPConnectionError(`Failed to connect to SSE server: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
27
+ }
28
+ }
29
+ /**
30
+ * Disconnect from server
31
+ */
32
+ async disconnect() {
33
+ if (this.transport) {
34
+ try {
35
+ await this.transport.close();
36
+ }
37
+ catch (error) {
38
+ // Log but don't throw on disconnect errors
39
+ console.warn('Warning during disconnect:', error);
40
+ }
41
+ finally {
42
+ this.transport = null;
43
+ }
44
+ }
45
+ }
46
+ /**
47
+ * List available tools from server
48
+ */
49
+ async listTools() {
50
+ if (!this.transport) {
51
+ throw new MCPConnectionError('Client is not connected');
52
+ }
53
+ try {
54
+ const response = await this.client.listTools();
55
+ return response;
56
+ }
57
+ catch (error) {
58
+ throw new MCPToolCallError(`Failed to list tools: ${error instanceof Error ? error.message : String(error)}`, 'listTools', error instanceof Error ? error : undefined);
59
+ }
60
+ }
61
+ /**
62
+ * Call a tool on the server
63
+ */
64
+ async callTool(name, arguments_ = {}) {
65
+ if (!this.transport) {
66
+ throw new MCPConnectionError('Client is not connected');
67
+ }
68
+ try {
69
+ const response = await this.client.callTool({ name, arguments: arguments_ });
70
+ // Ensure the response conforms to CallToolResult interface
71
+ return response;
72
+ }
73
+ catch (error) {
74
+ throw new MCPToolCallError(`Failed to call tool '${name}': ${error instanceof Error ? error.message : String(error)}`, name, error instanceof Error ? error : undefined);
75
+ }
76
+ }
77
+ /**
78
+ * Test connection by listing tools
79
+ */
80
+ async testConnection() {
81
+ try {
82
+ const tools = await this.listTools();
83
+ return Array.isArray(tools.tools) && tools.tools.length > 0;
84
+ }
85
+ catch (error) {
86
+ return false;
87
+ }
88
+ }
89
+ /**
90
+ * Get client connection status
91
+ */
92
+ get isConnected() {
93
+ return this.transport !== null;
94
+ }
95
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Stdio MCP Client for testing
3
+ */
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import { MCPConnectionError, MCPToolCallError } from "./client.js";
7
+ export class StdioTestClient {
8
+ client;
9
+ transport = null;
10
+ constructor() {
11
+ this.client = new Client({ name: "test-client", version: "1.0.0" });
12
+ }
13
+ /**
14
+ * Connect to MCP server via stdio
15
+ */
16
+ async connect(serverPath, env) {
17
+ if (this.transport) {
18
+ throw new MCPConnectionError('Client is already connected');
19
+ }
20
+ try {
21
+ const command = process.execPath;
22
+ const args = [serverPath];
23
+ // Prepare environment variables for the server process
24
+ const serverEnv = {};
25
+ // Copy process.env, filtering out undefined values
26
+ for (const [key, value] of Object.entries(process.env)) {
27
+ if (value !== undefined) {
28
+ serverEnv[key] = value;
29
+ }
30
+ }
31
+ // Add custom environment variables
32
+ if (env) {
33
+ Object.assign(serverEnv, env);
34
+ }
35
+ this.transport = new StdioClientTransport({
36
+ command,
37
+ args,
38
+ env: serverEnv
39
+ });
40
+ await this.client.connect(this.transport);
41
+ }
42
+ catch (error) {
43
+ this.transport = null;
44
+ throw new MCPConnectionError(`Failed to connect to stdio server: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
45
+ }
46
+ }
47
+ /**
48
+ * Disconnect from server
49
+ */
50
+ async disconnect() {
51
+ if (this.transport) {
52
+ try {
53
+ await this.transport.close();
54
+ }
55
+ catch (error) {
56
+ // Log but don't throw on disconnect errors
57
+ console.warn('Warning during disconnect:', error);
58
+ }
59
+ finally {
60
+ this.transport = null;
61
+ }
62
+ }
63
+ }
64
+ /**
65
+ * List available tools from server
66
+ */
67
+ async listTools() {
68
+ if (!this.transport) {
69
+ throw new MCPConnectionError('Client is not connected');
70
+ }
71
+ try {
72
+ const response = await this.client.listTools();
73
+ return response;
74
+ }
75
+ catch (error) {
76
+ throw new MCPToolCallError(`Failed to list tools: ${error instanceof Error ? error.message : String(error)}`, 'listTools', error instanceof Error ? error : undefined);
77
+ }
78
+ }
79
+ /**
80
+ * Call a tool on the server
81
+ */
82
+ async callTool(name, arguments_ = {}) {
83
+ if (!this.transport) {
84
+ throw new MCPConnectionError('Client is not connected');
85
+ }
86
+ try {
87
+ const response = await this.client.callTool({ name, arguments: arguments_ });
88
+ // Ensure the response conforms to CallToolResult interface
89
+ return response;
90
+ }
91
+ catch (error) {
92
+ throw new MCPToolCallError(`Failed to call tool '${name}': ${error instanceof Error ? error.message : String(error)}`, name, error instanceof Error ? error : undefined);
93
+ }
94
+ }
95
+ /**
96
+ * Test connection by listing tools
97
+ */
98
+ async testConnection() {
99
+ try {
100
+ const tools = await this.listTools();
101
+ return Array.isArray(tools.tools) && tools.tools.length > 0;
102
+ }
103
+ catch (error) {
104
+ return false;
105
+ }
106
+ }
107
+ /**
108
+ * Get client connection status
109
+ */
110
+ get isConnected() {
111
+ return this.transport !== null;
112
+ }
113
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Streamable HTTP MCP Client for testing
3
+ */
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ import { MCPConnectionError, MCPToolCallError } from "./client.js";
7
+ export class StreamableHTTPTestClient {
8
+ client;
9
+ transport = null;
10
+ constructor() {
11
+ this.client = new Client({ name: "test-client", version: "1.0.0" });
12
+ }
13
+ /**
14
+ * Connect to MCP server via Streamable HTTP
15
+ */
16
+ async connect(url) {
17
+ if (this.transport) {
18
+ throw new MCPConnectionError('Client is already connected');
19
+ }
20
+ try {
21
+ this.transport = new StreamableHTTPClientTransport(new URL(url));
22
+ await this.client.connect(this.transport);
23
+ }
24
+ catch (error) {
25
+ this.transport = null;
26
+ throw new MCPConnectionError(`Failed to connect to Streamable HTTP server: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
27
+ }
28
+ }
29
+ /**
30
+ * Disconnect from server
31
+ */
32
+ async disconnect() {
33
+ if (this.transport) {
34
+ try {
35
+ await this.transport.close();
36
+ }
37
+ catch (error) {
38
+ // Log but don't throw on disconnect errors
39
+ console.warn('Warning during disconnect:', error);
40
+ }
41
+ finally {
42
+ this.transport = null;
43
+ }
44
+ }
45
+ }
46
+ /**
47
+ * List available tools from server
48
+ */
49
+ async listTools() {
50
+ if (!this.transport) {
51
+ throw new MCPConnectionError('Client is not connected');
52
+ }
53
+ try {
54
+ const response = await this.client.listTools();
55
+ return response;
56
+ }
57
+ catch (error) {
58
+ throw new MCPToolCallError(`Failed to list tools: ${error instanceof Error ? error.message : String(error)}`, 'listTools', error instanceof Error ? error : undefined);
59
+ }
60
+ }
61
+ /**
62
+ * Call a tool on the server
63
+ */
64
+ async callTool(name, arguments_ = {}) {
65
+ if (!this.transport) {
66
+ throw new MCPConnectionError('Client is not connected');
67
+ }
68
+ try {
69
+ const response = await this.client.callTool({ name, arguments: arguments_ });
70
+ // Ensure the response conforms to CallToolResult interface
71
+ return response;
72
+ }
73
+ catch (error) {
74
+ throw new MCPToolCallError(`Failed to call tool '${name}': ${error instanceof Error ? error.message : String(error)}`, name, error instanceof Error ? error : undefined);
75
+ }
76
+ }
77
+ /**
78
+ * Test connection by listing tools
79
+ */
80
+ async testConnection() {
81
+ try {
82
+ const tools = await this.listTools();
83
+ return Array.isArray(tools.tools) && tools.tools.length > 0;
84
+ }
85
+ catch (error) {
86
+ return false;
87
+ }
88
+ }
89
+ /**
90
+ * Get client connection status
91
+ */
92
+ get isConnected() {
93
+ return this.transport !== null;
94
+ }
95
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * GitLab MCP Server Transport Tests
3
+ * Tests all three transport modes: stdio, SSE, and streamable-http
4
+ */
5
+ import * as path from 'path';
6
+ import { describe, test, after, before } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, checkHealthEndpoint, HOST } from './utils/server-launcher.js';
9
+ import { StdioTestClient } from './clients/stdio-client.js';
10
+ import { SSETestClient } from './clients/sse-client.js';
11
+ import { StreamableHTTPTestClient } from './clients/streamable-http-client.js';
12
+ console.log('🚀 GitLab MCP Server Tests');
13
+ console.log('');
14
+ // Configuration check
15
+ const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
16
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN_TEST || process.env.GITLAB_TOKEN;
17
+ const TEST_PROJECT_ID = process.env.TEST_PROJECT_ID;
18
+ console.log('🔧 Test Configuration:');
19
+ console.log(` GitLab URL: ${GITLAB_API_URL}`);
20
+ console.log(` Token: ${GITLAB_TOKEN ? '✅ Provided' : '❌ Missing'}`);
21
+ console.log(` Project ID: ${TEST_PROJECT_ID || '❌ Missing'}`);
22
+ // Validate required configuration
23
+ if (!GITLAB_TOKEN) {
24
+ console.error('❌ Error: GITLAB_TOKEN_TEST or GITLAB_TOKEN environment variable is required for testing');
25
+ console.error(' Set one of these variables to your GitLab API token');
26
+ process.exit(1);
27
+ }
28
+ if (!TEST_PROJECT_ID) {
29
+ console.error('❌ Error: TEST_PROJECT_ID environment variable is required for testing');
30
+ console.error(' Set this variable to a valid GitLab project ID (e.g., "123" or "group/project")');
31
+ process.exit(1);
32
+ }
33
+ console.log('✅ Configuration validated');
34
+ console.log('');
35
+ let servers = [];
36
+ // Cleanup function for all tests
37
+ const cleanup = () => {
38
+ cleanupServers(servers);
39
+ servers = [];
40
+ };
41
+ // Handle process termination
42
+ process.on('SIGINT', cleanup);
43
+ process.on('SIGTERM', cleanup);
44
+ process.on('exit', cleanup);
45
+ describe('GitLab MCP Server - Stdio Transport', () => {
46
+ let client;
47
+ // Prepare environment variables for stdio server
48
+ const stdioEnv = {
49
+ GITLAB_PERSONAL_ACCESS_TOKEN: GITLAB_TOKEN,
50
+ GITLAB_API_URL: `${GITLAB_API_URL}/api/v4`,
51
+ GITLAB_PROJECT_ID: TEST_PROJECT_ID,
52
+ GITLAB_READ_ONLY_MODE: 'true',
53
+ // Explicitly disable other transport modes to ensure stdio mode
54
+ SSE: 'false',
55
+ STREAMABLE_HTTP: 'false'
56
+ };
57
+ before(async () => {
58
+ client = new StdioTestClient();
59
+ const serverPath = path.resolve(process.cwd(), 'build/index.js');
60
+ await client.connect(serverPath, stdioEnv);
61
+ assert.ok(client.isConnected, 'Client should be connected');
62
+ console.log('Client connected to stdio server');
63
+ });
64
+ after(async () => {
65
+ if (client && client.isConnected) {
66
+ await client.disconnect();
67
+ }
68
+ });
69
+ test('should list tools via stdio', async () => {
70
+ const tools = await client.listTools();
71
+ assert.ok(tools !== null && tools !== undefined, 'Tools response should be defined');
72
+ assert.ok('tools' in tools, 'Response should have tools property');
73
+ assert.ok(Array.isArray(tools.tools) && tools.tools.length > 0, 'Tools array should not be empty');
74
+ // Check for specific GitLab tools with proper typing
75
+ const toolNames = tools.tools.map(tool => tool.name);
76
+ assert.ok(toolNames.includes('list_merge_requests'), 'Should have list_merge_requests tool');
77
+ assert.ok(toolNames.includes('get_project'), 'Should have get_project tool');
78
+ // Verify tools have proper structure
79
+ const gitlabTools = tools.tools.filter(tool => tool.name === 'list_merge_requests' || tool.name === 'get_project');
80
+ assert.ok(gitlabTools.length >= 2, 'Should have at least 2 GitLab tools');
81
+ for (const tool of gitlabTools) {
82
+ assert.ok(tool.description !== null && tool.description !== undefined, `Tool ${tool.name} should have description`);
83
+ assert.ok('inputSchema' in tool, `Tool ${tool.name} should have input schema`);
84
+ }
85
+ });
86
+ test('should call list_merge_requests tool via stdio', async () => {
87
+ const result = await client.callTool('list_merge_requests', {
88
+ project_id: TEST_PROJECT_ID
89
+ });
90
+ assert.ok(result !== null && result !== undefined, 'Tool call result should be defined');
91
+ assert.ok('content' in result, 'Result should have content property');
92
+ });
93
+ test('should call get_project tool via stdio', async () => {
94
+ const result = await client.callTool('get_project', {
95
+ project_id: TEST_PROJECT_ID
96
+ });
97
+ // Verify proper CallToolResult structure
98
+ assert.ok(result !== null && result !== undefined, 'Tool call result should be defined');
99
+ assert.ok('content' in result, 'Result should have content property');
100
+ assert.ok(Array.isArray(result.content), 'Content should be an array');
101
+ assert.ok(result.content.length > 0, 'Content array should not be empty');
102
+ // Check content structure
103
+ const firstContent = result.content[0];
104
+ assert.ok(firstContent !== null && firstContent !== undefined, 'First content item should be defined');
105
+ assert.ok('type' in firstContent, 'Content item should have type');
106
+ assert.strictEqual(firstContent.type, 'text', 'Content type should be text');
107
+ assert.ok('text' in firstContent, 'Text content should have text property');
108
+ // Verify it's valid JSON containing project info
109
+ const projectData = JSON.parse(firstContent.text);
110
+ assert.ok(projectData !== null && projectData !== undefined, 'Project data should be parseable JSON');
111
+ assert.ok('id' in projectData, 'Project should have id');
112
+ assert.ok('name' in projectData, 'Project should have name');
113
+ });
114
+ });
115
+ describe('GitLab MCP Server - SSE Transport', () => {
116
+ let server;
117
+ let client;
118
+ let port;
119
+ before(async () => {
120
+ port = await findAvailablePort();
121
+ server = await launchServer({
122
+ mode: TransportMode.SSE,
123
+ port,
124
+ timeout: 3000,
125
+ env: {
126
+ SSE: 'true',
127
+ STREAMABLE_HTTP: 'false'
128
+ }
129
+ });
130
+ servers.push(server);
131
+ // Verify server started successfully
132
+ assert.ok(server.process.pid !== undefined, 'Server process should have PID');
133
+ assert.strictEqual(server.mode, TransportMode.SSE, 'Server mode should be SSE');
134
+ assert.strictEqual(server.port, port, 'Server should use correct port');
135
+ // Verify health check
136
+ const health = await checkHealthEndpoint(server.port);
137
+ assert.strictEqual(health.status, 'healthy', 'Health status should be healthy');
138
+ assert.strictEqual(health.transport, 'sse', 'Transport should be SSE');
139
+ assert.ok(health.version !== null && health.version !== undefined, 'Version should be defined');
140
+ // Create and connect client
141
+ client = new SSETestClient();
142
+ await client.connect(`http://${HOST}:${port}/sse`);
143
+ assert.ok(client.isConnected, 'Client should be connected');
144
+ assert.ok(await client.testConnection(), 'Connection test should pass');
145
+ console.log('Client connected to SSE server');
146
+ });
147
+ after(async () => {
148
+ if (client && client.isConnected) {
149
+ await client.disconnect();
150
+ }
151
+ cleanup();
152
+ console.log('Client disconnected from SSE server');
153
+ });
154
+ test('should list tools via SSE', async () => {
155
+ const tools = await client.listTools();
156
+ assert.ok(tools !== null && tools !== undefined, 'Tools response should be defined');
157
+ assert.ok('tools' in tools, 'Response should have tools property');
158
+ assert.ok(Array.isArray(tools.tools) && tools.tools.length > 0, 'Tools array should not be empty');
159
+ // Check for specific GitLab tools
160
+ const toolNames = tools.tools.map((tool) => tool.name);
161
+ assert.ok(toolNames.includes('list_merge_requests'), 'Should have list_merge_requests tool');
162
+ assert.ok(toolNames.includes('get_project'), 'Should have get_project tool');
163
+ });
164
+ test('should call list_merge_requests tool via SSE', async () => {
165
+ const result = await client.callTool('list_merge_requests', {
166
+ project_id: TEST_PROJECT_ID
167
+ });
168
+ assert.ok(result !== null && result !== undefined, 'Tool call result should be defined');
169
+ assert.ok('content' in result, 'Result should have content property');
170
+ });
171
+ test('should call get_project tool via SSE', async () => {
172
+ const result = await client.callTool('get_project', {
173
+ project_id: TEST_PROJECT_ID
174
+ });
175
+ assert.ok(result !== null && result !== undefined, 'Tool call result should be defined');
176
+ assert.ok('content' in result, 'Result should have content property');
177
+ });
178
+ });
179
+ describe('GitLab MCP Server - Streamable HTTP Transport', () => {
180
+ let server;
181
+ let client;
182
+ let port;
183
+ before(async () => {
184
+ port = await findAvailablePort();
185
+ server = await launchServer({
186
+ mode: TransportMode.STREAMABLE_HTTP,
187
+ port,
188
+ timeout: 3000,
189
+ env: {
190
+ SSE: 'false',
191
+ STREAMABLE_HTTP: 'true'
192
+ }
193
+ });
194
+ servers.push(server);
195
+ // Verify server started successfully
196
+ assert.ok(server.process.pid !== undefined, 'Server process should have PID');
197
+ assert.strictEqual(server.mode, TransportMode.STREAMABLE_HTTP, 'Server mode should be streamable-http');
198
+ assert.strictEqual(server.port, port, 'Server should use correct port');
199
+ // Verify health check
200
+ const health = await checkHealthEndpoint(server.port);
201
+ assert.strictEqual(health.status, 'healthy', 'Health status should be healthy');
202
+ assert.strictEqual(health.transport, 'streamable-http', 'Transport should be streamable-http');
203
+ assert.ok(health.version !== null && health.version !== undefined, 'Version should be defined');
204
+ assert.ok(health.activeSessions !== null && health.activeSessions !== undefined, 'Active sessions should be defined');
205
+ // Create and connect client
206
+ client = new StreamableHTTPTestClient();
207
+ await client.connect(`http://${HOST}:${port}/mcp`);
208
+ assert.ok(client.isConnected, 'Client should be connected');
209
+ assert.ok(await client.testConnection(), 'Connection test should pass');
210
+ console.log('Client connected to Streamable HTTP server');
211
+ });
212
+ after(async () => {
213
+ if (client && client.isConnected) {
214
+ await client.disconnect();
215
+ }
216
+ cleanup();
217
+ console.log('Client disconnected from Streamable HTTP server');
218
+ });
219
+ test('should list tools via Streamable HTTP', async () => {
220
+ const tools = await client.listTools();
221
+ assert.ok(tools !== null && tools !== undefined, 'Tools response should be defined');
222
+ assert.ok('tools' in tools, 'Response should have tools property');
223
+ assert.ok(Array.isArray(tools.tools) && tools.tools.length > 0, 'Tools array should not be empty');
224
+ // Check for specific GitLab tools
225
+ const toolNames = tools.tools.map((tool) => tool.name);
226
+ assert.ok(toolNames.includes('list_merge_requests'), 'Should have list_merge_requests tool');
227
+ assert.ok(toolNames.includes('get_project'), 'Should have get_project tool');
228
+ });
229
+ test('should call list_merge_requests tool via Streamable HTTP', async () => {
230
+ const result = await client.callTool('list_merge_requests', {
231
+ project_id: TEST_PROJECT_ID
232
+ });
233
+ assert.ok(result !== null && result !== undefined, 'Tool call result should be defined');
234
+ assert.ok('content' in result, 'Result should have content property');
235
+ });
236
+ test('should call get_project tool via Streamable HTTP', async () => {
237
+ const result = await client.callTool('get_project', {
238
+ project_id: TEST_PROJECT_ID
239
+ });
240
+ assert.ok(result !== null && result !== undefined, 'Tool call result should be defined');
241
+ assert.ok('content' in result, 'Result should have content property');
242
+ });
243
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Server launcher utility for testing different transport modes
3
+ * Manages server processes and provides clean shutdown
4
+ */
5
+ import { spawn } from 'child_process';
6
+ import * as path from 'path';
7
+ export const HOST = process.env.HOST || '127.0.0.1';
8
+ export var TransportMode;
9
+ (function (TransportMode) {
10
+ TransportMode["STDIO"] = "stdio";
11
+ TransportMode["SSE"] = "sse";
12
+ TransportMode["STREAMABLE_HTTP"] = "streamable-http";
13
+ })(TransportMode || (TransportMode = {}));
14
+ /**
15
+ * Launch a server with specified configuration
16
+ */
17
+ export async function launchServer(config) {
18
+ const { mode, port = 3002, env = {}, timeout = 3000 } = config;
19
+ // Prepare environment variables based on transport mode
20
+ // Use same configuration pattern as existing validate-api.js
21
+ const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
22
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN_TEST || process.env.GITLAB_TOKEN;
23
+ const TEST_PROJECT_ID = process.env.TEST_PROJECT_ID;
24
+ // Validate that we have required configuration
25
+ if (!GITLAB_TOKEN) {
26
+ throw new Error('GITLAB_TOKEN_TEST or GITLAB_TOKEN environment variable is required for server testing');
27
+ }
28
+ if (!TEST_PROJECT_ID) {
29
+ throw new Error('TEST_PROJECT_ID environment variable is required for server testing');
30
+ }
31
+ const serverEnv = {
32
+ // Add all environment variables from the current process
33
+ ...process.env,
34
+ GITLAB_API_URL: `${GITLAB_API_URL}/api/v4`,
35
+ GITLAB_PROJECT_ID: TEST_PROJECT_ID,
36
+ GITLAB_READ_ONLY_MODE: 'true', // Use read-only mode for testing
37
+ ...env,
38
+ };
39
+ // Set transport-specific environment variables
40
+ switch (mode) {
41
+ case TransportMode.SSE:
42
+ serverEnv.SSE = 'true';
43
+ serverEnv.PORT = port.toString();
44
+ break;
45
+ case TransportMode.STREAMABLE_HTTP:
46
+ serverEnv.STREAMABLE_HTTP = 'true';
47
+ serverEnv.PORT = port.toString();
48
+ break;
49
+ case TransportMode.STDIO:
50
+ // Stdio mode doesn't need port configuration - uses process communication
51
+ throw new Error(`${TransportMode.STDIO} mode is not supported for server testing, because it uses process communication.`);
52
+ }
53
+ const serverPath = path.resolve(process.cwd(), 'build/index.js');
54
+ const serverProcess = spawn('node', [serverPath], {
55
+ env: serverEnv,
56
+ stdio: ['pipe', 'pipe', 'pipe'],
57
+ detached: false
58
+ });
59
+ // Wait for server to start
60
+ await waitForServerStart(serverProcess, mode, port, timeout);
61
+ const instance = {
62
+ process: serverProcess,
63
+ port: port,
64
+ mode,
65
+ kill: () => {
66
+ if (!serverProcess.killed) {
67
+ serverProcess.kill('SIGTERM');
68
+ // Force kill if not terminated within 5 seconds
69
+ setTimeout(() => {
70
+ if (!serverProcess.killed) {
71
+ serverProcess.kill('SIGKILL');
72
+ }
73
+ }, 5000);
74
+ }
75
+ }
76
+ };
77
+ return instance;
78
+ }
79
+ /**
80
+ * Wait for server to start based on transport mode
81
+ */
82
+ async function waitForServerStart(process, mode, port, timeout) {
83
+ return new Promise((resolve, reject) => {
84
+ const timer = setTimeout(() => {
85
+ reject(new Error(`Server failed to start within ${timeout}ms for mode ${mode}`));
86
+ }, timeout);
87
+ let outputBuffer = '';
88
+ const onData = (data) => {
89
+ const output = data.toString();
90
+ outputBuffer += output;
91
+ // Check for server start messages
92
+ const startMessages = [
93
+ 'Starting GitLab MCP Server with stdio transport',
94
+ 'Starting GitLab MCP Server with SSE transport',
95
+ 'Starting GitLab MCP Server with Streamable HTTP transport',
96
+ 'GitLab MCP Server running',
97
+ `port ${port}`
98
+ ];
99
+ const hasStartMessage = startMessages.some(msg => outputBuffer.includes(msg));
100
+ if (hasStartMessage) {
101
+ clearTimeout(timer);
102
+ process.stdout?.removeListener('data', onData);
103
+ process.stderr?.removeListener('data', onData);
104
+ // Additional wait for HTTP servers to be fully ready
105
+ if (mode !== TransportMode.STDIO) {
106
+ setTimeout(resolve, 1000);
107
+ }
108
+ else {
109
+ resolve();
110
+ }
111
+ }
112
+ };
113
+ const onError = (error) => {
114
+ clearTimeout(timer);
115
+ reject(new Error(`Server process error: ${error.message}`));
116
+ };
117
+ const onExit = (code) => {
118
+ clearTimeout(timer);
119
+ reject(new Error(`Server process exited with code ${code} before starting`));
120
+ };
121
+ process.stdout?.on('data', onData);
122
+ process.stderr?.on('data', onData);
123
+ process.on('error', onError);
124
+ process.on('exit', onExit);
125
+ });
126
+ }
127
+ /**
128
+ * Find an available port starting from a base port
129
+ */
130
+ export async function findAvailablePort(basePort = 3002) {
131
+ const net = await import('net');
132
+ return new Promise((resolve, reject) => {
133
+ const server = net.createServer();
134
+ server.listen(basePort, () => {
135
+ const address = server.address();
136
+ const port = typeof address === 'object' && address ? address.port : basePort;
137
+ server.close(() => resolve(port));
138
+ });
139
+ server.on('error', (err) => {
140
+ if (err.code === 'EADDRINUSE') {
141
+ // Port is in use, try next one
142
+ resolve(findAvailablePort(basePort + 1));
143
+ }
144
+ else {
145
+ reject(err);
146
+ }
147
+ });
148
+ });
149
+ }
150
+ /**
151
+ * Clean shutdown for multiple server instances
152
+ */
153
+ export function cleanupServers(servers) {
154
+ servers.forEach(server => {
155
+ try {
156
+ server.kill();
157
+ }
158
+ catch (error) {
159
+ console.warn(`Failed to kill server process: ${error}`);
160
+ }
161
+ });
162
+ }
163
+ /**
164
+ * Create AbortController with timeout
165
+ */
166
+ export function createTimeoutController(timeout) {
167
+ const controller = new AbortController();
168
+ setTimeout(() => controller.abort(), timeout);
169
+ return controller;
170
+ }
171
+ /**
172
+ * Check if a health endpoint is responding
173
+ */
174
+ export async function checkHealthEndpoint(port, maxRetries = 5) {
175
+ let lastError;
176
+ for (let i = 0; i < maxRetries; i++) {
177
+ try {
178
+ const controller = createTimeoutController(5000);
179
+ const response = await fetch(`http://${HOST}:${port}/health`, {
180
+ method: 'GET',
181
+ signal: controller.signal
182
+ });
183
+ if (response.ok) {
184
+ const healthData = await response.json();
185
+ return healthData;
186
+ }
187
+ else {
188
+ throw new Error(`Health check failed with status ${response.status}`);
189
+ }
190
+ }
191
+ catch (error) {
192
+ if (error instanceof Error && error.name === 'AbortError') {
193
+ lastError = new Error('Request timeout after 5000ms');
194
+ }
195
+ else {
196
+ lastError = error instanceof Error ? error : new Error(String(error));
197
+ }
198
+ if (i < maxRetries - 1) {
199
+ // Wait before retry
200
+ await new Promise(resolve => setTimeout(resolve, 1000));
201
+ }
202
+ }
203
+ }
204
+ throw lastError;
205
+ }
package/build/utils.js ADDED
@@ -0,0 +1,9 @@
1
+ export function formatBoolean(value) {
2
+ if (value == null) {
3
+ return value;
4
+ }
5
+ if (typeof value === 'string') {
6
+ return value == 'true';
7
+ }
8
+ return value;
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "1.0.68",
3
+ "version": "1.0.70",
4
4
  "description": "MCP server for using the GitLab API",
5
5
  "license": "MIT",
6
6
  "author": "zereight",
@@ -18,19 +18,21 @@
18
18
  "scripts": {
19
19
  "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
20
20
  "prepare": "npm run build",
21
+ "dev": "npm run build && node build/index.js",
21
22
  "watch": "tsc --watch",
22
23
  "deploy": "npm publish --access public",
23
24
  "generate-tools": "npx ts-node scripts/generate-tools-readme.ts",
24
25
  "changelog": "auto-changelog -p",
25
26
  "test": "node test/validate-api.js",
26
27
  "test:integration": "node test/validate-api.js",
28
+ "test:server": "npm run build && node build/test/test-all-transport-server.js",
27
29
  "lint": "eslint . --ext .ts",
28
30
  "lint:fix": "eslint . --ext .ts --fix",
29
31
  "format": "prettier --write \"**/*.{js,ts,json,md}\"",
30
32
  "format:check": "prettier --check \"**/*.{js,ts,json,md}\""
31
33
  },
32
34
  "dependencies": {
33
- "@modelcontextprotocol/sdk": "1.8.0",
35
+ "@modelcontextprotocol/sdk": "^1.10.0",
34
36
  "@types/node-fetch": "^2.6.12",
35
37
  "express": "^5.1.0",
36
38
  "fetch-cookie": "^3.1.0",
@@ -38,6 +40,8 @@
38
40
  "http-proxy-agent": "^7.0.2",
39
41
  "https-proxy-agent": "^7.0.6",
40
42
  "node-fetch": "^3.3.2",
43
+ "pino": "^9.7.0",
44
+ "pino-pretty": "^13.0.0",
41
45
  "socks-proxy-agent": "^8.0.5",
42
46
  "tough-cookie": "^5.1.2",
43
47
  "zod-to-json-schema": "^3.23.5"
@@ -47,11 +51,11 @@
47
51
  "@types/node": "^22.13.10",
48
52
  "@typescript-eslint/eslint-plugin": "^8.21.0",
49
53
  "@typescript-eslint/parser": "^8.21.0",
54
+ "auto-changelog": "^2.4.0",
50
55
  "eslint": "^9.18.0",
51
56
  "prettier": "^3.4.2",
52
57
  "ts-node": "^10.9.2",
53
58
  "typescript": "^5.8.2",
54
- "zod": "^3.24.2",
55
- "auto-changelog": "^2.4.0"
59
+ "zod": "^3.24.2"
56
60
  }
57
61
  }