@zereight/mcp-gitlab 2.1.70 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +3 -1
- package/README.md +6 -2
- package/README.pt-BR.md +1 -1
- package/README.zh-CN.md +3 -1
- package/build/config.js +12 -4
- package/build/deprecated-env.js +47 -0
- package/build/index.js +369 -184
- package/build/oauth-device-flow.js +80 -0
- package/build/oauth.js +135 -18
- package/build/schemas.js +1 -1
- package/build/server/remote-http-stack.js +39 -0
- package/build/tools/discover-activation.js +38 -0
- package/build/tools/exposed-tools.js +35 -0
- package/build/tools/listed-tools.js +76 -0
- package/build/tools/registry.js +226 -57
- package/build/tools/tool-profile.js +66 -0
- package/build/utils/compact-tool-allowlist.js +14 -0
- package/build/utils/logger.js +34 -12
- package/package.json +3 -3
- package/build/scripts/check-skill-sync.js +0 -143
- package/build/scripts/generate-server-card.js +0 -39
- package/build/scripts/generate-tool-coverage.js +0 -58
- package/build/scripts/generate-tool-docs.js +0 -430
- package/build/scripts/tool-coverage/coverage.js +0 -341
- package/build/scripts/tool-coverage/coverage.test.js +0 -142
- package/build/test/callback-proxy-tests.js +0 -321
- package/build/test/cli/args.test.js +0 -175
- package/build/test/cli/boolean-flags.test.js +0 -106
- package/build/test/cli/compact-mcp-result.test.js +0 -93
- package/build/test/cli/credentials.test.js +0 -153
- package/build/test/cli/exposure.test.js +0 -104
- package/build/test/cli/load-config-snapshot.js +0 -5
- package/build/test/cli/output.test.js +0 -74
- package/build/test/cli/replay-command.test.js +0 -37
- package/build/test/cli/router.test.js +0 -183
- package/build/test/client-pool-test.js +0 -109
- package/build/test/clients/client.js +0 -34
- package/build/test/clients/custom-header-client.js +0 -122
- package/build/test/clients/sse-client.js +0 -95
- package/build/test/clients/stdio-client.js +0 -113
- package/build/test/clients/streamable-http-client.js +0 -95
- package/build/test/config-allowed-groups.test.js +0 -97
- package/build/test/create-commit-actions.test.js +0 -102
- package/build/test/draft-note-schema.test.js +0 -44
- package/build/test/dynamic-api-url-allowlist.test.js +0 -121
- package/build/test/dynamic-api-url-test.js +0 -304
- package/build/test/dynamic-routing-tests.js +0 -495
- package/build/test/get-merge-request-discussion-schema.test.js +0 -30
- package/build/test/gitlab-api-tool-errors.test.js +0 -112
- package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
- package/build/test/gitlab-diff-schema.test.js +0 -36
- package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
- package/build/test/gitlab-tag-schema.test.js +0 -51
- package/build/test/group-milestone-schema.test.js +0 -45
- package/build/test/mcp-oauth-tests.js +0 -905
- package/build/test/mcp-server-name.test.js +0 -87
- package/build/test/merge-merge-request-schema.test.js +0 -75
- package/build/test/multi-server-test.js +0 -190
- package/build/test/no-proxy-integration-test.js +0 -183
- package/build/test/no-proxy-test.js +0 -149
- package/build/test/nullable-gitlab-response-fields.test.js +0 -37
- package/build/test/nullish-tool-arguments-schema.test.js +0 -118
- package/build/test/oauth-device-flow-tests.js +0 -450
- package/build/test/oauth-download-url.test.js +0 -157
- package/build/test/oauth-startup.test.js +0 -204
- package/build/test/oauth-tests.js +0 -428
- package/build/test/path-segment-encoding.test.js +0 -98
- package/build/test/remote-auth-simple-test.js +0 -277
- package/build/test/remote-auth-tests.js +0 -314
- package/build/test/response-masking.test.js +0 -445
- package/build/test/schema-tests.js +0 -1679
- package/build/test/server/metrics.test.js +0 -47
- package/build/test/server/request-helpers.test.js +0 -48
- package/build/test/server/version.test.js +0 -13
- package/build/test/sse-auth-guard.test.js +0 -96
- package/build/test/sse-session-limits.test.js +0 -273
- package/build/test/stateless/callback-proxy.test.js +0 -510
- package/build/test/stateless/client-id.test.js +0 -177
- package/build/test/stateless/codec.test.js +0 -328
- package/build/test/stateless/config-ttl.test.js +0 -149
- package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
- package/build/test/stateless/session-id-integration.test.js +0 -675
- package/build/test/stateless/session-id.test.js +0 -157
- package/build/test/streamable-http-concurrent-session.test.js +0 -171
- package/build/test/streamable-http-dns-rebinding.test.js +0 -185
- package/build/test/streamable-http-sse-stream.test.js +0 -350
- package/build/test/streamable-http-static-token-auth.test.js +0 -157
- package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
- package/build/test/test-all-transport-server.js +0 -255
- package/build/test/test-auth-retry.js +0 -195
- package/build/test/test-blank-filters.js +0 -156
- package/build/test/test-ci-catalog.js +0 -177
- package/build/test/test-ci-lint.js +0 -191
- package/build/test/test-ci-variables.js +0 -309
- package/build/test/test-create-repository.js +0 -120
- package/build/test/test-dependency-proxy.js +0 -195
- package/build/test/test-deployment-tools.js +0 -463
- package/build/test/test-download-attachment.js +0 -190
- package/build/test/test-dynamic-project-scope.js +0 -524
- package/build/test/test-empty-toolsets.js +0 -535
- package/build/test/test-get-file-blame.js +0 -145
- package/build/test/test-get-file-contents.js +0 -128
- package/build/test/test-geteffectiveprojectid.js +0 -804
- package/build/test/test-health-check.js +0 -115
- package/build/test/test-issue-description-patch.js +0 -256
- package/build/test/test-job-artifacts.js +0 -275
- package/build/test/test-json-schema.js +0 -166
- package/build/test/test-list-group-members.js +0 -150
- package/build/test/test-list-group-merge-requests.js +0 -165
- package/build/test/test-list-issues.js +0 -170
- package/build/test/test-list-merge-requests.js +0 -201
- package/build/test/test-list-milestones.js +0 -145
- package/build/test/test-list-project-members.js +0 -132
- package/build/test/test-merge-request-approval-state-tools.js +0 -213
- package/build/test/test-merge-request-approvals.js +0 -187
- package/build/test/test-merge-request-pipelines.js +0 -106
- package/build/test/test-mr-diffs-filter.js +0 -132
- package/build/test/test-mr-file-diffs.js +0 -251
- package/build/test/test-oauth-proxy-rate-limit.js +0 -133
- package/build/test/test-orbit.js +0 -155
- package/build/test/test-permission-mode.js +0 -424
- package/build/test/test-pipeline-schedules.js +0 -484
- package/build/test/test-protected-branches.js +0 -174
- package/build/test/test-remote-downloads.js +0 -510
- package/build/test/test-search-code.js +0 -275
- package/build/test/test-snippets.js +0 -735
- package/build/test/test-tags.js +0 -220
- package/build/test/test-todos.js +0 -195
- package/build/test/test-token-optimizations.js +0 -691
- package/build/test/test-toolset-filtering.js +0 -499
- package/build/test/test-update-issue-slim.js +0 -141
- package/build/test/test-update-project.js +0 -112
- package/build/test/test-upload-markdown.js +0 -206
- package/build/test/test-vulnerabilities.js +0 -399
- package/build/test/test-webhooks.js +0 -218
- package/build/test/tool-description-quality.test.js +0 -50
- package/build/test/utils/bulk-publish-options.test.js +0 -32
- package/build/test/utils/download-token.test.js +0 -35
- package/build/test/utils/forwarded-public-base-url.test.js +0 -46
- package/build/test/utils/gitlab-instance-version.test.js +0 -32
- package/build/test/utils/gitlab-version-gate.test.js +0 -74
- package/build/test/utils/graphql-query.test.js +0 -168
- package/build/test/utils/helpers-path.test.js +0 -104
- package/build/test/utils/jmespath-tool-result.test.js +0 -91
- package/build/test/utils/masking.test.js +0 -384
- package/build/test/utils/merge-request-position.test.js +0 -57
- package/build/test/utils/mock-gitlab-server.js +0 -614
- package/build/test/utils/proxy-client-ip.test.js +0 -28
- package/build/test/utils/redact-sensitive.test.js +0 -32
- package/build/test/utils/safe-redirect-fetch.test.js +0 -463
- package/build/test/utils/server-launcher.js +0 -231
- package/build/test/utils/tool-args.test.js +0 -210
- package/build/test/utils/url.test.js +0 -141
- package/build/test/utils/version-check.test.js +0 -239
- package/build/test/utils/wiki-title.test.js +0 -21
- package/build/test-note.js +0 -54
- package/build/test-resolve-issue-note.js +0 -127
|
@@ -1,350 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { once } from "node:events";
|
|
4
|
-
import { afterEach, describe, test } from "node:test";
|
|
5
|
-
import * as path from "node:path";
|
|
6
|
-
import { findAvailablePort } from "./utils/server-launcher.js";
|
|
7
|
-
const ERROR_MESSAGE = "STREAMABLE_HTTP=true/--streamable-http with server-side GitLab credentials requires REMOTE_AUTHORIZATION=true/--remote-auth=true, GITLAB_MCP_OAUTH=true/--mcp-oauth=true, or STREAMABLE_HTTP_AUTH_TOKEN/--streamable-http-auth-token";
|
|
8
|
-
const HOST = process.env.HOST || "127.0.0.1";
|
|
9
|
-
const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
|
|
10
|
-
const running = new Set();
|
|
11
|
-
function startServer(env, port) {
|
|
12
|
-
const child = spawn("node", [SERVER_PATH], {
|
|
13
|
-
env: {
|
|
14
|
-
...process.env,
|
|
15
|
-
GITLAB_API_URL: "https://gitlab.example.com",
|
|
16
|
-
HOST,
|
|
17
|
-
PORT: String(port),
|
|
18
|
-
STREAMABLE_HTTP: "true",
|
|
19
|
-
REMOTE_AUTHORIZATION: "false",
|
|
20
|
-
GITLAB_MCP_OAUTH: "false",
|
|
21
|
-
GITLAB_USE_OAUTH: "false",
|
|
22
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: "",
|
|
23
|
-
GITLAB_JOB_TOKEN: "",
|
|
24
|
-
GITLAB_AUTH_COOKIE_PATH: "",
|
|
25
|
-
MCP_SERVER_URL: "",
|
|
26
|
-
GITLAB_OAUTH_APP_ID: "",
|
|
27
|
-
...env,
|
|
28
|
-
},
|
|
29
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
30
|
-
});
|
|
31
|
-
running.add(child);
|
|
32
|
-
child.once("exit", () => running.delete(child));
|
|
33
|
-
return child;
|
|
34
|
-
}
|
|
35
|
-
async function waitForExit(child, timeoutMs = 5000) {
|
|
36
|
-
let output = "";
|
|
37
|
-
child.stdout?.on("data", chunk => {
|
|
38
|
-
output += chunk.toString();
|
|
39
|
-
});
|
|
40
|
-
child.stderr?.on("data", chunk => {
|
|
41
|
-
output += chunk.toString();
|
|
42
|
-
});
|
|
43
|
-
let timeoutHandle;
|
|
44
|
-
const timeout = new Promise((_, reject) => {
|
|
45
|
-
timeoutHandle = setTimeout(() => reject(new Error(`server did not exit within ${timeoutMs}ms`)), timeoutMs);
|
|
46
|
-
});
|
|
47
|
-
try {
|
|
48
|
-
const [code] = (await Promise.race([once(child, "exit"), timeout]));
|
|
49
|
-
return { code, output };
|
|
50
|
-
}
|
|
51
|
-
finally {
|
|
52
|
-
clearTimeout(timeoutHandle);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
async function waitForHealth(port, timeoutMs = 5000) {
|
|
56
|
-
const deadline = Date.now() + timeoutMs;
|
|
57
|
-
let lastError;
|
|
58
|
-
while (Date.now() < deadline) {
|
|
59
|
-
try {
|
|
60
|
-
const response = await fetch(`http://${HOST}:${port}/health`);
|
|
61
|
-
if (response.ok)
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
lastError = error;
|
|
66
|
-
}
|
|
67
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
68
|
-
}
|
|
69
|
-
throw new Error(`server did not become healthy: ${String(lastError)}`);
|
|
70
|
-
}
|
|
71
|
-
async function postMcpWithoutAuth(port) {
|
|
72
|
-
return postMcp(port);
|
|
73
|
-
}
|
|
74
|
-
async function postMcp(port, token) {
|
|
75
|
-
return fetch(`http://${HOST}:${port}/mcp`, {
|
|
76
|
-
method: "POST",
|
|
77
|
-
headers: {
|
|
78
|
-
"Content-Type": "application/json",
|
|
79
|
-
Accept: "application/json, text/event-stream",
|
|
80
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
81
|
-
},
|
|
82
|
-
body: JSON.stringify({
|
|
83
|
-
jsonrpc: "2.0",
|
|
84
|
-
id: 1,
|
|
85
|
-
method: "initialize",
|
|
86
|
-
params: {
|
|
87
|
-
protocolVersion: "2025-03-26",
|
|
88
|
-
capabilities: {},
|
|
89
|
-
clientInfo: { name: "static-token-auth-test", version: "1.0.0" },
|
|
90
|
-
},
|
|
91
|
-
}),
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
afterEach(() => {
|
|
95
|
-
for (const child of running) {
|
|
96
|
-
if (!child.killed)
|
|
97
|
-
child.kill("SIGTERM");
|
|
98
|
-
}
|
|
99
|
-
running.clear();
|
|
100
|
-
});
|
|
101
|
-
describe("Streamable HTTP static server token auth", () => {
|
|
102
|
-
test("refuses startup with PAT and no MCP-layer auth", async () => {
|
|
103
|
-
const port = await findAvailablePort(4300);
|
|
104
|
-
const child = startServer({ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test" }, port);
|
|
105
|
-
const { code, output } = await waitForExit(child);
|
|
106
|
-
assert.notEqual(code, 0);
|
|
107
|
-
assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
108
|
-
});
|
|
109
|
-
test("refuses startup with JOB-TOKEN and no MCP-layer auth", async () => {
|
|
110
|
-
const port = await findAvailablePort(4310);
|
|
111
|
-
const child = startServer({ GITLAB_JOB_TOKEN: "job_test" }, port);
|
|
112
|
-
const { code, output } = await waitForExit(child);
|
|
113
|
-
assert.notEqual(code, 0);
|
|
114
|
-
assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
115
|
-
});
|
|
116
|
-
test("refuses startup with cookie auth and no MCP-layer auth", async () => {
|
|
117
|
-
const port = await findAvailablePort(4315);
|
|
118
|
-
const child = startServer({ GITLAB_AUTH_COOKIE_PATH: "./cookies.txt" }, port);
|
|
119
|
-
const { code, output } = await waitForExit(child);
|
|
120
|
-
assert.notEqual(code, 0);
|
|
121
|
-
assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
122
|
-
});
|
|
123
|
-
test("allows startup with PAT when REMOTE_AUTHORIZATION is enabled", async () => {
|
|
124
|
-
const port = await findAvailablePort(4320);
|
|
125
|
-
startServer({
|
|
126
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
|
|
127
|
-
REMOTE_AUTHORIZATION: "true",
|
|
128
|
-
}, port);
|
|
129
|
-
await waitForHealth(port);
|
|
130
|
-
const initResponse = await postMcpWithoutAuth(port);
|
|
131
|
-
assert.equal(initResponse.status, 401);
|
|
132
|
-
});
|
|
133
|
-
test("requires configured Streamable HTTP auth token", async () => {
|
|
134
|
-
const port = await findAvailablePort(4325);
|
|
135
|
-
startServer({
|
|
136
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
|
|
137
|
-
STREAMABLE_HTTP_AUTH_TOKEN: "mcp_static_secret",
|
|
138
|
-
}, port);
|
|
139
|
-
await waitForHealth(port);
|
|
140
|
-
const unauthenticated = await postMcpWithoutAuth(port);
|
|
141
|
-
assert.equal(unauthenticated.status, 401);
|
|
142
|
-
const authenticated = await postMcp(port, "mcp_static_secret");
|
|
143
|
-
assert.equal(authenticated.status, 200);
|
|
144
|
-
});
|
|
145
|
-
test("allows startup with PAT when GITLAB_MCP_OAUTH is enabled", async () => {
|
|
146
|
-
const port = await findAvailablePort(4330);
|
|
147
|
-
startServer({
|
|
148
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
|
|
149
|
-
GITLAB_MCP_OAUTH: "true",
|
|
150
|
-
MCP_SERVER_URL: `http://${HOST}:${port}`,
|
|
151
|
-
GITLAB_OAUTH_APP_ID: "oauth-app-id",
|
|
152
|
-
}, port);
|
|
153
|
-
await waitForHealth(port);
|
|
154
|
-
const response = await postMcpWithoutAuth(port);
|
|
155
|
-
assert.equal(response.status, 401);
|
|
156
|
-
});
|
|
157
|
-
});
|