@zereight/mcp-gitlab 2.1.68 → 2.1.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +6 -2
- package/README.zh-CN.md +5 -1
- package/build/cli/args.js +298 -0
- package/build/cli/compact-mcp-result.js +87 -0
- package/build/cli/curated.js +243 -0
- package/build/cli/exposure.js +72 -0
- package/build/cli/help.js +108 -0
- package/build/cli/output.js +65 -0
- package/build/cli/replay-command.js +53 -0
- package/build/cli/router.js +158 -0
- package/build/cli/tables.js +117 -0
- package/build/cli-boolean-flags.js +38 -0
- package/build/cli-command.js +14 -4
- package/build/config.js +30 -7
- package/build/index.js +95 -43
- package/build/oauth.js +93 -10
- package/build/scripts/generate-tool-docs.js +2 -1
- package/build/test/cli/args.test.js +175 -0
- package/build/test/cli/boolean-flags.test.js +106 -0
- package/build/test/cli/compact-mcp-result.test.js +93 -0
- package/build/test/cli/exposure.test.js +104 -0
- package/build/test/cli/load-config-snapshot.js +5 -0
- package/build/test/cli/output.test.js +74 -0
- package/build/test/cli/replay-command.test.js +37 -0
- package/build/test/cli/router.test.js +183 -0
- package/build/test/oauth-device-flow-tests.js +10 -0
- package/build/test/oauth-startup.test.js +204 -0
- package/build/test/test-dynamic-project-scope.js +2 -0
- package/build/test/test-geteffectiveprojectid.js +4 -2
- package/build/test/test-permission-mode.js +86 -0
- package/build/tools/denied-regex.js +38 -0
- package/package.json +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { formatCliError, formatToolOutput } from "../../cli/output.js";
|
|
4
|
+
describe("When formatToolOutput prints a tool result", () => {
|
|
5
|
+
describe("with json mode", () => {
|
|
6
|
+
it("should write the unwrapped payload as JSON", () => {
|
|
7
|
+
const formatted = formatToolOutput({
|
|
8
|
+
result: { content: [{ type: "text", text: JSON.stringify({ id: 1, username: "tao" }) }] },
|
|
9
|
+
mode: "json",
|
|
10
|
+
tableSpec: undefined,
|
|
11
|
+
});
|
|
12
|
+
assert.equal(formatted.stdout, "{\"id\":1,\"username\":\"tao\"}\n");
|
|
13
|
+
assert.equal(formatted.stderr, "");
|
|
14
|
+
assert.equal(formatted.isError, false);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe("with table mode for a list", () => {
|
|
18
|
+
it("should print iid state title columns", () => {
|
|
19
|
+
const formatted = formatToolOutput({
|
|
20
|
+
result: {
|
|
21
|
+
content: [
|
|
22
|
+
{
|
|
23
|
+
type: "text",
|
|
24
|
+
text: JSON.stringify([
|
|
25
|
+
{ iid: 45, state: "opened", title: "fix: pipeline retry race" },
|
|
26
|
+
{ iid: 44, state: "opened", title: "chore: bump deps" },
|
|
27
|
+
]),
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
mode: "table",
|
|
32
|
+
tableSpec: { kind: "list", columns: ["iid", "state", "title"] },
|
|
33
|
+
});
|
|
34
|
+
assert.match(formatted.stdout, /IID\s+STATE\s+TITLE/);
|
|
35
|
+
assert.match(formatted.stdout, /45\s+opened\s+fix: pipeline retry race/);
|
|
36
|
+
assert.equal(formatted.stderr, "");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("with a non-text content block first", () => {
|
|
40
|
+
it("should unwrap the first text payload", () => {
|
|
41
|
+
const formatted = formatToolOutput({
|
|
42
|
+
result: {
|
|
43
|
+
content: [
|
|
44
|
+
{ type: "image", data: "xxx" },
|
|
45
|
+
{ type: "text", text: JSON.stringify({ id: 2 }) },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
mode: "json",
|
|
49
|
+
tableSpec: undefined,
|
|
50
|
+
});
|
|
51
|
+
assert.equal(formatted.stdout, "{\"id\":2}\n");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("with an API-style error object", () => {
|
|
55
|
+
it("should mark the result as an error", () => {
|
|
56
|
+
const formatted = formatToolOutput({
|
|
57
|
+
result: { content: [{ type: "text", text: "GitLab API error: 404 Not Found" }], isError: true },
|
|
58
|
+
mode: "json",
|
|
59
|
+
tableSpec: undefined,
|
|
60
|
+
});
|
|
61
|
+
assert.equal(formatted.isError, true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("When formatCliError prints a thrown error", () => {
|
|
66
|
+
describe("with a token in the message", () => {
|
|
67
|
+
it("should redact the token", () => {
|
|
68
|
+
const leaked = `glpat-${"notarealtokenvalue"}`;
|
|
69
|
+
const text = formatCliError(new Error(`auth failed ${leaked}`));
|
|
70
|
+
assert.match(text, /\[REDACTED\]/);
|
|
71
|
+
assert.equal(text.includes(leaked), false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { buildToolReplayCommand } from "../../cli/replay-command.js";
|
|
4
|
+
describe("When buildToolReplayCommand prints a CLI invocation", () => {
|
|
5
|
+
describe("with scalar tool arguments", () => {
|
|
6
|
+
it("should emit kebab-case flags without the token", () => {
|
|
7
|
+
const command = buildToolReplayCommand("list_issues", {
|
|
8
|
+
project_id: "123",
|
|
9
|
+
state: "opened",
|
|
10
|
+
token: "glpat-secret",
|
|
11
|
+
});
|
|
12
|
+
assert.equal(command, "zereight-mcp-gitlab tool list_issues --project-id 123 --state opened");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("with a nested object", () => {
|
|
16
|
+
it("should pass it through --args-json", () => {
|
|
17
|
+
const command = buildToolReplayCommand("execute_graphql", {
|
|
18
|
+
query: "{ currentUser { id } }",
|
|
19
|
+
variables: { n: 1 },
|
|
20
|
+
});
|
|
21
|
+
assert.match(command, /tool execute_graphql/);
|
|
22
|
+
assert.match(command, /--query/);
|
|
23
|
+
assert.match(command, /--args-json/);
|
|
24
|
+
assert.equal(command.includes("glpat"), false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe("with confirmation metadata", () => {
|
|
28
|
+
it("should omit _confirmed", () => {
|
|
29
|
+
const command = buildToolReplayCommand("push_files", {
|
|
30
|
+
project_id: "1",
|
|
31
|
+
_confirmed: true,
|
|
32
|
+
});
|
|
33
|
+
assert.equal(command.includes("_confirmed"), false);
|
|
34
|
+
assert.match(command, /--project-id 1/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isCliInvocation, resolveCli } from "../../cli/router.js";
|
|
4
|
+
function argv(...args) {
|
|
5
|
+
return ["node", "index.js", ...args];
|
|
6
|
+
}
|
|
7
|
+
const OPEN_EXPOSURE = {
|
|
8
|
+
isExposed() {
|
|
9
|
+
return true;
|
|
10
|
+
},
|
|
11
|
+
needsConfirmation() {
|
|
12
|
+
return false;
|
|
13
|
+
},
|
|
14
|
+
exposureRefusal() {
|
|
15
|
+
return undefined;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
function resolve(args, extra = {}) {
|
|
19
|
+
return resolveCli(args, { exposure: extra.exposure ?? OPEN_EXPOSURE, permissionMode: extra.permissionMode });
|
|
20
|
+
}
|
|
21
|
+
describe("When resolveCli classifies argv", () => {
|
|
22
|
+
describe("with no positional command", () => {
|
|
23
|
+
it("should stay on the MCP server path", () => {
|
|
24
|
+
const resolved = resolve(argv("--token", "glpat-xxxxxxxxxxxxxxxxxxxx"));
|
|
25
|
+
assert.equal(resolved.kind, "server");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("with --help", () => {
|
|
29
|
+
it("should print global help without starting the server", () => {
|
|
30
|
+
const resolved = resolve(argv("--help"));
|
|
31
|
+
assert.equal(resolved.kind, "help");
|
|
32
|
+
if (resolved.kind === "help") {
|
|
33
|
+
assert.match(resolved.text, /tool <tool-name>/);
|
|
34
|
+
assert.match(resolved.text, /auth/);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe("with --help after an unknown positional", () => {
|
|
39
|
+
it("should print global help instead of starting the server", () => {
|
|
40
|
+
const resolved = resolve(argv("not-a-command", "--help"));
|
|
41
|
+
assert.equal(resolved.kind, "help");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("with tool whoami", () => {
|
|
45
|
+
it("should run whoami with json output by default", () => {
|
|
46
|
+
const resolved = resolve(argv("tool", "whoami"));
|
|
47
|
+
assert.equal(resolved.kind, "run");
|
|
48
|
+
if (resolved.kind === "run") {
|
|
49
|
+
assert.equal(resolved.toolName, "whoami");
|
|
50
|
+
assert.equal(resolved.output, "json");
|
|
51
|
+
assert.deepEqual(resolved.args, {});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("When a curated command is resolved", () => {
|
|
57
|
+
describe("with mr list --group-id", () => {
|
|
58
|
+
it("should call list_group_merge_requests", () => {
|
|
59
|
+
const resolved = resolve(argv("mr", "list", "--group-id", "42", "--state", "opened"));
|
|
60
|
+
assert.equal(resolved.kind, "run");
|
|
61
|
+
if (resolved.kind === "run") {
|
|
62
|
+
assert.equal(resolved.toolName, "list_group_merge_requests");
|
|
63
|
+
assert.equal(resolved.args.group_id, "42");
|
|
64
|
+
assert.equal(resolved.output, "table");
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("with issue close", () => {
|
|
69
|
+
it("should call update_issue with state_event close", () => {
|
|
70
|
+
const resolved = resolve(argv("issue", "close", "--project-id", "123", "--issue-iid", "9"));
|
|
71
|
+
assert.equal(resolved.kind, "run");
|
|
72
|
+
if (resolved.kind === "run") {
|
|
73
|
+
assert.equal(resolved.toolName, "update_issue");
|
|
74
|
+
assert.equal(resolved.args.issue_iid, "9");
|
|
75
|
+
assert.equal(resolved.args.state_event, "close");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe("with mr merge and --yes=false", () => {
|
|
80
|
+
it("should reject the inline value instead of merging", () => {
|
|
81
|
+
const resolved = resolve(argv("mr", "merge", "--project-id", "123", "--mr-iid", "45", "--yes=false"));
|
|
82
|
+
assert.equal(resolved.kind, "usage");
|
|
83
|
+
if (resolved.kind === "usage") {
|
|
84
|
+
assert.match(resolved.message, /--yes does not accept a value/);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("with mr merge and no --yes", () => {
|
|
89
|
+
it("should refuse until --yes is passed without dumping argument values", () => {
|
|
90
|
+
const resolved = resolve(argv("mr", "merge", "--project-id", "123", "--mr-iid", "45"));
|
|
91
|
+
assert.equal(resolved.kind, "refused");
|
|
92
|
+
if (resolved.kind === "refused") {
|
|
93
|
+
assert.match(resolved.message, /--yes/);
|
|
94
|
+
assert.match(resolved.message, /merge_merge_request/);
|
|
95
|
+
assert.match(resolved.message, /project_id/);
|
|
96
|
+
assert.equal(resolved.message.includes("123"), false);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("with variable list scoped via --args-json", () => {
|
|
101
|
+
it("should call list_group_variables when group_id is only in JSON", () => {
|
|
102
|
+
const resolved = resolve(argv("variable", "list", "--args-json", '{"group_id":"42"}'));
|
|
103
|
+
assert.equal(resolved.kind, "run");
|
|
104
|
+
if (resolved.kind === "run") {
|
|
105
|
+
assert.equal(resolved.toolName, "list_group_variables");
|
|
106
|
+
assert.equal(resolved.args.group_id, "42");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe("When permission mode blocks a write", () => {
|
|
112
|
+
describe("with readonly mode and issue create", () => {
|
|
113
|
+
it("should refuse before dispatch", () => {
|
|
114
|
+
const resolved = resolve(argv("issue", "create", "--project-id", "123", "--title", "bug"), { permissionMode: "readonly" });
|
|
115
|
+
assert.equal(resolved.kind, "refused");
|
|
116
|
+
if (resolved.kind === "refused") {
|
|
117
|
+
assert.match(resolved.message, /read-only/);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe("When isCliInvocation inspects argv", () => {
|
|
123
|
+
describe("with a curated group", () => {
|
|
124
|
+
it("should treat it as a human CLI invocation", () => {
|
|
125
|
+
assert.equal(isCliInvocation(argv("mr", "list")), true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe("with --help", () => {
|
|
129
|
+
it("should treat it as a human CLI invocation", () => {
|
|
130
|
+
assert.equal(isCliInvocation(argv("--help")), true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe("with only a server token flag", () => {
|
|
134
|
+
it("should stay on the MCP server path", () => {
|
|
135
|
+
assert.equal(isCliInvocation(argv("--token", "x")), false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe("When MCP exposure filters block a CLI tool", () => {
|
|
140
|
+
describe("with a denied-tools regex", () => {
|
|
141
|
+
it("should refuse before dispatch", () => {
|
|
142
|
+
const resolved = resolve(argv("tool", "whoami"), {
|
|
143
|
+
exposure: {
|
|
144
|
+
isExposed() {
|
|
145
|
+
return false;
|
|
146
|
+
},
|
|
147
|
+
needsConfirmation() {
|
|
148
|
+
return false;
|
|
149
|
+
},
|
|
150
|
+
exposureRefusal(toolName) {
|
|
151
|
+
return `${toolName} is blocked by GITLAB_DENIED_TOOLS_REGEX`;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
assert.equal(resolved.kind, "refused");
|
|
156
|
+
if (resolved.kind === "refused") {
|
|
157
|
+
assert.match(resolved.message, /GITLAB_DENIED_TOOLS_REGEX/);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe("with GITLAB_TOOL_POLICY_APPROVE", () => {
|
|
162
|
+
it("should require --yes", () => {
|
|
163
|
+
const resolved = resolve(argv("tool", "whoami"), {
|
|
164
|
+
exposure: {
|
|
165
|
+
isExposed() {
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
needsConfirmation() {
|
|
169
|
+
return true;
|
|
170
|
+
},
|
|
171
|
+
exposureRefusal() {
|
|
172
|
+
return undefined;
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
assert.equal(resolved.kind, "refused");
|
|
177
|
+
if (resolved.kind === "refused") {
|
|
178
|
+
assert.match(resolved.message, /GITLAB_TOOL_POLICY_APPROVE/);
|
|
179
|
+
assert.match(resolved.message, /--yes/);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -193,6 +193,16 @@ describe("When reading the CLI command", () => {
|
|
|
193
193
|
assert.equal(getPositionalCliCommand(["node", "/usr/local/bin/tsx", "index.ts", "auth", "--help"]), "auth");
|
|
194
194
|
});
|
|
195
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
|
+
});
|
|
196
206
|
describe("with space-separated --api-url before auth", () => {
|
|
197
207
|
it("should skip the URL value and return auth", () => {
|
|
198
208
|
assert.equal(getPositionalCliCommand([
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth startup tests.
|
|
3
|
+
*
|
|
4
|
+
* - isAuthInvalidTokenResponse: pure classifier — refresh failures that mean
|
|
5
|
+
* "re-login would fix it" vs transient/config errors that must not open a browser.
|
|
6
|
+
* - ensureOAuthToken: lazy first-use delivery — the caller must receive the
|
|
7
|
+
* token even when the on-disk token is still valid (regression: returning
|
|
8
|
+
* early on hasValidToken() alone leaves requests unauthenticated).
|
|
9
|
+
*
|
|
10
|
+
* Temp token files + a loopback mock for /oauth/token only; no MCP server.
|
|
11
|
+
* The missing-token and invalid_grant paths start the interactive browser
|
|
12
|
+
* flow and are deliberately not covered here.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, test, afterEach } from "node:test";
|
|
15
|
+
import assert from "node:assert";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as http from "node:http";
|
|
20
|
+
import { GitLabOAuth, ensureOAuthToken, isAuthInvalidTokenResponse } from "../oauth.js";
|
|
21
|
+
const validTokenFile = {
|
|
22
|
+
access_token: "cached-access-token",
|
|
23
|
+
refresh_token: "cached-refresh-token",
|
|
24
|
+
expires_in: 7200,
|
|
25
|
+
created_at: Date.now(),
|
|
26
|
+
token_type: "Bearer",
|
|
27
|
+
};
|
|
28
|
+
const expiredTokenFile = {
|
|
29
|
+
...validTokenFile,
|
|
30
|
+
created_at: Date.now() - 10 * 3600 * 1000,
|
|
31
|
+
};
|
|
32
|
+
const tmpDirs = [];
|
|
33
|
+
const servers = [];
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
for (const server of servers.splice(0)) {
|
|
36
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
37
|
+
}
|
|
38
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
function makeTokenFile(data) {
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oauth-startup-"));
|
|
44
|
+
tmpDirs.push(dir);
|
|
45
|
+
const file = path.join(dir, "token.json");
|
|
46
|
+
fs.writeFileSync(file, JSON.stringify(data));
|
|
47
|
+
return file;
|
|
48
|
+
}
|
|
49
|
+
function makeClient(tokenStoragePath, gitlabUrl, extra) {
|
|
50
|
+
return new GitLabOAuth({
|
|
51
|
+
clientId: "test-client",
|
|
52
|
+
redirectUri: "http://127.0.0.1:8888/callback",
|
|
53
|
+
gitlabUrl,
|
|
54
|
+
scopes: ["api"],
|
|
55
|
+
tokenStoragePath,
|
|
56
|
+
...extra,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function startTokenServer(handler) {
|
|
60
|
+
const bodies = [];
|
|
61
|
+
const server = http.createServer((req, res) => {
|
|
62
|
+
let body = "";
|
|
63
|
+
req.on("data", (chunk) => {
|
|
64
|
+
body += chunk.toString();
|
|
65
|
+
});
|
|
66
|
+
req.on("end", () => {
|
|
67
|
+
bodies.push(body);
|
|
68
|
+
const { status, json } = handler();
|
|
69
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
70
|
+
res.end(JSON.stringify(json));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
servers.push(server);
|
|
74
|
+
await new Promise(resolve => server.listen(0, "127.0.0.1", () => resolve()));
|
|
75
|
+
const addr = server.address();
|
|
76
|
+
assert(addr && typeof addr !== "string");
|
|
77
|
+
return { url: `http://127.0.0.1:${addr.port}`, bodies };
|
|
78
|
+
}
|
|
79
|
+
describe("isAuthInvalidTokenResponse", () => {
|
|
80
|
+
test("400 invalid_grant is auth-invalid", () => {
|
|
81
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_grant"}'), true);
|
|
82
|
+
});
|
|
83
|
+
test("400 invalid_token is auth-invalid", () => {
|
|
84
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_token"}'), true);
|
|
85
|
+
});
|
|
86
|
+
test("401 invalid_grant is auth-invalid", () => {
|
|
87
|
+
assert.strictEqual(isAuthInvalidTokenResponse(401, '{"error":"invalid_grant"}'), true);
|
|
88
|
+
});
|
|
89
|
+
test("400 invalid_client is not auth-invalid (config error)", () => {
|
|
90
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_client"}'), false);
|
|
91
|
+
});
|
|
92
|
+
test("400 invalid_request is not auth-invalid (config error)", () => {
|
|
93
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_request"}'), false);
|
|
94
|
+
});
|
|
95
|
+
test("500 server_error is not auth-invalid (transient)", () => {
|
|
96
|
+
assert.strictEqual(isAuthInvalidTokenResponse(500, '{"error":"server_error"}'), false);
|
|
97
|
+
});
|
|
98
|
+
test("429 is not auth-invalid (rate limited)", () => {
|
|
99
|
+
assert.strictEqual(isAuthInvalidTokenResponse(429, '{"error":"invalid_grant"}'), false);
|
|
100
|
+
});
|
|
101
|
+
test("non-JSON body is not auth-invalid", () => {
|
|
102
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, "gateway timeout"), false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe("ensureOAuthToken", () => {
|
|
106
|
+
test("null client is a no-op", async () => {
|
|
107
|
+
const delivered = [];
|
|
108
|
+
await ensureOAuthToken(null, null, t => {
|
|
109
|
+
delivered.push(t);
|
|
110
|
+
});
|
|
111
|
+
await ensureOAuthToken(undefined, undefined, t => {
|
|
112
|
+
delivered.push(t);
|
|
113
|
+
});
|
|
114
|
+
assert.deepStrictEqual(delivered, []);
|
|
115
|
+
});
|
|
116
|
+
test("first call with valid cached token delivers it without network", async () => {
|
|
117
|
+
// Closed port: any fetch attempt fails, proving the cached path is network-free.
|
|
118
|
+
const client = makeClient(makeTokenFile(validTokenFile), "http://127.0.0.1:9");
|
|
119
|
+
const delivered = [];
|
|
120
|
+
await ensureOAuthToken(client, null, t => {
|
|
121
|
+
delivered.push(t);
|
|
122
|
+
});
|
|
123
|
+
assert.deepStrictEqual(delivered, ["cached-access-token"]);
|
|
124
|
+
});
|
|
125
|
+
test("already-held valid token is not re-delivered", async () => {
|
|
126
|
+
const client = makeClient(makeTokenFile(validTokenFile), "http://127.0.0.1:9");
|
|
127
|
+
const delivered = [];
|
|
128
|
+
await ensureOAuthToken(client, "cached-access-token", t => {
|
|
129
|
+
delivered.push(t);
|
|
130
|
+
});
|
|
131
|
+
assert.deepStrictEqual(delivered, []);
|
|
132
|
+
});
|
|
133
|
+
test("expired token refreshes via /oauth/token and persists", async () => {
|
|
134
|
+
const { url, bodies } = await startTokenServer(() => ({
|
|
135
|
+
status: 200,
|
|
136
|
+
json: {
|
|
137
|
+
access_token: "fresh-token",
|
|
138
|
+
refresh_token: "fresh-refresh",
|
|
139
|
+
expires_in: 7200,
|
|
140
|
+
token_type: "Bearer",
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
const file = makeTokenFile(expiredTokenFile);
|
|
144
|
+
const client = makeClient(file, url);
|
|
145
|
+
const delivered = [];
|
|
146
|
+
await ensureOAuthToken(client, null, t => {
|
|
147
|
+
delivered.push(t);
|
|
148
|
+
});
|
|
149
|
+
assert.deepStrictEqual(delivered, ["fresh-token"]);
|
|
150
|
+
assert.strictEqual(bodies.length, 1);
|
|
151
|
+
assert.match(bodies[0], /grant_type=refresh_token/);
|
|
152
|
+
assert.match(bodies[0], /refresh_token=cached-refresh-token/);
|
|
153
|
+
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
154
|
+
assert.strictEqual(saved.access_token, "fresh-token");
|
|
155
|
+
});
|
|
156
|
+
test("transient refresh failure propagates without opening a browser", async () => {
|
|
157
|
+
const { url } = await startTokenServer(() => ({ status: 500, json: { error: "server_error" } }));
|
|
158
|
+
const client = makeClient(makeTokenFile(expiredTokenFile), url);
|
|
159
|
+
const delivered = [];
|
|
160
|
+
// Rejection (not a browser popup) proves the no-browser path.
|
|
161
|
+
await assert.rejects(() => ensureOAuthToken(client, null, t => {
|
|
162
|
+
delivered.push(t);
|
|
163
|
+
}), /Token refresh failed: 500/);
|
|
164
|
+
assert.deepStrictEqual(delivered, []);
|
|
165
|
+
});
|
|
166
|
+
test("token script resolves once, then fast path holds", async () => {
|
|
167
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oauth-startup-"));
|
|
168
|
+
tmpDirs.push(dir);
|
|
169
|
+
const client = makeClient(path.join(dir, "token.json"), "http://127.0.0.1:9", {
|
|
170
|
+
tokenScript: "echo script-token-1",
|
|
171
|
+
});
|
|
172
|
+
const delivered = [];
|
|
173
|
+
await ensureOAuthToken(client, null, t => {
|
|
174
|
+
delivered.push(t);
|
|
175
|
+
});
|
|
176
|
+
assert.deepStrictEqual(delivered, ["script-token-1"]);
|
|
177
|
+
await ensureOAuthToken(client, delivered[0], t => {
|
|
178
|
+
delivered.push(t);
|
|
179
|
+
});
|
|
180
|
+
assert.deepStrictEqual(delivered, ["script-token-1"]);
|
|
181
|
+
});
|
|
182
|
+
test("concurrent calls coalesce into a single refresh request", async () => {
|
|
183
|
+
const { url, bodies } = await startTokenServer(() => ({
|
|
184
|
+
status: 200,
|
|
185
|
+
json: {
|
|
186
|
+
access_token: "coalesced-token",
|
|
187
|
+
refresh_token: "new-refresh",
|
|
188
|
+
expires_in: 7200,
|
|
189
|
+
token_type: "Bearer",
|
|
190
|
+
},
|
|
191
|
+
}));
|
|
192
|
+
const file = makeTokenFile(expiredTokenFile);
|
|
193
|
+
const client = makeClient(file, url);
|
|
194
|
+
const deliveredA = [];
|
|
195
|
+
const deliveredB = [];
|
|
196
|
+
await Promise.all([
|
|
197
|
+
ensureOAuthToken(client, null, t => deliveredA.push(t)),
|
|
198
|
+
ensureOAuthToken(client, null, t => deliveredB.push(t)),
|
|
199
|
+
]);
|
|
200
|
+
assert.deepStrictEqual(deliveredA, ["coalesced-token"]);
|
|
201
|
+
assert.deepStrictEqual(deliveredB, ["coalesced-token"]);
|
|
202
|
+
assert.strictEqual(bodies.length, 1);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -79,6 +79,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
|
|
|
79
79
|
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
80
80
|
ENABLE_STRICT_PROJECT_SCOPE: "true",
|
|
81
81
|
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
82
|
+
GITLAB_TOOLS: "execute_graphql",
|
|
82
83
|
GITLAB_ALLOWED_PROJECT_IDS: "1,2",
|
|
83
84
|
},
|
|
84
85
|
});
|
|
@@ -286,6 +287,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
|
|
|
286
287
|
REMOTE_AUTHORIZATION: "true",
|
|
287
288
|
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
288
289
|
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
290
|
+
GITLAB_TOOLS: "execute_graphql,get_vulnerability",
|
|
289
291
|
},
|
|
290
292
|
});
|
|
291
293
|
servers.push(server);
|
|
@@ -168,6 +168,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
168
168
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
169
169
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
170
170
|
GITLAB_READ_ONLY_MODE: 'false',
|
|
171
|
+
GITLAB_TOOLSETS: 'all',
|
|
171
172
|
}
|
|
172
173
|
});
|
|
173
174
|
servers.push(server);
|
|
@@ -318,6 +319,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
318
319
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
319
320
|
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
320
321
|
GITLAB_READ_ONLY_MODE: 'true',
|
|
322
|
+
GITLAB_TOOLSETS: 'all',
|
|
321
323
|
}
|
|
322
324
|
});
|
|
323
325
|
servers.push(server);
|
|
@@ -484,7 +486,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
484
486
|
REMOTE_AUTHORIZATION: 'true',
|
|
485
487
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
486
488
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
487
|
-
GITLAB_TOOLSETS: 'variables',
|
|
489
|
+
GITLAB_TOOLSETS: 'variables,repositories,groups,projects',
|
|
488
490
|
}
|
|
489
491
|
});
|
|
490
492
|
servers.push(server);
|
|
@@ -582,7 +584,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
582
584
|
REMOTE_AUTHORIZATION: 'true',
|
|
583
585
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
584
586
|
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
585
|
-
GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines',
|
|
587
|
+
GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines,repositories,groups,projects',
|
|
586
588
|
}
|
|
587
589
|
});
|
|
588
590
|
servers.push(server);
|
|
@@ -31,6 +31,8 @@ async function launchMcpServer(extraEnv = {}) {
|
|
|
31
31
|
REMOTE_AUTHORIZATION: "true",
|
|
32
32
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
33
33
|
GITLAB_TOOLSETS: "all",
|
|
34
|
+
// execute_graphql is in no toolset; opt in so the guards under test are reachable.
|
|
35
|
+
GITLAB_TOOLS: "execute_graphql",
|
|
34
36
|
...extraEnv,
|
|
35
37
|
},
|
|
36
38
|
});
|
|
@@ -280,6 +282,90 @@ describe("Permission Mode", { concurrency: 1 }, () => {
|
|
|
280
282
|
}
|
|
281
283
|
});
|
|
282
284
|
});
|
|
285
|
+
describe("call-time enforcement of list filters (modify mode)", () => {
|
|
286
|
+
const servers = [];
|
|
287
|
+
after(() => cleanupServers(servers));
|
|
288
|
+
/** Calls a tool on a fresh client and returns the serialized result, or the error message. */
|
|
289
|
+
async function callOutcome(server, name, args) {
|
|
290
|
+
const client = await connectClient(server);
|
|
291
|
+
try {
|
|
292
|
+
return JSON.stringify(await client.callTool(name, args));
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
return error instanceof Error ? error.message : String(error);
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
await client.disconnect();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
test("rejects a regex-denied tool and an execute_graphql with no GITLAB_TOOLS entry", async () => {
|
|
302
|
+
const server = await launchMcpServer({
|
|
303
|
+
GITLAB_PERMISSION_MODE: "modify",
|
|
304
|
+
GITLAB_TOOLS: "",
|
|
305
|
+
GITLAB_DENIED_TOOLS_REGEX: "^merge_merge_request$",
|
|
306
|
+
});
|
|
307
|
+
servers.push(server);
|
|
308
|
+
const names = await getToolNames(server);
|
|
309
|
+
assert.ok(!names.includes("merge_merge_request"), "regex-denied tool must be unlisted");
|
|
310
|
+
assert.ok(!names.includes("execute_graphql"), "execute_graphql must be unlisted");
|
|
311
|
+
const merge = await callOutcome(server, "merge_merge_request", {
|
|
312
|
+
project_id: "1",
|
|
313
|
+
merge_request_iid: 1,
|
|
314
|
+
});
|
|
315
|
+
assert.ok(merge.includes("not available"), `merge_merge_request: ${merge}`);
|
|
316
|
+
const graphql = await callOutcome(server, "execute_graphql", {
|
|
317
|
+
query: "query { currentUser { id } }",
|
|
318
|
+
});
|
|
319
|
+
assert.ok(graphql.includes("not available"), `execute_graphql: ${graphql}`);
|
|
320
|
+
});
|
|
321
|
+
test("policy-hidden tool is unlisted but still callable", async () => {
|
|
322
|
+
const server = await launchMcpServer({
|
|
323
|
+
GITLAB_PERMISSION_MODE: "modify",
|
|
324
|
+
GITLAB_TOOL_POLICY_HIDDEN: "list_issues",
|
|
325
|
+
});
|
|
326
|
+
servers.push(server);
|
|
327
|
+
const names = await getToolNames(server);
|
|
328
|
+
assert.ok(!names.includes("list_issues"), "hidden tool must be unlisted");
|
|
329
|
+
const outcome = await callOutcome(server, "list_issues", { project_id: "1" });
|
|
330
|
+
assert.ok(!outcome.includes("not available"), `hidden tool must stay callable: ${outcome}`);
|
|
331
|
+
});
|
|
332
|
+
test("policy-hidden tool that is also regex-denied is rejected", async () => {
|
|
333
|
+
const server = await launchMcpServer({
|
|
334
|
+
GITLAB_PERMISSION_MODE: "modify",
|
|
335
|
+
GITLAB_TOOL_POLICY_HIDDEN: "list_issues",
|
|
336
|
+
GITLAB_DENIED_TOOLS_REGEX: "^list_issues$",
|
|
337
|
+
});
|
|
338
|
+
servers.push(server);
|
|
339
|
+
const names = await getToolNames(server);
|
|
340
|
+
assert.ok(!names.includes("list_issues"), "hidden tool must be unlisted");
|
|
341
|
+
const outcome = await callOutcome(server, "list_issues", { project_id: "1" });
|
|
342
|
+
assert.ok(outcome.includes("not available"), `regex denial must win over hidden exemption: ${outcome}`);
|
|
343
|
+
});
|
|
344
|
+
test("tool outside the enabled toolsets is rejected until discover_tools activates it", async () => {
|
|
345
|
+
const server = await launchMcpServer({
|
|
346
|
+
GITLAB_PERMISSION_MODE: "modify",
|
|
347
|
+
GITLAB_TOOLSETS: "issues",
|
|
348
|
+
});
|
|
349
|
+
servers.push(server);
|
|
350
|
+
const client = await connectClient(server);
|
|
351
|
+
try {
|
|
352
|
+
const before = await client
|
|
353
|
+
.callTool("list_pipelines", { project_id: "1" })
|
|
354
|
+
.then(r => JSON.stringify(r))
|
|
355
|
+
.catch((e) => e.message);
|
|
356
|
+
assert.ok(before.includes("not available"), `before activation: ${before}`);
|
|
357
|
+
await client.callTool("discover_tools", { category: "pipelines" });
|
|
358
|
+
const after = await client
|
|
359
|
+
.callTool("list_pipelines", { project_id: "1" })
|
|
360
|
+
.then(r => JSON.stringify(r))
|
|
361
|
+
.catch((e) => e.message);
|
|
362
|
+
assert.ok(!after.includes("not available"), `after activation: ${after}`);
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
await client.disconnect();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
});
|
|
283
369
|
describe("readonly mode via GITLAB_PERMISSION_MODE", () => {
|
|
284
370
|
let server;
|
|
285
371
|
const servers = [];
|