@zereight/mcp-gitlab 2.3.8-next.149 → 2.3.8
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 +1 -1
- package/README.md +1 -1
- package/README.pt-BR.md +1 -1
- package/README.zh-CN.md +1 -1
- package/build/downloads/proxy.js +17 -3
- package/build/index.js +15 -10
- package/build/utils/download-token.js +12 -1
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -252,7 +252,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
|
|
|
252
252
|
|
|
253
253
|
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
254
254
|
|
|
255
|
-
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.3.
|
|
255
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.3.7`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. `main`에 머지된 수정을 다음 안정 버전 전에 확인하려면 프리릴리스 빌드인 `npx -y @zereight/mcp-gitlab@next`를 사용하세요(운영 환경용이 아닙니다). 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
256
256
|
|
|
257
257
|
#### Claude Desktop 원클릭 설치(MCP Bundle)
|
|
258
258
|
|
package/README.md
CHANGED
|
@@ -258,7 +258,7 @@ The store path is pinned by your lock file; update it with `nix flake update git
|
|
|
258
258
|
|
|
259
259
|
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`.
|
|
260
260
|
|
|
261
|
-
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.3.
|
|
261
|
+
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.3.7`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. To try a fix merged to `main` before the next stable release, use `npx -y @zereight/mcp-gitlab@next`, a pre-release build that is not meant for production. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
262
262
|
|
|
263
263
|
#### Claude Desktop one-click install (MCP Bundle)
|
|
264
264
|
|
package/README.pt-BR.md
CHANGED
|
@@ -252,7 +252,7 @@ O caminho no store é fixado pelo seu lock file; atualize-o com `nix flake updat
|
|
|
252
252
|
|
|
253
253
|
Os exemplos usam `zereight-mcp-gitlab`, um alias com menor chance de conflito que o binário legado `mcp-gitlab`. Se o seu cliente MCP não o encontrar, use o caminho absoluto retornado por `which zereight-mcp-gitlab`.
|
|
254
254
|
|
|
255
|
-
Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.3.
|
|
255
|
+
Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.3.7`. Se quiser sempre a versão mais recente, use `npx -y @zereight/mcp-gitlab@latest`. Para testar uma correção já mesclada na `main` antes da próxima versão estável, use `npx -y @zereight/mcp-gitlab@next`, um build de pré-lançamento que não é indicado para produção. O servidor exibe um aviso no stderr durante a inicialização quando existe uma versão mais nova disponível (desative com `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
256
256
|
|
|
257
257
|
#### Instalação com um clique no Claude Desktop (MCP Bundle)
|
|
258
258
|
|
package/README.zh-CN.md
CHANGED
|
@@ -252,7 +252,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
|
|
|
252
252
|
|
|
253
253
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
254
254
|
|
|
255
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.3.
|
|
255
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.3.7`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。如需在下一个稳定版本发布前试用已合并到 `main` 的修复,请使用 `npx -y @zereight/mcp-gitlab@next`(预发布版本,不适用于生产环境)。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
256
256
|
|
|
257
257
|
#### Claude Desktop 一键安装(MCP Bundle)
|
|
258
258
|
|
package/build/downloads/proxy.js
CHANGED
|
@@ -11,6 +11,18 @@ function canonicalizeQueryParams(params) {
|
|
|
11
11
|
}
|
|
12
12
|
return JSON.stringify(normalized);
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Check the requested project against the env allowlist (getEffectiveProjectId) and
|
|
16
|
+
* against the allowlist sealed into the download token, since the proxy runs
|
|
17
|
+
* outside the session that issued it.
|
|
18
|
+
*/
|
|
19
|
+
function resolveDownloadProjectId(deps, projectId, sealedProjectIds) {
|
|
20
|
+
const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(projectId));
|
|
21
|
+
if (sealedProjectIds.length > 0 && !sealedProjectIds.includes(effectiveProjectId)) {
|
|
22
|
+
throw new Error(`Access denied: Project ${effectiveProjectId} is outside the project scope of this download token`);
|
|
23
|
+
}
|
|
24
|
+
return effectiveProjectId;
|
|
25
|
+
}
|
|
14
26
|
/**
|
|
15
27
|
* Register the /downloads/:type proxy endpoint on an Express app.
|
|
16
28
|
* Streams GitLab API responses directly to the client. Auth is read from
|
|
@@ -46,6 +58,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
46
58
|
// Try embedded encrypted token first (self-contained URL), then headers
|
|
47
59
|
const encryptedToken = req.query._token;
|
|
48
60
|
let tokenApiUrl;
|
|
61
|
+
let sealedProjectIds = [];
|
|
49
62
|
if (encryptedToken) {
|
|
50
63
|
const decrypted = decryptDownloadToken(encryptedToken);
|
|
51
64
|
if (!decrypted) {
|
|
@@ -70,6 +83,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
70
83
|
headers[decrypted.header] = decrypted.token;
|
|
71
84
|
rateLimitKey = decrypted.token;
|
|
72
85
|
tokenApiUrl = decrypted.apiUrl;
|
|
86
|
+
sealedProjectIds = decrypted.allowedProjectIds;
|
|
73
87
|
}
|
|
74
88
|
else {
|
|
75
89
|
const privateToken = req.headers["private-token"];
|
|
@@ -119,7 +133,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
119
133
|
res.status(400).json({ error: "project_id and job_id are required" });
|
|
120
134
|
return;
|
|
121
135
|
}
|
|
122
|
-
const effectiveProjectId = deps
|
|
136
|
+
const effectiveProjectId = resolveDownloadProjectId(deps, project_id, sealedProjectIds);
|
|
123
137
|
gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/jobs/${deps.encodeGitLabPathSegment(job_id)}/artifacts`;
|
|
124
138
|
break;
|
|
125
139
|
}
|
|
@@ -129,7 +143,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
129
143
|
res.status(400).json({ error: "project_id, secret, and filename are required" });
|
|
130
144
|
return;
|
|
131
145
|
}
|
|
132
|
-
const effectiveProjectId = deps
|
|
146
|
+
const effectiveProjectId = resolveDownloadProjectId(deps, project_id, sealedProjectIds);
|
|
133
147
|
// The uploads route takes one filename segment; a slash inside the name must
|
|
134
148
|
// stay inside that segment instead of turning into another route level.
|
|
135
149
|
gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/uploads/${deps.encodeGitLabPathSegment(secret)}/${deps.encodeGitLabPathSegment(filename)}`;
|
|
@@ -143,7 +157,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
143
157
|
.json({ error: "project_id, tag_name, and direct_asset_path are required" });
|
|
144
158
|
return;
|
|
145
159
|
}
|
|
146
|
-
const effectiveProjectId = deps
|
|
160
|
+
const effectiveProjectId = resolveDownloadProjectId(deps, project_id, sealedProjectIds);
|
|
147
161
|
gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/releases/${deps.encodeGitLabPathSegment(tag_name)}/downloads/${deps.encodeGitLabPath(direct_asset_path)}`;
|
|
148
162
|
break;
|
|
149
163
|
}
|
package/build/index.js
CHANGED
|
@@ -8,25 +8,32 @@ const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STRE
|
|
|
8
8
|
* Build a URL pointing to the download proxy endpoint.
|
|
9
9
|
* Embeds an encrypted auth token (and API URL for dynamic routing)
|
|
10
10
|
* from the current session so the URL works standalone.
|
|
11
|
+
* @throws {ProjectAccessDeniedError} If the project is outside the effective allowlist
|
|
11
12
|
*/
|
|
12
|
-
function buildDownloadUrl(type, params) {
|
|
13
|
+
function buildDownloadUrl(type, projectId, params) {
|
|
14
|
+
// Decoded once, like the /downloads proxy does, so both check the same value
|
|
15
|
+
const resourceParams = {
|
|
16
|
+
...params,
|
|
17
|
+
project_id: getEffectiveProjectId(decodeURIComponent(projectId)),
|
|
18
|
+
};
|
|
13
19
|
const base = MCP_SERVER_URL || sessionAuthStore.getStore()?.publicBaseUrl || `http://${HOST}:${PORT}`;
|
|
14
20
|
const baseUrl = new URL(base);
|
|
15
21
|
// Preserve any path prefix (e.g. /gitlab-mcp) from the base URL
|
|
16
22
|
const basePath = baseUrl.pathname.replace(/\/+$/, "");
|
|
17
23
|
const safeBasePath = basePath ? `/${basePath.replace(/^\/+/, "")}` : "";
|
|
18
24
|
const url = new URL(`${safeBasePath}/downloads/${type}`, baseUrl.origin);
|
|
19
|
-
for (const [key, value] of Object.entries(
|
|
25
|
+
for (const [key, value] of Object.entries(resourceParams)) {
|
|
20
26
|
url.searchParams.set(key, value);
|
|
21
27
|
}
|
|
22
28
|
// Embed auth (and apiUrl when dynamic routing is active) from current session or static config
|
|
23
29
|
// Token is bound to the specific resource (type + params) to prevent URL tampering
|
|
24
|
-
const resource = { type, params };
|
|
30
|
+
const resource = { type, params: resourceParams };
|
|
31
|
+
const allowedProjectIds = getEffectiveAllowedProjectIds();
|
|
25
32
|
const ctx = sessionAuthStore.getStore();
|
|
26
33
|
if (ctx?.token) {
|
|
27
34
|
const headerValue = ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token;
|
|
28
35
|
const apiUrl = ENABLE_DYNAMIC_API_URL && ctx.apiUrl !== GITLAB_API_URL ? ctx.apiUrl : undefined;
|
|
29
|
-
url.searchParams.set("_token", createDownloadToken(ctx.header, headerValue, apiUrl, resource));
|
|
36
|
+
url.searchParams.set("_token", createDownloadToken(ctx.header, headerValue, allowedProjectIds, apiUrl, resource));
|
|
30
37
|
}
|
|
31
38
|
else {
|
|
32
39
|
// Fallback for SSE/static-token mode (no session auth context)
|
|
@@ -48,7 +55,7 @@ function buildDownloadUrl(type, params) {
|
|
|
48
55
|
header = "Authorization";
|
|
49
56
|
headerValue = `Bearer ${staticToken}`;
|
|
50
57
|
}
|
|
51
|
-
url.searchParams.set("_token", createDownloadToken(header, headerValue, undefined, resource));
|
|
58
|
+
url.searchParams.set("_token", createDownloadToken(header, headerValue, allowedProjectIds, undefined, resource));
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
61
|
return url.toString();
|
|
@@ -10095,7 +10102,7 @@ async function handleToolCall(params) {
|
|
|
10095
10102
|
if (local_path) {
|
|
10096
10103
|
throw new Error("local_path cannot be used in remote mode — use the returned download_url instead");
|
|
10097
10104
|
}
|
|
10098
|
-
const downloadUrl = buildDownloadUrl("job-artifacts",
|
|
10105
|
+
const downloadUrl = buildDownloadUrl("job-artifacts", project_id, { job_id });
|
|
10099
10106
|
return {
|
|
10100
10107
|
content: [
|
|
10101
10108
|
{
|
|
@@ -10614,8 +10621,7 @@ async function handleToolCall(params) {
|
|
|
10614
10621
|
// In remote mode for non-image files, return proxy URL
|
|
10615
10622
|
const mimeType = getImageMimeType(args.filename);
|
|
10616
10623
|
if (IS_REMOTE && !mimeType) {
|
|
10617
|
-
const downloadUrl = buildDownloadUrl("attachment", {
|
|
10618
|
-
project_id: args.project_id,
|
|
10624
|
+
const downloadUrl = buildDownloadUrl("attachment", args.project_id, {
|
|
10619
10625
|
secret: args.secret,
|
|
10620
10626
|
filename: args.filename,
|
|
10621
10627
|
});
|
|
@@ -10753,8 +10759,7 @@ async function handleToolCall(params) {
|
|
|
10753
10759
|
case "download_release_asset": {
|
|
10754
10760
|
const args = DownloadReleaseAssetSchema.parse(params.arguments);
|
|
10755
10761
|
if (IS_REMOTE) {
|
|
10756
|
-
const downloadUrl = buildDownloadUrl("release-asset", {
|
|
10757
|
-
project_id: args.project_id,
|
|
10762
|
+
const downloadUrl = buildDownloadUrl("release-asset", args.project_id, {
|
|
10758
10763
|
tag_name: args.tag_name,
|
|
10759
10764
|
direct_asset_path: args.direct_asset_path,
|
|
10760
10765
|
});
|
|
@@ -21,14 +21,17 @@ const DOWNLOAD_TOKEN_TTL = Number.isFinite(parsedDownloadTokenTtl) && parsedDown
|
|
|
21
21
|
: DEFAULT_DOWNLOAD_TOKEN_TTL;
|
|
22
22
|
/**
|
|
23
23
|
* Create a self-contained encrypted download token for a specific GitLab resource.
|
|
24
|
+
* `allowedProjectIds` is the project allowlist of the issuing request (session scope
|
|
25
|
+
* or GITLAB_ALLOWED_PROJECT_IDS), because the download proxy runs outside that request.
|
|
24
26
|
*/
|
|
25
|
-
export function createDownloadToken(header, token, apiUrl, resource) {
|
|
27
|
+
export function createDownloadToken(header, token, allowedProjectIds, apiUrl, resource) {
|
|
26
28
|
const iv = randomBytes(12);
|
|
27
29
|
const cipher = createCipheriv("aes-256-gcm", DOWNLOAD_TOKEN_KEY, iv);
|
|
28
30
|
const payload = JSON.stringify({
|
|
29
31
|
h: header,
|
|
30
32
|
t: token,
|
|
31
33
|
e: Math.floor(Date.now() / 1000) + DOWNLOAD_TOKEN_TTL,
|
|
34
|
+
s: allowedProjectIds,
|
|
32
35
|
...(apiUrl ? { u: apiUrl } : {}),
|
|
33
36
|
...(resource ? { r: resource.type, p: resource.params } : {}),
|
|
34
37
|
});
|
|
@@ -36,8 +39,13 @@ export function createDownloadToken(header, token, apiUrl, resource) {
|
|
|
36
39
|
const tag = cipher.getAuthTag();
|
|
37
40
|
return Buffer.concat([iv, tag, encrypted]).toString("base64url");
|
|
38
41
|
}
|
|
42
|
+
function isStringArray(value) {
|
|
43
|
+
return Array.isArray(value) && value.every(item => typeof item === "string");
|
|
44
|
+
}
|
|
39
45
|
/**
|
|
40
46
|
* Decrypt and validate a download token, returning null when it is invalid or expired.
|
|
47
|
+
* A token without a sealed project allowlist was issued by an older release, which
|
|
48
|
+
* did not check the session scope, so it is invalid too.
|
|
41
49
|
*/
|
|
42
50
|
export function decryptDownloadToken(tokenStr) {
|
|
43
51
|
try {
|
|
@@ -58,9 +66,12 @@ export function decryptDownloadToken(tokenStr) {
|
|
|
58
66
|
return null;
|
|
59
67
|
if (typeof payload.t !== "string" || payload.t.length === 0)
|
|
60
68
|
return null;
|
|
69
|
+
if (!isStringArray(payload.s))
|
|
70
|
+
return null;
|
|
61
71
|
return {
|
|
62
72
|
header: payload.h,
|
|
63
73
|
token: payload.t,
|
|
74
|
+
allowedProjectIds: payload.s,
|
|
64
75
|
...(payload.u ? { apiUrl: payload.u } : {}),
|
|
65
76
|
...(payload.r ? { resourceType: payload.r, resourceParams: payload.p } : {}),
|
|
66
77
|
};
|
package/package.json
CHANGED