@zereight/mcp-gitlab 2.1.47 → 2.1.48

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.ko.md CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
82
82
 
83
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.46`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
83
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.47`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
84
84
 
85
85
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
86
86
 
package/README.md CHANGED
@@ -82,7 +82,7 @@ npm install -g @zereight/mcp-gitlab
82
82
 
83
83
  The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
84
84
 
85
- No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.46`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
85
+ No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.47`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
86
86
 
87
87
  #### Using CLI Arguments (for clients with env var issues)
88
88
 
package/README.zh-CN.md CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
82
82
 
83
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.46`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
83
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.47`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
84
84
 
85
85
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
86
86
 
package/build/index.js CHANGED
@@ -85,7 +85,7 @@ import { createDownloadToken } from "./utils/download-token.js";
85
85
  import { determineTransportMode, TransportMode } from "./server/transport-mode.js";
86
86
  import { formatPrometheusMetrics } from "./server/metrics.js";
87
87
  import { SERVER_VERSION } from "./server/version.js";
88
- import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
88
+ import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
89
89
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
90
90
  import { normalizeGitLabApiUrl } from "./utils/url.js";
91
91
  import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
@@ -162,7 +162,9 @@ function createServer() {
162
162
  version: SERVER_VERSION,
163
163
  }, {
164
164
  capabilities: {
165
- tools: {},
165
+ tools: {
166
+ listChanged: true,
167
+ },
166
168
  },
167
169
  });
168
170
  mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -10511,13 +10513,87 @@ async function startStreamableHTTPServer() {
10511
10513
  await handleRequest();
10512
10514
  }
10513
10515
  });
