@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,450 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import * as fs from "node:fs";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
import * as path from "node:path";
|
|
6
|
-
import { getPositionalCliCommand } from "../cli-command.js";
|
|
7
|
-
import { gitlabOriginFromApiUrl, runAuthCommandAsync } from "../auth-cli.js";
|
|
8
|
-
import { runDeviceAuthorizationGrantAsync } from "../oauth-device-flow.js";
|
|
9
|
-
import { GitLabOAuth } from "../oauth.js";
|
|
10
|
-
const GITLAB_URL = "https://gitlab.example";
|
|
11
|
-
const CLIENT_ID = "test-client-id";
|
|
12
|
-
const DEVICE_CODE = "device-code-not-for-stdout";
|
|
13
|
-
const USER_CODE = "0A44L90H";
|
|
14
|
-
const ACCESS_TOKEN = "oauth-device-flow-access-token";
|
|
15
|
-
const REFRESH_TOKEN = "refresh-token-device-flow";
|
|
16
|
-
function requestUrl(input) {
|
|
17
|
-
if (typeof input === "string") {
|
|
18
|
-
return input;
|
|
19
|
-
}
|
|
20
|
-
if (input instanceof URL) {
|
|
21
|
-
return input.href;
|
|
22
|
-
}
|
|
23
|
-
return input.url;
|
|
24
|
-
}
|
|
25
|
-
function jsonResponse(status, body) {
|
|
26
|
-
return new Response(JSON.stringify(body), {
|
|
27
|
-
status,
|
|
28
|
-
headers: { "Content-Type": "application/json" },
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
function createFetchStub(plan, capture) {
|
|
32
|
-
const remainingTokens = [...plan.tokens];
|
|
33
|
-
return async (input, init) => {
|
|
34
|
-
const url = requestUrl(input);
|
|
35
|
-
if (url.includes("/oauth/authorize_device")) {
|
|
36
|
-
if (typeof init?.body === "string") {
|
|
37
|
-
capture?.authorizeBodies.push(init.body);
|
|
38
|
-
}
|
|
39
|
-
return jsonResponse(plan.authorize.status, plan.authorize.body);
|
|
40
|
-
}
|
|
41
|
-
if (url.includes("/oauth/token")) {
|
|
42
|
-
const next = remainingTokens.shift();
|
|
43
|
-
if (!next) {
|
|
44
|
-
throw new Error(`Unexpected token poll: ${url}`);
|
|
45
|
-
}
|
|
46
|
-
return jsonResponse(next.status, next.body);
|
|
47
|
-
}
|
|
48
|
-
throw new Error(`Unexpected fetch URL: ${url}`);
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
function deviceAuthorizationBody(interval = 5) {
|
|
52
|
-
return {
|
|
53
|
-
device_code: DEVICE_CODE,
|
|
54
|
-
user_code: USER_CODE,
|
|
55
|
-
verification_uri: `${GITLAB_URL}/oauth/device`,
|
|
56
|
-
verification_uri_complete: `${GITLAB_URL}/oauth/device?user_code=${USER_CODE}`,
|
|
57
|
-
expires_in: 300,
|
|
58
|
-
interval,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
function successTokenBody() {
|
|
62
|
-
return {
|
|
63
|
-
access_token: ACCESS_TOKEN,
|
|
64
|
-
refresh_token: REFRESH_TOKEN,
|
|
65
|
-
expires_in: 7200,
|
|
66
|
-
token_type: "Bearer",
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
function pendingBody() {
|
|
70
|
-
return { error: "authorization_pending" };
|
|
71
|
-
}
|
|
72
|
-
function createTempTokenPath() {
|
|
73
|
-
return path.join(os.tmpdir(), `gitlab-mcp-device-flow-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
|
|
74
|
-
}
|
|
75
|
-
function createOauthClient(tokenStoragePath) {
|
|
76
|
-
return new GitLabOAuth({
|
|
77
|
-
clientId: CLIENT_ID,
|
|
78
|
-
redirectUri: "http://127.0.0.1:8888/callback",
|
|
79
|
-
gitlabUrl: GITLAB_URL,
|
|
80
|
-
scopes: ["api"],
|
|
81
|
-
tokenStoragePath,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
function createAuthorizeCapture() {
|
|
85
|
-
return { authorizeBodies: [] };
|
|
86
|
-
}
|
|
87
|
-
function capturedAuthorizeScope(bodies) {
|
|
88
|
-
if (bodies.length !== 1) {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
const [body] = bodies;
|
|
92
|
-
if (body === undefined) {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
return new URLSearchParams(body).get("scope");
|
|
96
|
-
}
|
|
97
|
-
async function runAuthCapturingScopeAsync(input) {
|
|
98
|
-
const tokenPath = createTempTokenPath();
|
|
99
|
-
const capture = createAuthorizeCapture();
|
|
100
|
-
const fetchImpl = createFetchStub({
|
|
101
|
-
authorize: { status: 200, body: deviceAuthorizationBody() },
|
|
102
|
-
tokens: [{ status: 200, body: successTokenBody() }],
|
|
103
|
-
}, capture);
|
|
104
|
-
try {
|
|
105
|
-
await runAuthCommandAsync({
|
|
106
|
-
argv: [
|
|
107
|
-
"node",
|
|
108
|
-
"index.js",
|
|
109
|
-
"auth",
|
|
110
|
-
`--client-id=${CLIENT_ID}`,
|
|
111
|
-
`--api-url=${GITLAB_URL}`,
|
|
112
|
-
`--token-path=${tokenPath}`,
|
|
113
|
-
...(input.extraArgv ?? []),
|
|
114
|
-
],
|
|
115
|
-
env: input.env ?? {},
|
|
116
|
-
stdout: createStdoutBuffer().stdout,
|
|
117
|
-
fetchImpl,
|
|
118
|
-
sleepAsync: async () => { },
|
|
119
|
-
});
|
|
120
|
-
return capturedAuthorizeScope(capture.authorizeBodies);
|
|
121
|
-
}
|
|
122
|
-
finally {
|
|
123
|
-
if (fs.existsSync(tokenPath)) {
|
|
124
|
-
fs.unlinkSync(tokenPath);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
function createStdoutBuffer() {
|
|
129
|
-
const chunks = [];
|
|
130
|
-
return {
|
|
131
|
-
stdout: {
|
|
132
|
-
write(chunk) {
|
|
133
|
-
chunks.push(chunk);
|
|
134
|
-
return true;
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
text: () => chunks.join(""),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
function createSleepRecorder() {
|
|
141
|
-
const delays = [];
|
|
142
|
-
return {
|
|
143
|
-
delays,
|
|
144
|
-
async sleepAsync(ms) {
|
|
145
|
-
delays.push(ms);
|
|
146
|
-
},
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
function readStoredToken(value) {
|
|
150
|
-
if (typeof value !== "object" || value === null) {
|
|
151
|
-
throw new Error("token file is not an object");
|
|
152
|
-
}
|
|
153
|
-
const accessToken = Reflect.get(value, "access_token");
|
|
154
|
-
const refreshToken = Reflect.get(value, "refresh_token");
|
|
155
|
-
const expiresIn = Reflect.get(value, "expires_in");
|
|
156
|
-
const createdAt = Reflect.get(value, "created_at");
|
|
157
|
-
const tokenType = Reflect.get(value, "token_type");
|
|
158
|
-
if (typeof accessToken !== "string") {
|
|
159
|
-
throw new Error("access_token missing");
|
|
160
|
-
}
|
|
161
|
-
if (typeof createdAt !== "number") {
|
|
162
|
-
throw new Error("created_at missing");
|
|
163
|
-
}
|
|
164
|
-
if (typeof tokenType !== "string") {
|
|
165
|
-
throw new Error("token_type missing");
|
|
166
|
-
}
|
|
167
|
-
return {
|
|
168
|
-
access_token: accessToken,
|
|
169
|
-
refresh_token: typeof refreshToken === "string" ? refreshToken : undefined,
|
|
170
|
-
expires_in: typeof expiresIn === "number" ? expiresIn : undefined,
|
|
171
|
-
created_at: createdAt,
|
|
172
|
-
token_type: tokenType,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
describe("When reading the CLI command", () => {
|
|
176
|
-
describe("with auth as the first positional argument", () => {
|
|
177
|
-
it("should return auth", () => {
|
|
178
|
-
assert.equal(getPositionalCliCommand(["node", "index.js", "auth"]), "auth");
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
describe("with only --token", () => {
|
|
182
|
-
it("should return undefined so MCP startup stays unchanged", () => {
|
|
183
|
-
assert.equal(getPositionalCliCommand(["node", "index.js", "--token=glpat-xxxxxxxx"]), undefined);
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
describe("with flags before auth", () => {
|
|
187
|
-
it("should still return auth", () => {
|
|
188
|
-
assert.equal(getPositionalCliCommand(["node", "index.js", "--api-url=https://gitlab.com", "auth"]), "auth");
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
describe("with tsx index.ts auth", () => {
|
|
192
|
-
it("should skip the script path and return auth", () => {
|
|
193
|
-
assert.equal(getPositionalCliCommand(["node", "/usr/local/bin/tsx", "index.ts", "auth", "--help"]), "auth");
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
describe("with --use-oauth before a curated command", () => {
|
|
197
|
-
it("should still return mr instead of consuming it as the flag value", () => {
|
|
198
|
-
assert.equal(getPositionalCliCommand(["node", "index.js", "--use-oauth", "mr", "list"]), "mr");
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
describe("with --read-only false before a curated command", () => {
|
|
202
|
-
it("should skip the boolean literal and return mr", () => {
|
|
203
|
-
assert.equal(getPositionalCliCommand(["node", "index.js", "--read-only", "false", "mr", "list"]), "mr");
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
describe("with space-separated --api-url before auth", () => {
|
|
207
|
-
it("should skip the URL value and return auth", () => {
|
|
208
|
-
assert.equal(getPositionalCliCommand([
|
|
209
|
-
"node",
|
|
210
|
-
"index.js",
|
|
211
|
-
"--api-url",
|
|
212
|
-
"https://gitlab.example/api/v4",
|
|
213
|
-
"auth",
|
|
214
|
-
]), "auth");
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
describe("with space-separated --client-id before auth", () => {
|
|
218
|
-
it("should skip the client id value and return auth", () => {
|
|
219
|
-
assert.equal(getPositionalCliCommand(["node", "index.js", "--client-id", "app-id", "auth"]), "auth");
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
describe("with -h before auth", () => {
|
|
223
|
-
it("should not consume auth as a help value", () => {
|
|
224
|
-
assert.equal(getPositionalCliCommand(["node", "index.js", "-h", "auth"]), "auth");
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
describe("When converting a GitLab API URL", () => {
|
|
229
|
-
describe("with an /api/v4 suffix", () => {
|
|
230
|
-
it("should strip it the same way initializeOAuthClient does", () => {
|
|
231
|
-
assert.equal(gitlabOriginFromApiUrl("https://gitlab.com/api/v4"), "https://gitlab.com");
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
describe("with a trailing slash after /api/v4", () => {
|
|
235
|
-
it("should strip the suffix and trailing slash", () => {
|
|
236
|
-
assert.equal(gitlabOriginFromApiUrl("https://gitlab.example/api/v4/"), "https://gitlab.example");
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
describe("When running device authorization grant", () => {
|
|
241
|
-
describe("with authorization_pending then a token", () => {
|
|
242
|
-
it("should return TokenData compatible with the existing OAuth store", async () => {
|
|
243
|
-
const fetchImpl = createFetchStub({
|
|
244
|
-
authorize: { status: 200, body: deviceAuthorizationBody() },
|
|
245
|
-
tokens: [
|
|
246
|
-
{ status: 400, body: pendingBody() },
|
|
247
|
-
{ status: 200, body: successTokenBody() },
|
|
248
|
-
],
|
|
249
|
-
});
|
|
250
|
-
const sleep = createSleepRecorder();
|
|
251
|
-
const token = await runDeviceAuthorizationGrantAsync({
|
|
252
|
-
gitlabUrl: GITLAB_URL,
|
|
253
|
-
clientId: CLIENT_ID,
|
|
254
|
-
scopes: ["api"],
|
|
255
|
-
fetchImpl,
|
|
256
|
-
sleepAsync: sleep.sleepAsync,
|
|
257
|
-
});
|
|
258
|
-
assert.equal(token.access_token, ACCESS_TOKEN);
|
|
259
|
-
assert.equal(token.refresh_token, REFRESH_TOKEN);
|
|
260
|
-
assert.equal(token.expires_in, 7200);
|
|
261
|
-
assert.equal(token.token_type, "Bearer");
|
|
262
|
-
assert.equal(typeof token.created_at, "number");
|
|
263
|
-
assert.deepEqual(sleep.delays, [5000]);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
describe("with slow_down then a token", () => {
|
|
267
|
-
it("should wait the increased interval before the next poll", async () => {
|
|
268
|
-
const fetchImpl = createFetchStub({
|
|
269
|
-
authorize: { status: 200, body: deviceAuthorizationBody(5) },
|
|
270
|
-
tokens: [
|
|
271
|
-
{ status: 400, body: { error: "slow_down" } },
|
|
272
|
-
{ status: 200, body: successTokenBody() },
|
|
273
|
-
],
|
|
274
|
-
});
|
|
275
|
-
const sleep = createSleepRecorder();
|
|
276
|
-
await runDeviceAuthorizationGrantAsync({
|
|
277
|
-
gitlabUrl: GITLAB_URL,
|
|
278
|
-
clientId: CLIENT_ID,
|
|
279
|
-
scopes: ["api"],
|
|
280
|
-
fetchImpl,
|
|
281
|
-
sleepAsync: sleep.sleepAsync,
|
|
282
|
-
});
|
|
283
|
-
assert.deepEqual(sleep.delays, [10000]);
|
|
284
|
-
});
|
|
285
|
-
});
|
|
286
|
-
});
|
|
287
|
-
describe("When the token endpoint returns expired_token", () => {
|
|
288
|
-
describe("with no token file yet", () => {
|
|
289
|
-
it("should throw and not write a token file", async () => {
|
|
290
|
-
const tokenPath = createTempTokenPath();
|
|
291
|
-
const fetchImpl = createFetchStub({
|
|
292
|
-
authorize: { status: 200, body: deviceAuthorizationBody() },
|
|
293
|
-
tokens: [{ status: 400, body: { error: "expired_token" } }],
|
|
294
|
-
});
|
|
295
|
-
const oauth = createOauthClient(tokenPath);
|
|
296
|
-
await assert.rejects(() => oauth.runDeviceFlowAsync({ fetchImpl, sleepAsync: async () => { } }), /Device code expired/);
|
|
297
|
-
assert.equal(fs.existsSync(tokenPath), false);
|
|
298
|
-
});
|
|
299
|
-
});
|
|
300
|
-
});
|
|
301
|
-
describe("When the token endpoint returns access_denied", () => {
|
|
302
|
-
describe("with no token file yet", () => {
|
|
303
|
-
it("should throw and not write a token file", async () => {
|
|
304
|
-
const tokenPath = createTempTokenPath();
|
|
305
|
-
const fetchImpl = createFetchStub({
|
|
306
|
-
authorize: { status: 200, body: deviceAuthorizationBody() },
|
|
307
|
-
tokens: [{ status: 400, body: { error: "access_denied" } }],
|
|
308
|
-
});
|
|
309
|
-
const oauth = createOauthClient(tokenPath);
|
|
310
|
-
await assert.rejects(() => oauth.runDeviceFlowAsync({ fetchImpl, sleepAsync: async () => { } }), /Authorization was denied/);
|
|
311
|
-
assert.equal(fs.existsSync(tokenPath), false);
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
describe("When GitLab returns 404 for authorize_device", () => {
|
|
316
|
-
describe("with a self-managed instance", () => {
|
|
317
|
-
it("should tell the user to use a PAT", async () => {
|
|
318
|
-
const fetchImpl = createFetchStub({
|
|
319
|
-
authorize: { status: 404, body: { error: "not_found" } },
|
|
320
|
-
tokens: [],
|
|
321
|
-
});
|
|
322
|
-
await assert.rejects(() => runDeviceAuthorizationGrantAsync({
|
|
323
|
-
gitlabUrl: GITLAB_URL,
|
|
324
|
-
clientId: CLIENT_ID,
|
|
325
|
-
scopes: ["api"],
|
|
326
|
-
fetchImpl,
|
|
327
|
-
sleepAsync: async () => { },
|
|
328
|
-
}), /GITLAB_PERSONAL_ACCESS_TOKEN/);
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
});
|
|
332
|
-
describe("When device flow succeeds", () => {
|
|
333
|
-
describe("with GitLabOAuth.saveToken", () => {
|
|
334
|
-
it("should persist the existing token file shape", async () => {
|
|
335
|
-
const tokenPath = createTempTokenPath();
|
|
336
|
-
const fetchImpl = createFetchStub({
|
|
337
|
-
authorize: { status: 200, body: deviceAuthorizationBody() },
|
|
338
|
-
tokens: [{ status: 200, body: successTokenBody() }],
|
|
339
|
-
});
|
|
340
|
-
try {
|
|
341
|
-
await createOauthClient(tokenPath).runDeviceFlowAsync({
|
|
342
|
-
fetchImpl,
|
|
343
|
-
sleepAsync: async () => { },
|
|
344
|
-
});
|
|
345
|
-
const raw = JSON.parse(fs.readFileSync(tokenPath, "utf8"));
|
|
346
|
-
const stored = readStoredToken(raw);
|
|
347
|
-
assert.equal(stored.access_token, ACCESS_TOKEN);
|
|
348
|
-
assert.equal(stored.refresh_token, REFRESH_TOKEN);
|
|
349
|
-
assert.equal(stored.expires_in, 7200);
|
|
350
|
-
assert.equal(stored.token_type, "Bearer");
|
|
351
|
-
assert.equal(typeof stored.created_at, "number");
|
|
352
|
-
assert.equal(fs.statSync(tokenPath).mode & 0o777, 0o600);
|
|
353
|
-
}
|
|
354
|
-
finally {
|
|
355
|
-
if (fs.existsSync(tokenPath)) {
|
|
356
|
-
fs.unlinkSync(tokenPath);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
});
|
|
361
|
-
});
|
|
362
|
-
describe("When running the auth command", () => {
|
|
363
|
-
describe("with --help", () => {
|
|
364
|
-
it("should print usage and not fetch", async () => {
|
|
365
|
-
const out = createStdoutBuffer();
|
|
366
|
-
const fetchImpl = async () => {
|
|
367
|
-
throw new Error("fetch should not run for --help");
|
|
368
|
-
};
|
|
369
|
-
await runAuthCommandAsync({
|
|
370
|
-
argv: ["node", "index.js", "auth", "--help"],
|
|
371
|
-
env: {},
|
|
372
|
-
stdout: out.stdout,
|
|
373
|
-
fetchImpl,
|
|
374
|
-
});
|
|
375
|
-
assert.match(out.text(), /zereight-mcp-gitlab auth/);
|
|
376
|
-
assert.equal(out.text().includes(DEVICE_CODE), false);
|
|
377
|
-
});
|
|
378
|
-
});
|
|
379
|
-
describe("without a client id", () => {
|
|
380
|
-
it("should fail before calling GitLab", async () => {
|
|
381
|
-
await assert.rejects(() => runAuthCommandAsync({
|
|
382
|
-
argv: ["node", "index.js", "auth"],
|
|
383
|
-
env: {},
|
|
384
|
-
stdout: createStdoutBuffer().stdout,
|
|
385
|
-
fetchImpl: async () => {
|
|
386
|
-
throw new Error("fetch should not run without client id");
|
|
387
|
-
},
|
|
388
|
-
}), /GITLAB_OAUTH_CLIENT_ID/);
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
|
-
describe("with a successful device flow", () => {
|
|
392
|
-
it("should print the user code and not the device code or access token", async () => {
|
|
393
|
-
const tokenPath = createTempTokenPath();
|
|
394
|
-
const out = createStdoutBuffer();
|
|
395
|
-
const fetchImpl = createFetchStub({
|
|
396
|
-
authorize: { status: 200, body: deviceAuthorizationBody() },
|
|
397
|
-
tokens: [{ status: 200, body: successTokenBody() }],
|
|
398
|
-
});
|
|
399
|
-
try {
|
|
400
|
-
await runAuthCommandAsync({
|
|
401
|
-
argv: [
|
|
402
|
-
"node",
|
|
403
|
-
"index.js",
|
|
404
|
-
"auth",
|
|
405
|
-
`--client-id=${CLIENT_ID}`,
|
|
406
|
-
`--api-url=${GITLAB_URL}`,
|
|
407
|
-
`--token-path=${tokenPath}`,
|
|
408
|
-
],
|
|
409
|
-
env: {},
|
|
410
|
-
stdout: out.stdout,
|
|
411
|
-
fetchImpl,
|
|
412
|
-
sleepAsync: async () => { },
|
|
413
|
-
});
|
|
414
|
-
const printed = out.text();
|
|
415
|
-
assert.equal(printed.includes(USER_CODE), true);
|
|
416
|
-
assert.match(printed, /Token saved to/);
|
|
417
|
-
assert.match(printed, /GITLAB_USE_OAUTH=true/);
|
|
418
|
-
assert.match(printed, /GITLAB_OAUTH_TOKEN_PATH/);
|
|
419
|
-
assert.equal(printed.includes(DEVICE_CODE), false);
|
|
420
|
-
assert.equal(printed.includes(ACCESS_TOKEN), false);
|
|
421
|
-
assert.equal(fs.existsSync(tokenPath), true);
|
|
422
|
-
}
|
|
423
|
-
finally {
|
|
424
|
-
if (fs.existsSync(tokenPath)) {
|
|
425
|
-
fs.unlinkSync(tokenPath);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
});
|
|
429
|
-
});
|
|
430
|
-
describe("without a read-only flag", () => {
|
|
431
|
-
it("should request the api scope", async () => {
|
|
432
|
-
assert.equal(await runAuthCapturingScopeAsync({}), "api");
|
|
433
|
-
});
|
|
434
|
-
});
|
|
435
|
-
describe("with --permission-mode=readonly", () => {
|
|
436
|
-
it("should request the read_api scope", async () => {
|
|
437
|
-
assert.equal(await runAuthCapturingScopeAsync({ extraArgv: ["--permission-mode=readonly"] }), "read_api");
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
describe("with GITLAB_PERMISSION_MODE=readonly", () => {
|
|
441
|
-
it("should request the read_api scope", async () => {
|
|
442
|
-
assert.equal(await runAuthCapturingScopeAsync({ env: { GITLAB_PERMISSION_MODE: "readonly" } }), "read_api");
|
|
443
|
-
});
|
|
444
|
-
});
|
|
445
|
-
describe("with --read-only=true", () => {
|
|
446
|
-
it("should request the read_api scope", async () => {
|
|
447
|
-
assert.equal(await runAuthCapturingScopeAsync({ extraArgv: ["--read-only=true"] }), "read_api");
|
|
448
|
-
});
|
|
449
|
-
});
|
|
450
|
-
});
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import * as fs from "node:fs";
|
|
4
|
-
import * as net from "node:net";
|
|
5
|
-
import * as os from "node:os";
|
|
6
|
-
import * as path from "node:path";
|
|
7
|
-
import * as url from "node:url";
|
|
8
|
-
import { describe, it } from "node:test";
|
|
9
|
-
// The token key is derived at module load, so the secret must be set before importing it.
|
|
10
|
-
const DOWNLOAD_TOKEN_SECRET = "oauth-download-test-secret";
|
|
11
|
-
process.env.DOWNLOAD_TOKEN_SECRET = DOWNLOAD_TOKEN_SECRET;
|
|
12
|
-
const { decryptDownloadToken } = await import("../utils/download-token.js");
|
|
13
|
-
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
14
|
-
const SERVER_ENTRY = path.resolve(__dirname, "../build/index.js");
|
|
15
|
-
const HOST = "127.0.0.1";
|
|
16
|
-
const OAUTH_TOKEN = "oauth-download-test";
|
|
17
|
-
const MCP_ACCESS_TOKEN = "mcp-access-test";
|
|
18
|
-
const STARTUP_TIMEOUT_MS = 15_000;
|
|
19
|
-
const MCP_HEADERS = {
|
|
20
|
-
"Content-Type": "application/json",
|
|
21
|
-
Accept: "application/json, text/event-stream",
|
|
22
|
-
Authorization: `Bearer ${MCP_ACCESS_TOKEN}`,
|
|
23
|
-
};
|
|
24
|
-
function getFreePortAsync() {
|
|
25
|
-
return new Promise((resolve, reject) => {
|
|
26
|
-
const probe = net.createServer();
|
|
27
|
-
probe.on("error", reject);
|
|
28
|
-
probe.listen(0, HOST, () => {
|
|
29
|
-
const address = probe.address();
|
|
30
|
-
if (address === null || typeof address === "string") {
|
|
31
|
-
probe.close();
|
|
32
|
-
reject(new Error("probe server is not listening on a TCP port"));
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
probe.close(() => resolve(address.port));
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
function buildServerEnv(port, tmpDir) {
|
|
40
|
-
const tokenPath = path.join(tmpDir, "token.json");
|
|
41
|
-
fs.writeFileSync(tokenPath, JSON.stringify({
|
|
42
|
-
access_token: OAUTH_TOKEN,
|
|
43
|
-
refresh_token: "refresh",
|
|
44
|
-
expires_in: 7200,
|
|
45
|
-
created_at: Date.now(),
|
|
46
|
-
token_type: "Bearer",
|
|
47
|
-
}));
|
|
48
|
-
const env = {};
|
|
49
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
50
|
-
if (value !== undefined && !key.startsWith("GITLAB_")) {
|
|
51
|
-
env[key] = value;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return {
|
|
55
|
-
...env,
|
|
56
|
-
STREAMABLE_HTTP: "true",
|
|
57
|
-
HOST,
|
|
58
|
-
PORT: String(port),
|
|
59
|
-
MCP_SERVER_URL: "",
|
|
60
|
-
GITLAB_API_URL: `http://${HOST}:1/api/v4`,
|
|
61
|
-
GITLAB_USE_OAUTH: "true",
|
|
62
|
-
GITLAB_OAUTH_CLIENT_ID: "client-id",
|
|
63
|
-
GITLAB_OAUTH_TOKEN_PATH: tokenPath,
|
|
64
|
-
GITLAB_IS_OLD: "true",
|
|
65
|
-
USE_PIPELINE: "true",
|
|
66
|
-
DOWNLOAD_TOKEN_SECRET,
|
|
67
|
-
STREAMABLE_HTTP_AUTH_TOKEN: MCP_ACCESS_TOKEN,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
async function waitForServerAsync(port, child) {
|
|
71
|
-
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
72
|
-
while (Date.now() < deadline) {
|
|
73
|
-
if (child.exitCode !== null) {
|
|
74
|
-
throw new Error(`server exited early with code ${child.exitCode}`);
|
|
75
|
-
}
|
|
76
|
-
const reachable = await new Promise(resolve => {
|
|
77
|
-
const socket = net.connect(port, HOST);
|
|
78
|
-
socket.once("connect", () => {
|
|
79
|
-
socket.destroy();
|
|
80
|
-
resolve(true);
|
|
81
|
-
});
|
|
82
|
-
socket.once("error", () => resolve(false));
|
|
83
|
-
});
|
|
84
|
-
if (reachable) {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
88
|
-
}
|
|
89
|
-
throw new Error("server did not start listening in time");
|
|
90
|
-
}
|
|
91
|
-
async function postMcpAsync(port, body, sessionId) {
|
|
92
|
-
return fetch(`http://${HOST}:${port}/mcp`, {
|
|
93
|
-
method: "POST",
|
|
94
|
-
headers: sessionId ? { ...MCP_HEADERS, "mcp-session-id": sessionId } : MCP_HEADERS,
|
|
95
|
-
body: JSON.stringify(body),
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
function readJsonRpcResult(sseBody) {
|
|
99
|
-
const dataLine = sseBody.split("\n").find(line => line.startsWith("data: "));
|
|
100
|
-
if (dataLine === undefined) {
|
|
101
|
-
throw new Error(`no SSE data line in response: ${sseBody}`);
|
|
102
|
-
}
|
|
103
|
-
return JSON.parse(dataLine.slice("data: ".length));
|
|
104
|
-
}
|
|
105
|
-
function readDownloadUrl(rpc) {
|
|
106
|
-
const text = JSON.parse(JSON.stringify(rpc)).result?.content?.[0]?.text;
|
|
107
|
-
if (typeof text !== "string") {
|
|
108
|
-
throw new Error(`unexpected tool response: ${JSON.stringify(rpc)}`);
|
|
109
|
-
}
|
|
110
|
-
const downloadUrl = JSON.parse(text).download_url;
|
|
111
|
-
if (typeof downloadUrl !== "string") {
|
|
112
|
-
throw new Error(`no download_url in tool response: ${text}`);
|
|
113
|
-
}
|
|
114
|
-
return downloadUrl;
|
|
115
|
-
}
|
|
116
|
-
async function requestDownloadUrlAsync() {
|
|
117
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-oauth-download-"));
|
|
118
|
-
const port = await getFreePortAsync();
|
|
119
|
-
const env = buildServerEnv(port, tmpDir);
|
|
120
|
-
const child = spawn(process.execPath, [SERVER_ENTRY], { env, stdio: "ignore" });
|
|
121
|
-
try {
|
|
122
|
-
await waitForServerAsync(port, child);
|
|
123
|
-
const init = await postMcpAsync(port, {
|
|
124
|
-
jsonrpc: "2.0",
|
|
125
|
-
id: 1,
|
|
126
|
-
method: "initialize",
|
|
127
|
-
params: {
|
|
128
|
-
protocolVersion: "2025-03-26",
|
|
129
|
-
capabilities: {},
|
|
130
|
-
clientInfo: { name: "oauth-download-url-test", version: "1.0" },
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
const sessionId = init.headers.get("mcp-session-id") ?? undefined;
|
|
134
|
-
const call = await postMcpAsync(port, {
|
|
135
|
-
jsonrpc: "2.0",
|
|
136
|
-
id: 2,
|
|
137
|
-
method: "tools/call",
|
|
138
|
-
params: { name: "download_job_artifacts", arguments: { project_id: "g/p", job_id: "1" } },
|
|
139
|
-
}, sessionId);
|
|
140
|
-
return readDownloadUrl(readJsonRpcResult(await call.text()));
|
|
141
|
-
}
|
|
142
|
-
finally {
|
|
143
|
-
child.kill("SIGKILL");
|
|
144
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
describe("When a remote server builds a download URL with a stored OAuth token", () => {
|
|
148
|
-
describe("with GITLAB_IS_OLD enabled", () => {
|
|
149
|
-
it("should embed the token as a bearer credential", async () => {
|
|
150
|
-
const downloadUrl = await requestDownloadUrlAsync();
|
|
151
|
-
const embedded = new URL(downloadUrl).searchParams.get("_token");
|
|
152
|
-
const decrypted = embedded === null ? null : decryptDownloadToken(embedded);
|
|
153
|
-
assert.equal(decrypted?.header, "Authorization");
|
|
154
|
-
assert.equal(decrypted?.token, `Bearer ${OAUTH_TOKEN}`);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
});
|