@zereight/mcp-gitlab 2.1.50 → 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 +4 -1
- package/README.md +5 -1
- package/README.zh-CN.md +5 -1
- 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 +53 -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/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 +2 -2
- 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
|
|
|
@@ -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
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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(),
|
|
@@ -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
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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().
|
|
3343
|
-
mode: z.string().
|
|
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)",
|