10514
- // Reject unsupported methods on /mcp
10515
- app.get("/mcp", (_req, res) => {
10516
- res.setHeader("Allow", "POST, DELETE");
10517
- res.status(405).json({
10518
- error: "Method Not Allowed",
10519
- message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server.",
10520
- });
10516
+ // Streamable HTTP GET endpoint for listening to server-sent events (SSE)
10517
+ app.get("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
10518
+ const sessionId = readMcpSessionIdHeader(req);
10519
+ const acceptHeader = readAcceptHeader(req);
10520
+ if (!acceptHeader.includes("text/event-stream")) {
10521
+ res.setHeader("Allow", "GET, POST, DELETE");
10522
+ res.status(406).json({
10523
+ error: "Not Acceptable",
10524
+ message: "Client must accept text/event-stream for GET /mcp",
10525
+ });
10526
+ return;
10527
+ }
10528
+ if (!sessionId) {
10529
+ res.setHeader("Allow", "GET, POST, DELETE");
10530
+ res.status(400).json({
10531
+ error: "Bad Request",
10532
+ message: "Mcp-Session-Id header is required for GET /mcp",
10533
+ });
10534
+ return;
10535
+ }
10536
+ if (OAUTH_STATELESS_MODE && STATELESS_MATERIAL && (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
10537
+ res.setHeader("Allow", "POST");
10538
+ res.status(405).json({
10539
+ error: "Method Not Allowed",
10540
+ message: "GET /mcp SSE stream is not supported in stateless mode. Use POST to communicate with the MCP server.",
10541
+ });
10542
+ return;
10543
+ }
10544
+ const transport = streamableTransports[sessionId];
10545
+ if (!transport) {
10546
+ res.status(404).json({
10547
+ error: "Session not found",
10548
+ });
10549
+ return;
10550
+ }
10551
+ metrics.requestsProcessed++;
10552
+ const usesSessionTimeouts = REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH;
10553
+ if (usesSessionTimeouts) {
10554
+ if (authBySession[sessionId]) {
10555
+ authBySession[sessionId].lastUsed = Date.now();
10556
+ }
10557
+ // Listening on GET /mcp is session activity. Pause inactivity expiry for
10558
+ // the life of the SSE stream so list_changed can still be pushed.
10559
+ clearAuthTimeout(sessionId);
10560
+ }
10561
+ const handleGetRequest = async () => {
10562
+ try {
10563
+ await transport.handleRequest(req, res);
10564
+ }
10565
+ catch (error) {
10566
+ logger.error({ err: error }, "Streamable HTTP GET error");
10567
+ if (!res.headersSent) {
10568
+ res.status(500).json({
10569
+ error: "Internal server error",
10570
+ message: error instanceof Error ? error.message : "Unknown error",
10571
+ });
10572
+ }
10573
+ }
10574
+ };
10575
+ try {
10576
+ if (usesSessionTimeouts && authBySession[sessionId]) {
10577
+ const authData = authBySession[sessionId];
10578
+ const ctx = {
10579
+ sessionId,
10580
+ header: authData.header,
10581
+ token: authData.token,
10582
+ lastUsed: authData.lastUsed,
10583
+ apiUrl: authData.apiUrl,
10584
+ publicBaseUrl: authData.publicBaseUrl,
10585
+ };
10586
+ await sessionAuthStore.run(ctx, handleGetRequest);
10587
+ }
10588
+ else {
10589
+ await handleGetRequest();
10590
+ }
10591
+ }
10592
+ finally {
10593
+ if (usesSessionTimeouts && streamableTransports[sessionId]) {
10594
+ setAuthTimeout(sessionId);
10595
+ }
10596
+ }
10521
10597
  });
10522
10598
  const getMetricsSnapshot = () => ({
10523
10599
  ...metrics,
@@ -10585,6 +10661,14 @@ async function startStreamableHTTPServer() {
10585
10661
  res.status(404).json({ error: "Session not found" });
10586
10662
  }
10587
10663
  });
10664
+ // Reject unsupported methods on /mcp
10665
+ app.all("/mcp", (_req, res) => {
10666
+ res.setHeader("Allow", "GET, POST, DELETE");
10667
+ res.status(405).json({
10668
+ error: "Method Not Allowed",
10669
+ message: "Method Not Allowed. Use GET, POST, or DELETE to communicate with the MCP server.",
10670
+ });
10671
+ });
10588
10672
  // Start server
10589
10673
  const httpServer = app.listen(Number(PORT), HOST, () => {
10590
10674
  logger.info(`GitLab MCP Server running with Streamable HTTP transport`);
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { allTools } from "../tools/registry.js";
4
+ async function main() {
5
+ const pkgPath = path.resolve(process.cwd(), "package.json");
6
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
7
+ const serverCard = {
8
+ serverInfo: {
9
+ name: pkg.mcpName || pkg.name,
10
+ version: pkg.version,
11
+ },
12
+ authentication: {
13
+ required: true,
14
+ schemes: ["bearer", "oauth2"],
15
+ },
16
+ tools: allTools.map(t => {
17
+ const tool = { ...t };
18
+ if (tool.inputSchema &&
19
+ typeof tool.inputSchema === "object" &&
20
+ "$schema" in tool.inputSchema) {
21
+ const schema = { ...tool.inputSchema };
22
+ delete schema["$schema"];
23
+ tool.inputSchema = schema;
24
+ }
25
+ return tool;
26
+ }),
27
+ resources: [],
28
+ prompts: [],
29
+ };
30
+ const outDir = path.resolve(process.cwd(), ".well-known", "mcp");
31
+ fs.mkdirSync(outDir, { recursive: true });
32
+ const outFile = path.join(outDir, "server-card.json");
33
+ fs.writeFileSync(outFile, JSON.stringify(serverCard, null, 2), "utf8");
34
+ console.log(`Successfully generated ${outFile} (${allTools.length} tools)`);
35
+ }
36
+ main().catch(err => {
37
+ console.error("Failed to generate server card:", err);
38
+ process.exit(1);
39
+ });
@@ -61,3 +61,15 @@ export function readMcpSessionIdHeader(req) {
61
61
  return undefined;
62
62
  return raw.length > 0 ? raw : undefined;
63
63
  }
64
+ /**
65
+ * Normalize an `Accept` header. Duplicate Accept values surface as `string[]`
66
+ * in Node; joining them preserves `text/event-stream` if any value listed it.
67
+ */
68
+ export function readAcceptHeader(req) {
69
+ const raw = req.headers.accept;
70
+ if (typeof raw === "string")
71
+ return raw;
72
+ if (Array.isArray(raw))
73
+ return raw.filter(value => typeof value === "string").join(", ");
74
+ return "";
75
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from "node:test";
2
2
  import assert from "node:assert";
3
- import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readMcpSessionIdHeader, redactSessionIdForLog, } from "../../server/request-helpers.js";
3
+ import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "../../server/request-helpers.js";
4
4
  describe("streamable request helpers", () => {
5
5
  test("redacts stateless session ids and truncates legacy ids", () => {
6
6
  assert.strictEqual(redactSessionIdForLog(undefined), "<none>");
@@ -30,4 +30,9 @@ describe("streamable request helpers", () => {
30
30
  assert.strictEqual(readMcpSessionIdHeader({ headers: { "mcp-session-id": ["sid-1", "sid-2"] } }), undefined);
31
31
  assert.strictEqual(readMcpSessionIdHeader({ headers: {} }), undefined);
32
32
  });
33
+ test("joins duplicate Accept headers so text/event-stream is not dropped", () => {
34
+ assert.strictEqual(readAcceptHeader({ headers: { accept: "application/json, text/event-stream" } }), "application/json, text/event-stream");
35
+ assert.strictEqual(readAcceptHeader({ headers: { accept: ["application/json", "text/event-stream"] } }), "application/json, text/event-stream");
36
+ assert.strictEqual(readAcceptHeader({ headers: {} }), "");
37
+ });
33
38
  });
@@ -0,0 +1,350 @@
1
+ import { after, before, describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
4
+ import { findMockServerPort, MockGitLabServer } from "./utils/mock-gitlab-server.js";
5
+ const VALID_TOKEN = "test-token-sse-stream-12345";
6
+ async function postJsonRpc(url, body, headers = {}) {
7
+ const response = await fetch(url, {
8
+ method: "POST",
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ Accept: "application/json, text/event-stream",
12
+ ...headers,
13
+ },
14
+ body: JSON.stringify(body),
15
+ });
16
+ const sessionId = response.headers.get("mcp-session-id");
17
+ const text = await response.text();
18
+ const contentType = response.headers.get("content-type") ?? "";
19
+ if (contentType.includes("text/event-stream")) {
20
+ const dataLines = text
21
+ .split("\n")
22
+ .filter(line => line.startsWith("data: "))
23
+ .map(line => line.slice(6));
24
+ const lastLine = dataLines.at(-1);
25
+ const parsedData = lastLine && lastLine.length > 0 ? JSON.parse(lastLine) : null;
26
+ return {
27
+ status: response.status,
28
+ data: parsedData,
29
+ sessionId,
30
+ text,
31
+ };
32
+ }
33
+ const parsedData = text.length > 0 ? JSON.parse(text) : null;
34
+ return {
35
+ status: response.status,
36
+ data: parsedData,
37
+ sessionId,
38
+ text,
39
+ };
40
+ }
41
+ describe("When Streamable HTTP server is running", () => {
42
+ let mockGitLab = null;
43
+ let server = null;
44
+ let mcpUrl = "";
45
+ before(async () => {
46
+ const mockPort = await findMockServerPort();
47
+ mockGitLab = new MockGitLabServer({
48
+ port: mockPort,
49
+ validTokens: [VALID_TOKEN],
50
+ });
51
+ await mockGitLab.start();
52
+ const port = await findAvailablePort(3720);
53
+ server = await launchServer({
54
+ mode: TransportMode.STREAMABLE_HTTP,
55
+ port,
56
+ timeout: 10_000,
57
+ env: {
58
+ STREAMABLE_HTTP: "true",
59
+ REMOTE_AUTHORIZATION: "true",
60
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
61
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
62
+ GITLAB_TOOLSETS: "merge_requests",
63
+ },
64
+ });
65
+ mcpUrl = `http://${HOST}:${port}/mcp`;
66
+ });
67
+ after(async () => {
68
+ if (server) {
69
+ await cleanupServers([server]);
70
+ server = null;
71
+ }
72
+ if (mockGitLab) {
73
+ await mockGitLab.stop();
74
+ mockGitLab = null;
75
+ }
76
+ });
77
+ describe("with initialize handshake", () => {
78
+ it("should advertise tools.listChanged capability in server capabilities", async () => {
79
+ // Given: initialize request body
80
+ const initBody = {
81
+ jsonrpc: "2.0",
82
+ id: 1,
83
+ method: "initialize",
84
+ params: {
85
+ protocolVersion: "2025-03-26",
86
+ capabilities: {},
87
+ clientInfo: { name: "test-client", version: "1.0.0" },
88
+ },
89
+ };
90
+ // When: client sends initialize request to /mcp
91
+ const response = await postJsonRpc(mcpUrl, initBody);
92
+ // Then: response should be 200 and include tools.listChanged: true
93
+ assert.strictEqual(response.status, 200, response.text);
94
+ assert.ok(response.sessionId, "initialize should return Mcp-Session-Id");
95
+ const result = response.data?.result;
96
+ assert.ok(typeof result === "object" && result !== null, "Result should be an object");
97
+ assert.ok(result.capabilities?.tools, "capabilities.tools should be defined");
98
+ assert.strictEqual(result.capabilities.tools.listChanged, true, "tools.listChanged capability must be true");
99
+ });
100
+ });
101
+ describe("with GET requests on /mcp", () => {
102
+ it("should return 406 when Accept header lacks text/event-stream", async () => {
103
+ // Given: GET request without Accept: text/event-stream
104
+ const response = await fetch(mcpUrl, {
105
+ method: "GET",
106
+ headers: {
107
+ Accept: "application/json",
108
+ },
109
+ });
110
+ // When: server evaluates the request
111
+ const body = await response.json();
112
+ // Then: server should respond with 406 Not Acceptable
113
+ assert.strictEqual(response.status, 406);
114
+ assert.strictEqual(body?.error, "Not Acceptable");
115
+ const allowHeader = response.headers.get("allow");
116
+ assert.ok(allowHeader?.includes("GET"), "Allow header should contain GET");
117
+ assert.ok(allowHeader?.includes("POST"), "Allow header should contain POST");
118
+ assert.ok(allowHeader?.includes("DELETE"), "Allow header should contain DELETE");
119
+ });
120
+ it("should return 400 when Mcp-Session-Id header is missing", async () => {
121
+ // Given: GET request with Accept: text/event-stream but without Mcp-Session-Id
122
+ const response = await fetch(mcpUrl, {
123
+ method: "GET",
124
+ headers: {
125
+ Accept: "text/event-stream",
126
+ },
127
+ });
128
+ // When: server evaluates the request
129
+ const body = await response.json();
130
+ // Then: server should respond with 400 Bad Request
131
+ assert.strictEqual(response.status, 400);
132
+ assert.strictEqual(body?.error, "Bad Request");
133
+ });
134
+ it("should return 404 when Mcp-Session-Id does not match any active session", async () => {
135
+ // Given: GET request with unknown session ID
136
+ const response = await fetch(mcpUrl, {
137
+ method: "GET",
138
+ headers: {
139
+ Accept: "text/event-stream",
140
+ "Mcp-Session-Id": "non-existent-session-id-12345",
141
+ },
142
+ });
143
+ // When: server evaluates the request
144
+ const body = await response.json();
145
+ // Then: server should respond with 404 Session not found
146
+ assert.strictEqual(response.status, 404);
147
+ assert.strictEqual(body?.error, "Session not found");
148
+ });
149
+ it("should establish SSE stream when valid Mcp-Session-Id and Accept header are provided", async () => {
150
+ // Given: an initialized session
151
+ const initBody = {
152
+ jsonrpc: "2.0",
153
+ id: 10,
154
+ method: "initialize",
155
+ params: {
156
+ protocolVersion: "2025-03-26",
157
+ capabilities: {},
158
+ clientInfo: { name: "sse-stream-client", version: "1.0.0" },
159
+ },
160
+ };
161
+ const initResponse = await postJsonRpc(mcpUrl, initBody);
162
+ const sessionId = initResponse.sessionId;
163
+ assert.ok(sessionId, "Session ID should be present");
164
+ // When: client connects with GET /mcp and Accept: text/event-stream
165
+ const abortController = new AbortController();
166
+ const sseResponse = await fetch(mcpUrl, {
167
+ method: "GET",
168
+ headers: {
169
+ Accept: "text/event-stream",
170
+ "Mcp-Session-Id": sessionId,
171
+ },
172
+ signal: abortController.signal,
173
+ });
174
+ // Then: response should be 200 with Content-Type text/event-stream
175
+ assert.strictEqual(sseResponse.status, 200);
176
+ const contentType = sseResponse.headers.get("content-type") ?? "";
177
+ assert.ok(contentType.includes("text/event-stream"), "Content-Type must be text/event-stream");
178
+ abortController.abort();
179
+ });
180
+ it("should receive list_changed notification over SSE stream when discover_tools runs", async () => {
181
+ // Given: an authenticated session connected to GET /mcp SSE stream
182
+ const initBody = {
183
+ jsonrpc: "2.0",
184
+ id: 20,
185
+ method: "initialize",
186
+ params: {
187
+ protocolVersion: "2025-03-26",
188
+ capabilities: {},
189
+ clientInfo: { name: "sse-notify-client", version: "1.0.0" },
190
+ },
191
+ };
192
+ const initResponse = await postJsonRpc(mcpUrl, initBody, {
193
+ "Private-Token": VALID_TOKEN,
194
+ });
195
+ const sessionId = initResponse.sessionId;
196
+ assert.ok(sessionId, "Session ID should be present");
197
+ // Initialized notification
198
+ await postJsonRpc(mcpUrl, {
199
+ jsonrpc: "2.0",
200
+ method: "notifications/initialized",
201
+ }, {
202
+ "mcp-session-id": sessionId,
203
+ "Private-Token": VALID_TOKEN,
204
+ });
205
+ const abortController = new AbortController();
206
+ const sseResponse = await fetch(mcpUrl, {
207
+ method: "GET",
208
+ headers: {
209
+ Accept: "text/event-stream",
210
+ "Mcp-Session-Id": sessionId,
211
+ "Private-Token": VALID_TOKEN,
212
+ },
213
+ signal: abortController.signal,
214
+ });
215
+ assert.strictEqual(sseResponse.status, 200);
216
+ const streamReader = sseResponse.body?.getReader();
217
+ assert.ok(streamReader, "SSE response body reader should exist");
218
+ const decoder = new TextDecoder();
219
+ let receivedEvents = "";
220
+ // Background listener to collect SSE chunks
221
+ const readPromise = (async () => {
222
+ try {
223
+ while (true) {
224
+ const { done, value } = await streamReader.read();
225
+ if (done)
226
+ break;
227
+ receivedEvents += decoder.decode(value, { stream: true });
228
+ if (receivedEvents.includes("notifications/tools/list_changed")) {
229
+ break;
230
+ }
231
+ }
232
+ }
233
+ catch {
234
+ // Stream closed or aborted
235
+ }
236
+ })();
237
+ // When: client calls discover_tools to activate pipelines category
238
+ const discoverResponse = await postJsonRpc(mcpUrl, {
239
+ jsonrpc: "2.0",
240
+ id: 21,
241
+ method: "tools/call",
242
+ params: {
243
+ name: "discover_tools",
244
+ arguments: {
245
+ category: "pipelines",
246
+ },
247
+ },
248
+ }, {
249
+ "mcp-session-id": sessionId,
250
+ "Private-Token": VALID_TOKEN,
251
+ });
252
+ // Then: discover_tools succeeds and list_changed notification arrives on SSE stream
253
+ assert.strictEqual(discoverResponse.status, 200, discoverResponse.text);
254
+ let timeoutHandle;
255
+ try {
256
+ await Promise.race([
257
+ readPromise,
258
+ new Promise(resolve => {
259
+ timeoutHandle = setTimeout(resolve, 3000);
260
+ }),
261
+ ]);
262
+ }
263
+ finally {
264
+ if (timeoutHandle)
265
+ clearTimeout(timeoutHandle);
266
+ }
267
+ abortController.abort();
268
+ assert.ok(receivedEvents.includes("notifications/tools/list_changed"), `SSE stream should receive notifications/tools/list_changed. Received: ${receivedEvents}`);
269
+ });
270
+ });
271
+ describe("with unsupported HTTP methods", () => {
272
+ it("should return 405 Method Not Allowed for PUT requests", async () => {
273
+ // Given: PUT request to /mcp
274
+ const response = await fetch(mcpUrl, { method: "PUT" });
275
+ // When: server evaluates the method
276
+ const body = await response.json();
277
+ // Then: server should respond with 405 Method Not Allowed
278
+ assert.strictEqual(response.status, 405);
279
+ assert.strictEqual(body?.error, "Method Not Allowed");
280
+ const allowHeader = response.headers.get("allow");
281
+ assert.ok(allowHeader?.includes("GET"), "Allow header must contain GET");
282
+ assert.ok(allowHeader?.includes("POST"), "Allow header must contain POST");
283
+ assert.ok(allowHeader?.includes("DELETE"), "Allow header must contain DELETE");
284
+ });
285
+ });
286
+ });
287
+ describe("When Streamable HTTP GET SSE is open", { timeout: 20_000 }, () => {
288
+ describe("with a short session timeout and no POST traffic", () => {
289
+ it("should keep the session alive for the life of the SSE stream", async () => {
290
+ const mockPort = await findMockServerPort();
291
+ const mockGitLab = new MockGitLabServer({
292
+ port: mockPort,
293
+ validTokens: [VALID_TOKEN],
294
+ });
295
+ await mockGitLab.start();
296
+ const port = await findAvailablePort(3740);
297
+ const timeoutServer = await launchServer({
298
+ mode: TransportMode.STREAMABLE_HTTP,
299
+ port,
300
+ timeout: 10_000,
301
+ env: {
302
+ STREAMABLE_HTTP: "true",
303
+ REMOTE_AUTHORIZATION: "true",
304
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
305
+ SESSION_TIMEOUT_SECONDS: "1",
306
+ },
307
+ });
308
+ const timeoutMcpUrl = `http://${HOST}:${port}/mcp`;
309
+ try {
310
+ // Given: an authenticated session with an open GET SSE stream
311
+ const initResponse = await postJsonRpc(timeoutMcpUrl, {
312
+ jsonrpc: "2.0",
313
+ id: 1,
314
+ method: "initialize",
315
+ params: {
316
+ protocolVersion: "2025-03-26",
317
+ capabilities: {},
318
+ clientInfo: { name: "sse-keepalive-client", version: "1.0.0" },
319
+ },
320
+ }, { "Private-Token": VALID_TOKEN });
321
+ const sessionId = initResponse.sessionId;
322
+ assert.ok(sessionId, "Session ID should be present");
323
+ const abortController = new AbortController();
324
+ const sseResponse = await fetch(timeoutMcpUrl, {
325
+ method: "GET",
326
+ headers: {
327
+ Accept: "text/event-stream",
328
+ "Mcp-Session-Id": sessionId,
329
+ "Private-Token": VALID_TOKEN,
330
+ },
331
+ signal: abortController.signal,
332
+ });
333
+ assert.strictEqual(sseResponse.status, 200);
334
+ // When: more than SESSION_TIMEOUT_SECONDS elapses with no POST
335
+ await new Promise(resolve => setTimeout(resolve, 2_000));
336
+ const listResponse = await postJsonRpc(timeoutMcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, {
337
+ "mcp-session-id": sessionId,
338
+ "Private-Token": VALID_TOKEN,
339
+ });
340
+ // Then: the session is still alive
341
+ assert.strictEqual(listResponse.status, 200, listResponse.text);
342
+ abortController.abort();
343
+ }
344
+ finally {
345
+ await cleanupServers([timeoutServer]);
346
+ await mockGitLab.stop();
347
+ }
348
+ });
349
+ });
350
+ });
@@ -216,11 +216,17 @@ describe('GitLab MCP Server - Streamable HTTP Transport', () => {
216
216
  cleanup();
217
217
  console.log('Client disconnected from Streamable HTTP server');
218
218
  });
219
- test('should return 405 for GET /mcp', async () => {
219
+ test('should return 406 for GET /mcp without Accept: text/event-stream', async () => {
220
220
  const response = await fetch(`http://${HOST}:${port}/mcp`);
221
- assert.strictEqual(response.status, 405, 'GET /mcp should respond with 405');
221
+ assert.strictEqual(response.status, 406, 'GET /mcp should respond with 406 when Accept header is missing text/event-stream');
222
222
  const body = await response.json();
223
- assert.strictEqual(body?.error, 'Method Not Allowed');
223
+ assert.strictEqual(body?.error, 'Not Acceptable');
224
+ });
225
+ test('should return 405 for unsupported HTTP methods like PUT /mcp', async () => {
226
+ const response = await fetch(`http://${HOST}:${port}/mcp`, { method: 'PUT' });
227
+ assert.strictEqual(response.status, 405, 'PUT /mcp should respond with 405');
228
+ const allowHeader = response.headers.get('allow');
229
+ assert.ok(allowHeader?.includes('GET') && allowHeader?.includes('POST') && allowHeader?.includes('DELETE'), 'Allow header should contain GET, POST, DELETE');
224
230
  });
225
231
  test('should list tools via Streamable HTTP', async () => {
226
232
  const tools = await client.listTools();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.47",
3
+ "version": "2.1.48",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [