@zereight/mcp-gitlab 1.0.68 → 1.0.69
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 +30 -0
- package/build/index.js +172 -38
- package/build/schemas.js +2 -2
- package/build/test/clients/client.js +34 -0
- package/build/test/clients/sse-client.js +95 -0
- package/build/test/clients/stdio-client.js +113 -0
- package/build/test/clients/streamable-http-client.js +95 -0
- package/build/test/test-all-transport-server.js +243 -0
- package/build/test/utils/server-launcher.js +205 -0
- package/package.json +5 -3
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
|
[](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,16 @@ 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 { randomUUID } from "crypto";
|
|
33
|
+
/**
|
|
34
|
+
* Available transport modes for MCP server
|
|
35
|
+
*/
|
|
36
|
+
var TransportMode;
|
|
37
|
+
(function (TransportMode) {
|
|
38
|
+
TransportMode["STDIO"] = "stdio";
|
|
39
|
+
TransportMode["SSE"] = "sse";
|
|
40
|
+
TransportMode["STREAMABLE_HTTP"] = "streamable-http";
|
|
41
|
+
})(TransportMode || (TransportMode = {}));
|
|
31
42
|
/**
|
|
32
43
|
* Read version from package.json
|
|
33
44
|
*/
|
|
@@ -60,6 +71,9 @@ const USE_GITLAB_WIKI = process.env.USE_GITLAB_WIKI === "true";
|
|
|
60
71
|
const USE_MILESTONE = process.env.USE_MILESTONE === "true";
|
|
61
72
|
const USE_PIPELINE = process.env.USE_PIPELINE === "true";
|
|
62
73
|
const SSE = process.env.SSE === "true";
|
|
74
|
+
const STREAMABLE_HTTP = process.env.STREAMABLE_HTTP === "true";
|
|
75
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
76
|
+
const PORT = process.env.PORT || 3002;
|
|
63
77
|
// Add proxy configuration
|
|
64
78
|
const HTTP_PROXY = process.env.HTTP_PROXY;
|
|
65
79
|
const HTTPS_PROXY = process.env.HTTPS_PROXY;
|
|
@@ -3291,51 +3305,171 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3291
3305
|
}
|
|
3292
3306
|
});
|
|
3293
3307
|
/**
|
|
3294
|
-
*
|
|
3295
|
-
* 서버 초기화 및 실행
|
|
3308
|
+
* Color constants for terminal output
|
|
3296
3309
|
*/
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3310
|
+
const colorGreen = "\x1b[32m";
|
|
3311
|
+
const colorReset = "\x1b[0m";
|
|
3312
|
+
/**
|
|
3313
|
+
* Determine the transport mode based on environment variables and availability
|
|
3314
|
+
*
|
|
3315
|
+
* Transport mode priority (highest to lowest):
|
|
3316
|
+
* 1. STREAMABLE_HTTP
|
|
3317
|
+
* 2. SSE
|
|
3318
|
+
* 3. STDIO
|
|
3319
|
+
*/
|
|
3320
|
+
function determineTransportMode() {
|
|
3321
|
+
// Check for streamable-http support (highest priority)
|
|
3322
|
+
if (STREAMABLE_HTTP) {
|
|
3323
|
+
return TransportMode.STREAMABLE_HTTP;
|
|
3324
|
+
}
|
|
3325
|
+
// Check for SSE support (medium priority)
|
|
3326
|
+
if (SSE) {
|
|
3327
|
+
return TransportMode.SSE;
|
|
3328
|
+
}
|
|
3329
|
+
// Default to stdio (lowest priority)
|
|
3330
|
+
return TransportMode.STDIO;
|
|
3331
|
+
}
|
|
3332
|
+
/**
|
|
3333
|
+
* Start server with stdio transport
|
|
3334
|
+
*/
|
|
3335
|
+
async function startStdioServer() {
|
|
3336
|
+
const transport = new StdioServerTransport();
|
|
3337
|
+
await server.connect(transport);
|
|
3338
|
+
}
|
|
3339
|
+
/**
|
|
3340
|
+
* Start server with traditional SSE transport
|
|
3341
|
+
*/
|
|
3342
|
+
async function startSSEServer() {
|
|
3343
|
+
const app = express();
|
|
3344
|
+
const transports = {};
|
|
3345
|
+
app.get("/sse", async (_, res) => {
|
|
3346
|
+
const transport = new SSEServerTransport("/messages", res);
|
|
3347
|
+
transports[transport.sessionId] = transport;
|
|
3348
|
+
res.on("close", () => {
|
|
3349
|
+
delete transports[transport.sessionId];
|
|
3350
|
+
});
|
|
3351
|
+
await server.connect(transport);
|
|
3352
|
+
});
|
|
3353
|
+
app.post("/messages", async (req, res) => {
|
|
3354
|
+
const sessionId = req.query.sessionId;
|
|
3355
|
+
const transport = transports[sessionId];
|
|
3356
|
+
if (transport) {
|
|
3357
|
+
await transport.handlePostMessage(req, res);
|
|
3306
3358
|
}
|
|
3307
3359
|
else {
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3360
|
+
res.status(400).send("No transport found for sessionId");
|
|
3361
|
+
}
|
|
3362
|
+
});
|
|
3363
|
+
app.get("/health", (_, res) => {
|
|
3364
|
+
res.status(200).json({
|
|
3365
|
+
status: "healthy",
|
|
3366
|
+
version: SERVER_VERSION,
|
|
3367
|
+
transport: TransportMode.SSE
|
|
3368
|
+
});
|
|
3369
|
+
});
|
|
3370
|
+
app.listen(Number(PORT), HOST, () => {
|
|
3371
|
+
console.log(`GitLab MCP Server running with SSE transport`);
|
|
3372
|
+
const colorGreen = "\x1b[32m";
|
|
3373
|
+
const colorReset = "\x1b[0m";
|
|
3374
|
+
console.log(`${colorGreen}Endpoint: http://${HOST}:${PORT}/sse${colorReset}`);
|
|
3375
|
+
});
|
|
3376
|
+
}
|
|
3377
|
+
/**
|
|
3378
|
+
* Start server with Streamable HTTP transport
|
|
3379
|
+
*/
|
|
3380
|
+
async function startStreamableHTTPServer() {
|
|
3381
|
+
const app = express();
|
|
3382
|
+
const streamableTransports = {};
|
|
3383
|
+
// Configure Express middleware
|
|
3384
|
+
app.use(express.json());
|
|
3385
|
+
// Streamable HTTP endpoint - handles both session creation and message handling
|
|
3386
|
+
app.post('/mcp', async (req, res) => {
|
|
3387
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
3388
|
+
try {
|
|
3389
|
+
let transport;
|
|
3390
|
+
if (sessionId && streamableTransports[sessionId]) {
|
|
3391
|
+
// Reuse existing transport for ongoing session
|
|
3392
|
+
transport = streamableTransports[sessionId];
|
|
3393
|
+
await transport.handleRequest(req, res, req.body);
|
|
3394
|
+
}
|
|
3395
|
+
else {
|
|
3396
|
+
// Create new transport for new session
|
|
3397
|
+
transport = new StreamableHTTPServerTransport({
|
|
3398
|
+
sessionIdGenerator: () => randomUUID(),
|
|
3399
|
+
onsessioninitialized: (newSessionId) => {
|
|
3400
|
+
streamableTransports[newSessionId] = transport;
|
|
3401
|
+
console.warn(`Streamable HTTP session initialized: ${newSessionId}`);
|
|
3402
|
+
}
|
|
3315
3403
|
});
|
|
3404
|
+
// Set up cleanup handler when transport closes
|
|
3405
|
+
transport.onclose = () => {
|
|
3406
|
+
const sid = transport.sessionId;
|
|
3407
|
+
if (sid && streamableTransports[sid]) {
|
|
3408
|
+
console.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
|
|
3409
|
+
delete streamableTransports[sid];
|
|
3410
|
+
}
|
|
3411
|
+
};
|
|
3412
|
+
// Connect transport to MCP server before handling the request
|
|
3316
3413
|
await server.connect(transport);
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
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}`);
|
|
3414
|
+
await transport.handleRequest(req, res, req.body);
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
catch (error) {
|
|
3418
|
+
console.error('Streamable HTTP error:', error);
|
|
3419
|
+
res.status(500).json({
|
|
3420
|
+
error: 'Internal server error',
|
|
3421
|
+
message: error instanceof Error ? error.message : 'Unknown error'
|
|
3337
3422
|
});
|
|
3338
3423
|
}
|
|
3424
|
+
});
|
|
3425
|
+
// Health check endpoint
|
|
3426
|
+
app.get("/health", (_, res) => {
|
|
3427
|
+
res.status(200).json({
|
|
3428
|
+
status: "healthy",
|
|
3429
|
+
version: SERVER_VERSION,
|
|
3430
|
+
transport: TransportMode.STREAMABLE_HTTP,
|
|
3431
|
+
activeSessions: Object.keys(streamableTransports).length
|
|
3432
|
+
});
|
|
3433
|
+
});
|
|
3434
|
+
// Start server
|
|
3435
|
+
app.listen(Number(PORT), HOST, () => {
|
|
3436
|
+
console.log(`GitLab MCP Server running with Streamable HTTP transport`);
|
|
3437
|
+
console.log(`${colorGreen}Endpoint: http://${HOST}:${PORT}/mcp${colorReset}`);
|
|
3438
|
+
});
|
|
3439
|
+
}
|
|
3440
|
+
/**
|
|
3441
|
+
* Initialize server with specific transport mode
|
|
3442
|
+
* Handle transport-specific initialization logic
|
|
3443
|
+
*/
|
|
3444
|
+
async function initializeServerByTransportMode(mode) {
|
|
3445
|
+
console.log('Initializing server with transport mode:', mode);
|
|
3446
|
+
switch (mode) {
|
|
3447
|
+
case TransportMode.STDIO:
|
|
3448
|
+
console.warn('Starting GitLab MCP Server with stdio transport');
|
|
3449
|
+
await startStdioServer();
|
|
3450
|
+
break;
|
|
3451
|
+
case TransportMode.SSE:
|
|
3452
|
+
console.warn('Starting GitLab MCP Server with SSE transport');
|
|
3453
|
+
await startSSEServer();
|
|
3454
|
+
break;
|
|
3455
|
+
case TransportMode.STREAMABLE_HTTP:
|
|
3456
|
+
console.warn('Starting GitLab MCP Server with Streamable HTTP transport');
|
|
3457
|
+
await startStreamableHTTPServer();
|
|
3458
|
+
break;
|
|
3459
|
+
default:
|
|
3460
|
+
// This should never happen with proper enum usage, but TypeScript requires it
|
|
3461
|
+
const exhaustiveCheck = mode;
|
|
3462
|
+
throw new Error(`Unknown transport mode: ${exhaustiveCheck}`);
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
/**
|
|
3466
|
+
* Initialize and run the server
|
|
3467
|
+
* Main entry point for server startup
|
|
3468
|
+
*/
|
|
3469
|
+
async function runServer() {
|
|
3470
|
+
try {
|
|
3471
|
+
const transportMode = determineTransportMode();
|
|
3472
|
+
await initializeServerByTransportMode(transportMode);
|
|
3339
3473
|
}
|
|
3340
3474
|
catch (error) {
|
|
3341
3475
|
console.error("Error initializing server:", error);
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.69",
|
|
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
|
-
"test": "node test/validate-api.js",
|
|
26
|
+
"test": "node test/validate-api.js && npm run test:server",
|
|
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.
|
|
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",
|