@zereight/mcp-gitlab 2.1.49 → 2.1.51
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 -2
- package/README.md +7 -3
- package/README.zh-CN.md +6 -2
- package/build/auth-cli.js +95 -0
- package/build/cli-command.js +33 -0
- package/build/index.js +40 -20
- package/build/oauth-device-flow.js +158 -0
- package/build/oauth.js +20 -0
- package/build/schemas.js +58 -10
- package/build/scripts/generate-tool-docs.js +1 -1
- package/build/test/create-commit-actions.test.js +102 -0
- package/build/test/gitlab-artifact-entry-schema.test.js +44 -0
- package/build/test/merge-merge-request-schema.test.js +75 -0
- package/build/test/oauth-device-flow-tests.js +440 -0
- package/build/test/schema-tests.js +117 -3
- package/build/test/test-job-artifacts.js +30 -0
- package/build/test/test-permission-mode.js +28 -0
- package/build/tools/tool-descriptions.js +3 -3
- package/build/utils/gitlab-commit-actions.js +42 -0
- package/package.json +3 -3
package/README.ko.md
CHANGED
|
@@ -62,6 +62,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
|
|
|
62
62
|
- **Cursor**: [Cursor 설정 가이드](./docs/clients/cursor.md)
|
|
63
63
|
- **Factory AI Droid / OpenClaw / OpenCode 스타일 클라이언트**: [JSON 기반 MCP 클라이언트 설정 가이드](./docs/clients/json-clients.md)
|
|
64
64
|
- **OAuth 브라우저 플로우 상세**: [OAuth2 인증 설정 가이드](./docs/auth/oauth-setup.md)
|
|
65
|
+
- **localhost callback 없이 OAuth** (SSO, 원격 셸, 백그라운드 클라이언트): `zereight-mcp-gitlab auth`를 먼저 실행하세요 (GitLab 17.9+ device flow; 17.2–17.8은 `oauth2_device_grant_flow` 필요). 그다음 서버는 `GITLAB_USE_OAUTH=true`로 시작합니다. [독립 device-flow 커맨드](./docs/auth/oauth-setup.md#standalone-device-flow-auth-command)를 참고하세요.
|
|
65
66
|
|
|
66
67
|
가장 단순한 로컬 설정은 Personal Access Token으로 시작하세요. 브라우저 기반 로컬 인증은 OAuth2를 사용하세요. 원격 또는 멀티 유저 배포는 아래 MCP OAuth 및 원격 인증 섹션을 참고하세요.
|
|
67
68
|
|
|
@@ -80,7 +81,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
80
81
|
|
|
81
82
|
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
82
83
|
|
|
83
|
-
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.
|
|
84
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.49`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
84
85
|
|
|
85
86
|
#### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
|
|
86
87
|
|
|
@@ -111,8 +112,10 @@ npm install -g @zereight/mcp-gitlab
|
|
|
111
112
|
|
|
112
113
|
CLI 인자는 환경 변수보다 우선합니다.
|
|
113
114
|
|
|
115
|
+
`zereight-mcp-gitlab auth`는 MCP 서버 플래그가 아니라 서브커맨드입니다. GitLab device flow를 실행한 뒤 종료합니다. [CLI 인자](./docs/getting-started/cli-arguments.md#auth)를 참고하세요.
|
|
116
|
+
|
|
114
117
|
> **세밀한 도구 필터링:** `GITLAB_PERMISSION_MODE=modify`로 생성/수정은 허용하고 모든 삭제 도구를
|
|
115
|
-
>
|
|
118
|
+
> 차단하거나(`execute_graphql` 삭제 mutation과 `push_files`의 `delete`/`move` 포함), `GITLAB_PERMISSION_MODE=readonly`로 읽기 전용으로 운영할 수 있습니다. 또한
|
|
116
119
|
> `GITLAB_TOOLSETS=<group,…>`로 도구 그룹을 활성화하고, `GITLAB_TOOLS=<tool,…>`로 개별 도구만
|
|
117
120
|
> 허용하며(예: 읽기 도구 + 특정 쓰기 도구 몇 개), `GITLAB_DENIED_TOOLS_REGEX`로 패턴 차단할 수
|
|
118
121
|
> 있습니다. 레거시 `USE_GITLAB_WIKI` / `USE_MILESTONE` / `USE_PIPELINE` 플래그는 하위 호환용으로만
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GitLab MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://mcptoplist.com/server/io.github.zereight%2Fgitlab-mcp)
|
|
3
|
+
[](https://mcptoplist.com/server/io.github.zereight%2Fgitlab-mcp) [](https://mcpindex.ai/server/io-github-zereight-gitlab-mcp)
|
|
4
4
|
|
|
5
5
|
[English](./README.md) | [한국어](./README.ko.md) | [简体中文](./README.zh-CN.md)
|
|
6
6
|
|
|
@@ -64,6 +64,7 @@ The server supports four authentication methods:
|
|
|
64
64
|
- **Cursor**: see [Cursor Setup Guide](./docs/clients/cursor.md)
|
|
65
65
|
- **Factory AI Droid / OpenClaw / OpenCode style clients**: see [JSON-Based MCP Clients Setup Guide](./docs/clients/json-clients.md)
|
|
66
66
|
- **OAuth browser flow details**: see [OAuth2 Authentication Setup Guide](./docs/auth/oauth-setup.md)
|
|
67
|
+
- **OAuth without a localhost callback** (SSO, remote shell, background clients): run `zereight-mcp-gitlab auth` (GitLab 17.9+ device flow; 17.2–17.8 need `oauth2_device_grant_flow`), then start the server with `GITLAB_USE_OAUTH=true`. See [standalone device-flow command](./docs/auth/oauth-setup.md#standalone-device-flow-auth-command).
|
|
67
68
|
|
|
68
69
|
For the simplest local setup, start with a Personal Access Token. For browser-based local auth, use OAuth2. For remote or multi-user deployments, continue to the MCP OAuth and Remote Authorization sections later in this README.
|
|
69
70
|
|
|
@@ -82,7 +83,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
82
83
|
|
|
83
84
|
The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
|
|
84
85
|
|
|
85
|
-
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.
|
|
86
|
+
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.49`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
86
87
|
|
|
87
88
|
#### Using CLI Arguments (for clients with env var issues)
|
|
88
89
|
|
|
@@ -113,8 +114,11 @@ Some MCP clients (like GitHub Copilot CLI) have issues with environment variable
|
|
|
113
114
|
|
|
114
115
|
CLI arguments take precedence over environment variables.
|
|
115
116
|
|
|
117
|
+
`zereight-mcp-gitlab auth` is a subcommand (not an MCP server flag). It runs GitLab device flow and exits. See [CLI Arguments](./docs/getting-started/cli-arguments.md#auth).
|
|
118
|
+
|
|
116
119
|
> **Fine-grained tool filtering:** use `GITLAB_PERMISSION_MODE=modify` to allow create/update while
|
|
117
|
-
> blocking every delete tool (including delete mutations through `execute_graphql`
|
|
120
|
+
> blocking every delete tool (including delete mutations through `execute_graphql` and
|
|
121
|
+
> `push_files` `delete`/`move` actions), or
|
|
118
122
|
> `GITLAB_PERMISSION_MODE=readonly` for read-only access. You can also
|
|
119
123
|
> enable toolset groups with `GITLAB_TOOLSETS=<group,…>`, allow-list individual tools with
|
|
120
124
|
> `GITLAB_TOOLS=<tool,…>` (e.g. read-only groups plus a few specific write tools), and
|
package/README.zh-CN.md
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
- **Cursor**:[Cursor 设置指南](./docs/clients/cursor.md)
|
|
63
63
|
- **Factory AI Droid / OpenClaw / OpenCode 风格客户端**:[基于 JSON 的 MCP 客户端设置指南](./docs/clients/json-clients.md)
|
|
64
64
|
- **OAuth 浏览器流程详情**:[OAuth2 认证设置指南](./docs/auth/oauth-setup.md)
|
|
65
|
+
- **无需 localhost callback 的 OAuth**(SSO、远程 shell、后台客户端):先运行 `zereight-mcp-gitlab auth`(GitLab 17.9+ device flow;17.2–17.8 需 `oauth2_device_grant_flow`),再以 `GITLAB_USE_OAUTH=true` 启动服务器。参见[独立 device-flow 命令](./docs/auth/oauth-setup.md#standalone-device-flow-auth-command)。
|
|
65
66
|
|
|
66
67
|
最简单的本地设置可以从 Personal Access Token 开始。基于浏览器的本地认证使用 OAuth2。远程或多用户部署请继续查看下面的 MCP OAuth 和远程授权部分。
|
|
67
68
|
|
|
@@ -80,7 +81,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
80
81
|
|
|
81
82
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
82
83
|
|
|
83
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.
|
|
84
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.49`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
84
85
|
|
|
85
86
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
86
87
|
|
|
@@ -111,7 +112,10 @@ npm install -g @zereight/mcp-gitlab
|
|
|
111
112
|
|
|
112
113
|
CLI 参数优先于环境变量。
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
`zereight-mcp-gitlab auth` 是子命令,不是 MCP 服务器参数。它运行 GitLab device flow 后退出。参见 [CLI 参数](./docs/getting-started/cli-arguments.md#auth)。
|
|
116
|
+
|
|
117
|
+
> **细粒度工具过滤:**使用 `GITLAB_PERMISSION_MODE=modify` 允许创建/更新并阻止所有删除工具
|
|
118
|
+
> (包括通过 `execute_graphql` 的删除 mutation 以及 `push_files` 的 `delete`/`move`),
|
|
115
119
|
> 或使用 `GITLAB_PERMISSION_MODE=readonly` 只读运行。还可以用
|
|
116
120
|
> `GITLAB_TOOLSETS=<group,…>` 启用工具分组,用 `GITLAB_TOOLS=<tool,…>` 白名单启用单个工具
|
|
117
121
|
> (例如:只读分组 + 少数几个写工具),用 `GITLAB_DENIED_TOOLS_REGEX` 按正则屏蔽工具。
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as os from "os";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { GitLabOAuth } from "./oauth.js";
|
|
4
|
+
import { createLogger } from "./utils/logger.js";
|
|
5
|
+
const logger = createLogger("gitlab-mcp-auth");
|
|
6
|
+
const DEFAULT_API_URL = "https://gitlab.com";
|
|
7
|
+
const DEFAULT_REDIRECT_URI = "http://127.0.0.1:8888/callback";
|
|
8
|
+
export const AUTH_CLI_HELP = `Usage: zereight-mcp-gitlab auth [options]
|
|
9
|
+
|
|
10
|
+
Run GitLab OAuth Device Authorization Grant (GitLab 17.9+; 17.2–17.8 need
|
|
11
|
+
oauth2_device_grant_flow) and store a token at the same path used by
|
|
12
|
+
GITLAB_USE_OAUTH (default ~/.gitlab-mcp-token.json).
|
|
13
|
+
|
|
14
|
+
This does not replace the local browser callback flow. After auth succeeds,
|
|
15
|
+
start the MCP server with GITLAB_USE_OAUTH=true and GITLAB_OAUTH_CLIENT_ID.
|
|
16
|
+
If you used --token-path, set GITLAB_OAUTH_TOKEN_PATH to the same path.
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--client-id <id> OAuth application ID (or GITLAB_OAUTH_CLIENT_ID)
|
|
20
|
+
--api-url <url> GitLab API URL (or GITLAB_API_URL). Default: https://gitlab.com
|
|
21
|
+
--token-path <path> Token file path (or GITLAB_OAUTH_TOKEN_PATH)
|
|
22
|
+
-h, --help Show this help
|
|
23
|
+
`;
|
|
24
|
+
function readFlag(argv, name) {
|
|
25
|
+
const equalsPrefix = `--${name}=`;
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
if (arg.startsWith(equalsPrefix)) {
|
|
29
|
+
const value = arg.slice(equalsPrefix.length);
|
|
30
|
+
return value === "" ? undefined : value;
|
|
31
|
+
}
|
|
32
|
+
if (arg === `--${name}`) {
|
|
33
|
+
const next = argv[i + 1];
|
|
34
|
+
if (next && !next.startsWith("-")) {
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
function wantsHelp(argv) {
|
|
43
|
+
return argv.includes("--help") || argv.includes("-h");
|
|
44
|
+
}
|
|
45
|
+
function isReadOnlyMode(argv, env) {
|
|
46
|
+
const readOnly = readFlag(argv, "read-only") ?? env.GITLAB_READ_ONLY_MODE;
|
|
47
|
+
if (readOnly === "true") {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return (readFlag(argv, "permission-mode") ?? env.GITLAB_PERMISSION_MODE) === "readonly";
|
|
51
|
+
}
|
|
52
|
+
export function gitlabOriginFromApiUrl(apiUrl) {
|
|
53
|
+
return apiUrl.replace(/\/api\/v4\/?$/, "");
|
|
54
|
+
}
|
|
55
|
+
export async function runAuthCommandAsync(input = {}) {
|
|
56
|
+
const argv = input.argv ?? process.argv;
|
|
57
|
+
const env = input.env ?? process.env;
|
|
58
|
+
const stdout = input.stdout ?? process.stdout;
|
|
59
|
+
if (wantsHelp(argv)) {
|
|
60
|
+
stdout.write(AUTH_CLI_HELP);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const clientId = readFlag(argv, "client-id") ?? env.GITLAB_OAUTH_CLIENT_ID;
|
|
64
|
+
if (!clientId) {
|
|
65
|
+
throw new Error("Missing OAuth client ID. Pass --client-id or set GITLAB_OAUTH_CLIENT_ID.");
|
|
66
|
+
}
|
|
67
|
+
const apiUrl = readFlag(argv, "api-url") ?? env.GITLAB_API_URL ?? DEFAULT_API_URL;
|
|
68
|
+
const tokenPath = readFlag(argv, "token-path") ??
|
|
69
|
+
env.GITLAB_OAUTH_TOKEN_PATH ??
|
|
70
|
+
path.join(os.homedir(), ".gitlab-mcp-token.json");
|
|
71
|
+
const gitlabUrl = gitlabOriginFromApiUrl(apiUrl);
|
|
72
|
+
const scopes = [isReadOnlyMode(argv, env) ? "read_api" : "api"];
|
|
73
|
+
const oauth = new GitLabOAuth({
|
|
74
|
+
clientId,
|
|
75
|
+
clientSecret: env.GITLAB_OAUTH_CLIENT_SECRET,
|
|
76
|
+
redirectUri: env.GITLAB_OAUTH_REDIRECT_URI || DEFAULT_REDIRECT_URI,
|
|
77
|
+
gitlabUrl,
|
|
78
|
+
scopes,
|
|
79
|
+
tokenStoragePath: tokenPath,
|
|
80
|
+
});
|
|
81
|
+
logger.info("Starting GitLab device authorization (no browser will be opened)");
|
|
82
|
+
await oauth.runDeviceFlowAsync({
|
|
83
|
+
fetchImpl: input.fetchImpl,
|
|
84
|
+
sleepAsync: input.sleepAsync,
|
|
85
|
+
onUserCode: info => {
|
|
86
|
+
const visitUrl = info.verificationUriComplete ?? info.verificationUri;
|
|
87
|
+
stdout.write(`Visit: ${visitUrl}\n`);
|
|
88
|
+
stdout.write(`Code: ${info.userCode}\n`);
|
|
89
|
+
stdout.write("Waiting for authorization...\n");
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
stdout.write(`Token saved to ${tokenPath}\n`);
|
|
93
|
+
stdout.write("Start the MCP server with GITLAB_USE_OAUTH=true and the same GITLAB_OAUTH_CLIENT_ID.\n" +
|
|
94
|
+
"If you used --token-path, set GITLAB_OAUTH_TOKEN_PATH to that path as well.\n");
|
|
95
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const SCRIPT_PATH_PATTERN = /\.(cjs|mjs|js|cts|mts|ts)$/;
|
|
2
|
+
const FLAGS_WITHOUT_VALUE = new Set(["--help", "-h"]);
|
|
3
|
+
function isScriptPath(arg) {
|
|
4
|
+
return SCRIPT_PATH_PATTERN.test(arg);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read the first positional CLI command (not a flag, not a script path).
|
|
8
|
+
* `tsx index.ts auth` and `node build/index.js auth` both resolve to `auth`.
|
|
9
|
+
* Space-separated option values such as `--api-url https://...` are skipped.
|
|
10
|
+
* Flags such as --token stay on the MCP server path.
|
|
11
|
+
*/
|
|
12
|
+
export function getPositionalCliCommand(argv) {
|
|
13
|
+
const args = argv.slice(2);
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const arg = args[i];
|
|
16
|
+
if (arg.startsWith("--") && arg.includes("=")) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (arg.startsWith("-")) {
|
|
20
|
+
if (!FLAGS_WITHOUT_VALUE.has(arg) &&
|
|
21
|
+
i + 1 < args.length &&
|
|
22
|
+
!args[i + 1].startsWith("-")) {
|
|
23
|
+
i += 1;
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (isScriptPath(arg)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
return arg;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
package/build/index.js
CHANGED
|
@@ -75,6 +75,8 @@ import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
|
75
75
|
import { URL } from "node:url";
|
|
76
76
|
import { z } from "zod";
|
|
77
77
|
import { initializeOAuthClient } from "./oauth.js";
|
|
78
|
+
import { getPositionalCliCommand } from "./cli-command.js";
|
|
79
|
+
import { runAuthCommandAsync } from "./auth-cli.js";
|
|
78
80
|
import { createGitLabOAuthProvider } from "./oauth-proxy.js";
|
|
79
81
|
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
80
82
|
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
|
@@ -91,6 +93,7 @@ import { normalizeGitLabApiUrl } from "./utils/url.js";
|
|
|
91
93
|
import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
|
|
92
94
|
import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
|
|
93
95
|
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
96
|
+
import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
|
|
94
97
|
import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
|
|
95
98
|
import { checkForNewVersion } from "./utils/version-check.js";
|
|
96
99
|
import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
|
|
@@ -1100,7 +1103,8 @@ if (GITLAB_MCP_OAUTH) {
|
|
|
1100
1103
|
}
|
|
1101
1104
|
logger.info("MCP OAuth enabled: GitLab OAuth proxy active (Private-Token/JOB-TOKEN headers bypass OAuth)");
|
|
1102
1105
|
}
|
|
1103
|
-
if (
|
|
1106
|
+
if (getPositionalCliCommand(process.argv) !== "auth" &&
|
|
1107
|
+
!REMOTE_AUTHORIZATION &&
|
|
1104
1108
|
!GITLAB_MCP_OAUTH &&
|
|
1105
1109
|
!USE_OAUTH &&
|
|
1106
1110
|
!GITLAB_PERSONAL_ACCESS_TOKEN &&
|
|
@@ -3291,12 +3295,6 @@ async function updateMergeRequestNote(projectId, mergeRequestIid, noteId, body)
|
|
|
3291
3295
|
const data = await response.json();
|
|
3292
3296
|
return GitLabDiscussionNoteSchema.parse(data);
|
|
3293
3297
|
}
|
|
3294
|
-
function encodeRepoFilePayloadContent(content) {
|
|
3295
|
-
if (GITLAB_REPO_FILE_ENCODING === "base64") {
|
|
3296
|
-
return Buffer.from(content).toString("base64");
|
|
3297
|
-
}
|
|
3298
|
-
return content;
|
|
3299
|
-
}
|
|
3300
3298
|
/**
|
|
3301
3299
|
* Create or update a file in a GitLab project
|
|
3302
3300
|
* 파일 생성 또는 업데이트
|
|
@@ -3309,15 +3307,18 @@ function encodeRepoFilePayloadContent(content) {
|
|
|
3309
3307
|
* @param {string} [previousPath] - The previous path of the file in case of rename
|
|
3310
3308
|
* @returns {Promise<GitLabCreateUpdateFileResponse>} The file update response
|
|
3311
3309
|
*/
|
|
3312
|
-
async function createOrUpdateFile(projectId, filePath, content, commitMessage, branch, previousPath, last_commit_id, commit_id) {
|
|
3310
|
+
async function createOrUpdateFile(projectId, filePath, content, commitMessage, branch, previousPath, last_commit_id, commit_id, encoding) {
|
|
3313
3311
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
3314
3312
|
const encodedPath = encodeURIComponent(filePath);
|
|
3315
3313
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}`);
|
|
3314
|
+
const resolvedEncoding = encoding ?? GITLAB_REPO_FILE_ENCODING;
|
|
3316
3315
|
const body = {
|
|
3317
3316
|
branch,
|
|
3318
|
-
content:
|
|
3317
|
+
content: encoding !== undefined
|
|
3318
|
+
? content
|
|
3319
|
+
: encodeRepoFilePayloadContent(content, GITLAB_REPO_FILE_ENCODING),
|
|
3319
3320
|
commit_message: commitMessage,
|
|
3320
|
-
encoding:
|
|
3321
|
+
encoding: resolvedEncoding,
|
|
3321
3322
|
...(previousPath ? { previous_path: previousPath } : {}),
|
|
3322
3323
|
};
|
|
3323
3324
|
// Check if file exists
|
|
@@ -3387,12 +3388,7 @@ async function createCommit(projectId, message, branch, actions) {
|
|
|
3387
3388
|
body: JSON.stringify({
|
|
3388
3389
|
branch,
|
|
3389
3390
|
commit_message: message,
|
|
3390
|
-
actions: actions
|
|
3391
|
-
action: "create",
|
|
3392
|
-
file_path: action.path,
|
|
3393
|
-
content: encodeRepoFilePayloadContent(action.content),
|
|
3394
|
-
encoding: GITLAB_REPO_FILE_ENCODING,
|
|
3395
|
-
})),
|
|
3391
|
+
actions: toGitLabCommitActions(actions, GITLAB_REPO_FILE_ENCODING),
|
|
3396
3392
|
}),
|
|
3397
3393
|
});
|
|
3398
3394
|
if (response.status === 400) {
|
|
@@ -7280,14 +7276,24 @@ async function handleToolCall(params) {
|
|
|
7280
7276
|
}
|
|
7281
7277
|
case "create_or_update_file": {
|
|
7282
7278
|
const args = CreateOrUpdateFileSchema.parse(params.arguments);
|
|
7283
|
-
const result = await createOrUpdateFile(args.project_id, args.file_path, args.content, args.commit_message, args.branch, args.previous_path, args.last_commit_id, args.commit_id);
|
|
7279
|
+
const result = await createOrUpdateFile(args.project_id, args.file_path, args.content, args.commit_message, args.branch, args.previous_path, args.last_commit_id, args.commit_id, args.encoding);
|
|
7284
7280
|
return {
|
|
7285
7281
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
7286
7282
|
};
|
|
7287
7283
|
}
|
|
7288
7284
|
case "push_files": {
|
|
7289
7285
|
const args = PushFilesSchema.parse(params.arguments);
|
|
7290
|
-
|
|
7286
|
+
if (GITLAB_PERMISSION_MODE === "modify" &&
|
|
7287
|
+
fileOperationsIncludeDeleteOrMove(args.files)) {
|
|
7288
|
+
throw new Error("push_files does not allow delete or move actions in modify mode");
|
|
7289
|
+
}
|
|
7290
|
+
const result = await createCommit(args.project_id, args.commit_message, args.branch, args.files.map(f => ({
|
|
7291
|
+
path: f.file_path,
|
|
7292
|
+
content: f.content,
|
|
7293
|
+
action: f.action,
|
|
7294
|
+
encoding: f.encoding,
|
|
7295
|
+
previous_path: f.previous_path,
|
|
7296
|
+
})));
|
|
7291
7297
|
return {
|
|
7292
7298
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
7293
7299
|
};
|
|
@@ -10795,8 +10801,22 @@ async function runServer() {
|
|
|
10795
10801
|
process.exit(1);
|
|
10796
10802
|
}
|
|
10797
10803
|
}
|
|
10798
|
-
|
|
10799
|
-
|
|
10804
|
+
async function main() {
|
|
10805
|
+
if (getPositionalCliCommand(process.argv) === "auth") {
|
|
10806
|
+
try {
|
|
10807
|
+
await runAuthCommandAsync();
|
|
10808
|
+
process.exit(0);
|
|
10809
|
+
}
|
|
10810
|
+
catch (error) {
|
|
10811
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
10812
|
+
process.stderr.write(`${message}\n`);
|
|
10813
|
+
logger.error({ err: error }, "auth command failed");
|
|
10814
|
+
process.exit(1);
|
|
10815
|
+
}
|
|
10816
|
+
}
|
|
10817
|
+
await runServer();
|
|
10818
|
+
}
|
|
10819
|
+
main().catch(error => {
|
|
10800
10820
|
logger.fatal({ err: error }, "Fatal error in main()");
|
|
10801
10821
|
process.exit(1);
|
|
10802
10822
|
});
|
|
@@ -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
|
|
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(),
|
|
@@ -1160,6 +1182,7 @@ export const GitLabMergeRequestSchema = z.object({
|
|
|
1160
1182
|
updated_at: z.string(),
|
|
1161
1183
|
merged_at: z.string().nullable(),
|
|
1162
1184
|
closed_at: z.string().nullable(),
|
|
1185
|
+
sha: z.string().optional().describe("SHA of the head commit in the source branch"),
|
|
1163
1186
|
merge_commit_sha: z.string().nullable(),
|
|
1164
1187
|
merge_user: GitLabUserSchema.nullable()
|
|
1165
1188
|
.optional()
|
|
@@ -1414,6 +1437,10 @@ export const CreateOrUpdateFileSchema = ProjectParamsSchema.extend({
|
|
|
1414
1437
|
previous_path: z.string().optional().describe("Path of the file to move/rename"),
|
|
1415
1438
|
last_commit_id: z.string().optional().describe("Last known file commit ID"),
|
|
1416
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."),
|
|
1417
1444
|
});
|
|
1418
1445
|
export const SearchRepositoriesSchema = z
|
|
1419
1446
|
.object({
|
|
@@ -1480,11 +1507,28 @@ export const GetFileContentsSchema = z
|
|
|
1480
1507
|
export const PushFilesSchema = ProjectParamsSchema.extend({
|
|
1481
1508
|
branch: z.string().describe("Branch to push to"),
|
|
1482
1509
|
files: z
|
|
1483
|
-
.array(z
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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."),
|
|
1488
1532
|
commit_message: z.string().describe("Commit message"),
|
|
1489
1533
|
});
|
|
1490
1534
|
export const CreateIssueSchema = ProjectParamsSchema.extend({
|
|
@@ -1773,6 +1817,10 @@ export const UpdateMergeRequestSchema = MergeRequestParamsSchema.extend({
|
|
|
1773
1817
|
});
|
|
1774
1818
|
export const MergeMergeRequestSchema = ProjectParamsSchema.extend({
|
|
1775
1819
|
merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
|
|
1820
|
+
sha: z
|
|
1821
|
+
.string()
|
|
1822
|
+
.optional()
|
|
1823
|
+
.describe("SHA of the source-branch HEAD from get_merge_request (`sha` or `diff_refs.head_sha`). If provided, GitLab merges only when HEAD still matches. GitLab 19.2+ groups may require this (Require a commit SHA on the merge requests API)."),
|
|
1776
1824
|
auto_merge: z.coerce
|
|
1777
1825
|
.boolean()
|
|
1778
1826
|
.optional()
|
|
@@ -3334,8 +3382,8 @@ export const GitLabArtifactEntrySchema = z.object({
|
|
|
3334
3382
|
name: z.string(),
|
|
3335
3383
|
path: z.string(),
|
|
3336
3384
|
type: z.enum(["file", "directory"]),
|
|
3337
|
-
size: z.coerce.number().
|
|
3338
|
-
mode: z.string().
|
|
3385
|
+
size: z.coerce.number().nullish(),
|
|
3386
|
+
mode: z.string().nullish(),
|
|
3339
3387
|
});
|
|
3340
3388
|
export const DownloadJobArtifactsSchema = z.object({
|
|
3341
3389
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|