@zereight/mcp-gitlab 2.1.50 → 2.1.52

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.
@@ -0,0 +1,158 @@
1
+ import { z } from "zod";
2
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
3
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
4
+ const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
5
+ const DeviceAuthorizationSchema = z.object({
6
+ device_code: z.string().min(1),
7
+ user_code: z.string().min(1),
8
+ verification_uri: z.string().min(1),
9
+ verification_uri_complete: z.string().min(1).optional(),
10
+ expires_in: z.number().positive(),
11
+ interval: z.number().nonnegative().optional(),
12
+ });
13
+ const DeviceTokenErrorSchema = z.object({
14
+ error: z.string().min(1),
15
+ error_description: z.string().optional(),
16
+ });
17
+ const TokenResponseSchema = z.object({
18
+ access_token: z.string().min(1),
19
+ refresh_token: z.string().min(1).optional(),
20
+ expires_in: z.number().positive().optional(),
21
+ token_type: z.string().min(1).optional(),
22
+ });
23
+ function delayAsync(ms) {
24
+ return new Promise(resolve => {
25
+ setTimeout(resolve, ms);
26
+ });
27
+ }
28
+ async function readJsonBodyAsync(response) {
29
+ const text = await response.text();
30
+ if (!text) {
31
+ return undefined;
32
+ }
33
+ try {
34
+ return JSON.parse(text);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ function gitlabOrigin(gitlabUrl) {
41
+ return gitlabUrl.replace(/\/$/, "");
42
+ }
43
+ function formatOauthError(errorCode, description) {
44
+ if (description) {
45
+ return `${errorCode}: ${description}`;
46
+ }
47
+ return errorCode;
48
+ }
49
+ function unsupportedDeviceFlowMessage(status) {
50
+ return (`Device authorization is not available on this GitLab instance (HTTP ${status}). ` +
51
+ "GitLab 17.9+ is required for `zereight-mcp-gitlab auth` " +
52
+ "(17.2–17.8 need oauth2_device_grant_flow). " +
53
+ "Use a Personal Access Token (GITLAB_PERSONAL_ACCESS_TOKEN) instead.");
54
+ }
55
+ /**
56
+ * RFC 8628 Device Authorization Grant against GitLab (17.9+; 17.2–17.8 with
57
+ * oauth2_device_grant_flow).
58
+ * Does not open a browser. Never logs device_code or tokens.
59
+ */
60
+ export async function runDeviceAuthorizationGrantAsync(input) {
61
+ const fetchImpl = input.fetchImpl ?? globalThis.fetch;
62
+ const sleepAsync = input.sleepAsync ?? delayAsync;
63
+ const now = input.now ?? Date.now;
64
+ const origin = gitlabOrigin(input.gitlabUrl);
65
+ const authorizeParams = new URLSearchParams({
66
+ client_id: input.clientId,
67
+ scope: input.scopes.join(" "),
68
+ });
69
+ const authorizeResponse = await fetchImpl(`${origin}/oauth/authorize_device`, {
70
+ method: "POST",
71
+ headers: {
72
+ "Content-Type": "application/x-www-form-urlencoded",
73
+ },
74
+ body: authorizeParams.toString(),
75
+ });
76
+ if (authorizeResponse.status === 404) {
77
+ throw new Error(unsupportedDeviceFlowMessage(404));
78
+ }
79
+ const authorizeBody = await readJsonBodyAsync(authorizeResponse);
80
+ if (!authorizeResponse.ok) {
81
+ const parsedError = DeviceTokenErrorSchema.safeParse(authorizeBody);
82
+ if (parsedError.success) {
83
+ throw new Error(`Device authorization request failed: ${formatOauthError(parsedError.data.error, parsedError.data.error_description)}`);
84
+ }
85
+ if (authorizeResponse.status >= 400 && authorizeResponse.status < 500) {
86
+ throw new Error(unsupportedDeviceFlowMessage(authorizeResponse.status));
87
+ }
88
+ throw new Error(`Device authorization request failed (HTTP ${authorizeResponse.status}).`);
89
+ }
90
+ const authorization = DeviceAuthorizationSchema.safeParse(authorizeBody);
91
+ if (!authorization.success) {
92
+ throw new Error("Device authorization endpoint returned an invalid response.");
93
+ }
94
+ const { device_code: deviceCode, user_code: userCode, verification_uri: verificationUri, verification_uri_complete: verificationUriComplete, expires_in: expiresIn, interval: rawInterval, } = authorization.data;
95
+ input.onUserCode?.({
96
+ userCode,
97
+ verificationUri,
98
+ verificationUriComplete,
99
+ });
100
+ let intervalSeconds = rawInterval === undefined || rawInterval <= 0
101
+ ? DEFAULT_POLL_INTERVAL_SECONDS
102
+ : rawInterval;
103
+ const deadline = now() + expiresIn * 1000;
104
+ const tokenUrl = `${origin}/oauth/token`;
105
+ while (now() < deadline) {
106
+ const tokenParams = new URLSearchParams({
107
+ grant_type: DEVICE_GRANT_TYPE,
108
+ device_code: deviceCode,
109
+ client_id: input.clientId,
110
+ });
111
+ if (input.clientSecret) {
112
+ tokenParams.set("client_secret", input.clientSecret);
113
+ }
114
+ const tokenResponse = await fetchImpl(tokenUrl, {
115
+ method: "POST",
116
+ headers: {
117
+ "Content-Type": "application/x-www-form-urlencoded",
118
+ },
119
+ body: tokenParams.toString(),
120
+ });
121
+ const tokenBody = await readJsonBodyAsync(tokenResponse);
122
+ if (tokenResponse.ok) {
123
+ const token = TokenResponseSchema.safeParse(tokenBody);
124
+ if (!token.success) {
125
+ throw new Error("Token endpoint returned an invalid response.");
126
+ }
127
+ return {
128
+ access_token: token.data.access_token,
129
+ refresh_token: token.data.refresh_token,
130
+ expires_in: token.data.expires_in,
131
+ created_at: now(),
132
+ token_type: token.data.token_type ?? "Bearer",
133
+ };
134
+ }
135
+ const tokenError = DeviceTokenErrorSchema.safeParse(tokenBody);
136
+ const errorCode = tokenError.success ? tokenError.data.error : undefined;
137
+ if (errorCode === "authorization_pending") {
138
+ await sleepAsync(intervalSeconds * 1000);
139
+ continue;
140
+ }
141
+ if (errorCode === "slow_down") {
142
+ intervalSeconds += SLOW_DOWN_INCREMENT_SECONDS;
143
+ await sleepAsync(intervalSeconds * 1000);
144
+ continue;
145
+ }
146
+ if (errorCode === "expired_token") {
147
+ throw new Error("Device code expired before authorization completed. Run `auth` again.");
148
+ }
149
+ if (errorCode === "access_denied") {
150
+ throw new Error("Authorization was denied in the browser.");
151
+ }
152
+ if (tokenError.success) {
153
+ throw new Error(`Device token request failed: ${formatOauthError(tokenError.data.error, tokenError.data.error_description)}`);
154
+ }
155
+ throw new Error(`Device token request failed (HTTP ${tokenResponse.status}).`);
156
+ }
157
+ throw new Error("Device code expired before authorization completed. Run `auth` again.");
158
+ }
package/build/oauth.js CHANGED
@@ -10,6 +10,7 @@ import { promisify } from "util";
10
10
  import open from "open";
11
11
  import pkceChallenge from "pkce-challenge";
12
12
  import { createLogger } from "./utils/logger.js";
13
+ import { runDeviceAuthorizationGrantAsync } from "./oauth-device-flow.js";
13
14
  const logger = createLogger("gitlab-mcp-oauth");
14
15
  const execFileAsync = promisify(execFile);
15
16
  function escapeHtml(str) {
@@ -566,6 +567,25 @@ export class GitLabOAuth {
566
567
  }
567
568
  return !this.isTokenExpired(tokenData);
568
569
  }
570
+ /**
571
+ * Device Authorization Grant (GitLab 17.9+; 17.2–17.8 need oauth2_device_grant_flow).
572
+ * Does not open a browser.
573
+ * Stores the token with the same file format as the localhost callback flow.
574
+ */
575
+ async runDeviceFlowAsync(options) {
576
+ const tokenData = await runDeviceAuthorizationGrantAsync({
577
+ gitlabUrl: this.config.gitlabUrl,
578
+ clientId: this.config.clientId,
579
+ clientSecret: this.config.clientSecret,
580
+ scopes: this.config.scopes,
581
+ fetchImpl: options?.fetchImpl,
582
+ sleepAsync: options?.sleepAsync,
583
+ onUserCode: options?.onUserCode,
584
+ now: options?.now,
585
+ });
586
+ this.saveToken(tokenData);
587
+ return tokenData;
588
+ }
569
589
  }
570
590
  /**
571
591
  * Create and initialize a GitLabOAuth client.
package/build/schemas.js CHANGED
@@ -830,11 +830,33 @@ export const GitLabContentSchema = z.union([
830
830
  GitLabFileContentSchema,
831
831
  z.array(GitLabDirectoryContentSchema),
832
832
  ]);
833
+ function refineCommitFileAction(data, ctx) {
834
+ const action = data.action ?? "create";
835
+ if ((action === "create" || action === "update") && data.content === undefined) {
836
+ ctx.addIssue({
837
+ code: z.ZodIssueCode.custom,
838
+ message: `content is required when action is '${action}'`,
839
+ path: ["content"],
840
+ });
841
+ }
842
+ if (action === "move" && (data.previous_path === undefined || data.previous_path.length === 0)) {
843
+ ctx.addIssue({
844
+ code: z.ZodIssueCode.custom,
845
+ message: "previous_path is required when action is 'move'",
846
+ path: ["previous_path"],
847
+ });
848
+ }
849
+ }
833
850
  // Operation schemas
834
- export const FileOperationSchema = z.object({
851
+ export const FileOperationSchema = z
852
+ .object({
835
853
  path: z.string(),
836
- content: z.string(),
837
- });
854
+ content: z.string().optional(),
855
+ action: z.enum(["create", "update", "delete", "move"]).optional(),
856
+ encoding: z.enum(["text", "base64"]).optional(),
857
+ previous_path: z.string().optional(),
858
+ })
859
+ .superRefine(refineCommitFileAction);
838
860
  // Tree and commit schemas
839
861
  export const GitLabTreeItemSchema = z.object({
840
862
  id: z.string(),
@@ -1415,6 +1437,10 @@ export const CreateOrUpdateFileSchema = ProjectParamsSchema.extend({
1415
1437
  previous_path: z.string().optional().describe("Path of the file to move/rename"),
1416
1438
  last_commit_id: z.string().optional().describe("Last known file commit ID"),
1417
1439
  commit_id: z.string().optional().describe("Current file commit ID (for update operations)"),
1440
+ encoding: z
1441
+ .enum(["text", "base64"])
1442
+ .optional()
1443
+ .describe("Content encoding. Use 'base64' for binary files (content must already be base64-encoded). When omitted, GITLAB_REPO_FILE_ENCODING applies."),
1418
1444
  });
1419
1445
  export const SearchRepositoriesSchema = z
1420
1446
  .object({
@@ -1481,11 +1507,28 @@ export const GetFileContentsSchema = z
1481
1507
  export const PushFilesSchema = ProjectParamsSchema.extend({
1482
1508
  branch: z.string().describe("Branch to push to"),
1483
1509
  files: z
1484
- .array(z.object({
1485
- file_path: z.string().describe("Path where to create the file"),
1486
- content: z.string().describe("Content of the file"),
1487
- }))
1488
- .describe("Array of files to push"),
1510
+ .array(z
1511
+ .object({
1512
+ file_path: z.string().describe("Path of the file in the repo"),
1513
+ content: z
1514
+ .string()
1515
+ .optional()
1516
+ .describe("File content. Required for create and update. Omit for delete, or for a move that should keep the original content. Base64-encoded when encoding is 'base64'."),
1517
+ action: z
1518
+ .enum(["create", "update", "delete", "move"])
1519
+ .optional()
1520
+ .describe("Commit action for this file. Defaults to 'create'."),
1521
+ encoding: z
1522
+ .enum(["text", "base64"])
1523
+ .optional()
1524
+ .describe("Use 'base64' for binary files (content must already be base64-encoded). When omitted, GITLAB_REPO_FILE_ENCODING applies."),
1525
+ previous_path: z
1526
+ .string()
1527
+ .optional()
1528
+ .describe("Previous path of the file. Required when action is 'move'."),
1529
+ })
1530
+ .superRefine(refineCommitFileAction))
1531
+ .describe("Array of files to push. Each entry defaults to action 'create'. Per-file fields: action (create/update/delete/move), encoding (text/base64; omitted uses GITLAB_REPO_FILE_ENCODING), previous_path (required for move). Content is required for create and update; omit content for delete, or for a move that should keep the original file. GITLAB_PERMISSION_MODE=modify rejects delete and move."),
1489
1532
  commit_message: z.string().describe("Commit message"),
1490
1533
  });
1491
1534
  export const CreateIssueSchema = ProjectParamsSchema.extend({
@@ -3339,8 +3382,8 @@ export const GitLabArtifactEntrySchema = z.object({
3339
3382
  name: z.string(),
3340
3383
  path: z.string(),
3341
3384
  type: z.enum(["file", "directory"]),
3342
- size: z.coerce.number().optional(),
3343
- mode: z.string().optional(),
3385
+ size: z.coerce.number().nullish(),
3386
+ mode: z.string().nullish(),
3344
3387
  });
3345
3388
  export const DownloadJobArtifactsSchema = z.object({
3346
3389
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -281,7 +281,7 @@ function buildIndexPage(groupedToolsList) {
281
281
  "Permission modes control which tools are exposed:",
282
282
  "",
283
283
  "- `GITLAB_PERMISSION_MODE=readonly` — hides every write tool regardless of toggles.",
284
- "- `GITLAB_PERMISSION_MODE=modify` — allows create/update but blocks all `delete_*` tools.",
284
+ "- `GITLAB_PERMISSION_MODE=modify` — allows create/update but blocks all `delete_*` tools, plus `push_files` `delete`/`move` actions.",
285
285
  "- `GITLAB_READ_ONLY_MODE=true` (deprecated) — same as `readonly`; prefer `GITLAB_PERMISSION_MODE=readonly`.",
286
286
  "",
287
287
  "See [Environment Variables](../configuration/environment-variables.md)",
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { FileOperationSchema } from "../schemas.js";
4
+ import { fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "../utils/gitlab-commit-actions.js";
5
+ function firstAction(actions) {
6
+ const entry = actions[0];
7
+ if (!entry) {
8
+ throw new Error("expected at least one commit action");
9
+ }
10
+ return entry;
11
+ }
12
+ describe("When mapping file operations to GitLab commit actions", () => {
13
+ describe("with a plain text file and no per-file encoding", () => {
14
+ it("should default the action to create", () => {
15
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "text"));
16
+ assert.equal(entry.action, "create");
17
+ });
18
+ it("should keep the content unchanged when the global encoding is text", () => {
19
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "text"));
20
+ assert.equal(entry.content, "hi");
21
+ });
22
+ it("should set encoding to the global text value", () => {
23
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "text"));
24
+ assert.equal(entry.encoding, "text");
25
+ });
26
+ });
27
+ describe("with a plain text file and global base64 encoding", () => {
28
+ it("should base64-encode the content", () => {
29
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "base64"));
30
+ assert.equal(entry.content, Buffer.from("hi").toString("base64"));
31
+ });
32
+ it("should set encoding to base64", () => {
33
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "base64"));
34
+ assert.equal(entry.encoding, "base64");
35
+ });
36
+ });
37
+ describe("with an explicit per-file encoding", () => {
38
+ it("should pass text content through when encoding is text even if global is base64", () => {
39
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi", encoding: "text" }], "base64"));
40
+ assert.equal(entry.content, "hi");
41
+ });
42
+ it("should keep the explicit text encoding on the payload", () => {
43
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi", encoding: "text" }], "base64"));
44
+ assert.equal(entry.encoding, "text");
45
+ });
46
+ it("should pass base64 content through without encoding it again", () => {
47
+ const entry = firstAction(toGitLabCommitActions([{ path: "logo.png", content: "aGk=", encoding: "base64" }], "text"));
48
+ assert.equal(entry.content, "aGk=");
49
+ });
50
+ });
51
+ describe("with action delete", () => {
52
+ it("should omit content from the payload", () => {
53
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", action: "delete" }], "text"));
54
+ assert.equal("content" in entry, false);
55
+ });
56
+ it("should omit encoding from the payload", () => {
57
+ const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", action: "delete" }], "text"));
58
+ assert.equal("encoding" in entry, false);
59
+ });
60
+ });
61
+ describe("with action move", () => {
62
+ it("should omit content when the move has no new content", () => {
63
+ const entry = firstAction(toGitLabCommitActions([{ path: "b.txt", action: "move", previous_path: "a.txt" }], "text"));
64
+ assert.equal("content" in entry, false);
65
+ });
66
+ it("should include previous_path", () => {
67
+ const entry = firstAction(toGitLabCommitActions([{ path: "b.txt", action: "move", previous_path: "a.txt" }], "text"));
68
+ assert.equal(entry.previous_path, "a.txt");
69
+ });
70
+ it("should include content when a move supplies replacement content", () => {
71
+ const entry = firstAction(toGitLabCommitActions([{ path: "b.txt", action: "move", previous_path: "a.txt", content: "new" }], "text"));
72
+ assert.equal(entry.content, "new");
73
+ });
74
+ });
75
+ describe("with create or update missing content", () => {
76
+ it("should reject a create without content before sending an empty file", () => {
77
+ assert.throws(() => toGitLabCommitActions([{ path: "a.txt" }], "text"), /content is required when action is 'create'/);
78
+ });
79
+ it("should reject the FileOperation schema for create without content", () => {
80
+ const parsed = FileOperationSchema.safeParse({ path: "a.txt" });
81
+ assert.equal(parsed.success, false);
82
+ });
83
+ });
84
+ });
85
+ describe("When checking push_files actions against modify mode", () => {
86
+ describe("with delete or move entries", () => {
87
+ it("should treat delete as blocked", () => {
88
+ assert.equal(fileOperationsIncludeDeleteOrMove([{ action: "delete" }]), true);
89
+ });
90
+ it("should treat move as blocked", () => {
91
+ assert.equal(fileOperationsIncludeDeleteOrMove([{ action: "move" }]), true);
92
+ });
93
+ });
94
+ describe("with create or update entries", () => {
95
+ it("should allow omitted action", () => {
96
+ assert.equal(fileOperationsIncludeDeleteOrMove([{}]), false);
97
+ });
98
+ it("should allow update", () => {
99
+ assert.equal(fileOperationsIncludeDeleteOrMove([{ action: "update" }]), false);
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { GitLabArtifactEntrySchema } from "../schemas.js";
4
+ function validEntry(overrides = {}) {
5
+ return {
6
+ name: "report.xml",
7
+ path: "report.xml",
8
+ type: "file",
9
+ ...overrides,
10
+ };
11
+ }
12
+ describe("When parsing an artifact tree entry", () => {
13
+ describe("with a string mode", () => {
14
+ it("should keep the mode", () => {
15
+ const parsed = GitLabArtifactEntrySchema.parse(validEntry({ mode: "100644", size: 1024 }));
16
+ assert.strictEqual(parsed.mode, "100644");
17
+ assert.strictEqual(parsed.size, 1024);
18
+ });
19
+ });
20
+ describe("with mode set to null", () => {
21
+ it("should accept null", () => {
22
+ const parsed = GitLabArtifactEntrySchema.parse(validEntry({ mode: null }));
23
+ assert.strictEqual(parsed.mode, null);
24
+ });
25
+ });
26
+ describe("with size set to null", () => {
27
+ it("should accept null", () => {
28
+ const parsed = GitLabArtifactEntrySchema.parse(validEntry({ size: null }));
29
+ assert.strictEqual(parsed.size, null);
30
+ });
31
+ });
32
+ describe("with mode and size omitted", () => {
33
+ it("should leave them undefined", () => {
34
+ const parsed = GitLabArtifactEntrySchema.parse(validEntry());
35
+ assert.strictEqual(parsed.mode, undefined);
36
+ assert.strictEqual(parsed.size, undefined);
37
+ });
38
+ });
39
+ describe("with an unknown type", () => {
40
+ it("should reject the entry", () => {
41
+ assert.throws(() => GitLabArtifactEntrySchema.parse(validEntry({ type: "symlink" })));
42
+ });
43
+ });
44
+ });