@zereight/mcp-gitlab 1.0.67 → 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 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,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
- * Initialize and run the server
3295
- * 서버 초기화 및 실행
3308
+ * Color constants for terminal output
3296
3309
  */
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);
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
- 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];
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
- 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}`);
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(),
@@ -809,7 +809,14 @@ export const UpdateMergeRequestSchema = GetMergeRequestSchema.extend({
809
809
  title: z.string().optional().describe("The title of the merge request"),
810
810
  description: z.string().optional().describe("The description of the merge request"),
811
811
  target_branch: z.string().optional().describe("The target branch"),
812
- assignee_ids: z.array(z.number()).optional().describe("The ID of the users to assign the MR to"),
812
+ assignee_ids: z
813
+ .array(z.number())
814
+ .optional()
815
+ .describe("The ID of the users to assign the MR to"),
816
+ reviewer_ids: z
817
+ .array(z.number())
818
+ .optional()
819
+ .describe("The ID of the users to assign as reviewers of the MR"),
813
820
  labels: z.array(z.string()).optional().describe("Labels for the MR"),
814
821
  state_event: z
815
822
  .enum(["close", "reopen"])
@@ -835,7 +842,7 @@ export const CreateNoteSchema = z.object({
835
842
  noteable_type: z
836
843
  .enum(["issue", "merge_request"])
837
844
  .describe("Type of noteable (issue or merge_request)"),
838
- 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"),
839
846
  body: z.string().describe("Note content"),
840
847
  });
841
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.67",
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.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",