@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 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.6`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. `main`에 머지된 수정을 다음 안정 버전 전에 확인하려면 프리릴리스 빌드인 `npx -y @zereight/mcp-gitlab@next`를 사용하세요(운영 환경용이 아닙니다). 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
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.6`. 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`).
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.6`. 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`).
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.6`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。如需在下一个稳定版本发布前试用已合并到 `main` 的修复,请使用 `npx -y @zereight/mcp-gitlab@next`(预发布版本,不适用于生产环境)。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
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
 
@@ -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.getEffectiveProjectId(decodeURIComponent(project_id));
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.getEffectiveProjectId(decodeURIComponent(project_id));
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.getEffectiveProjectId(decodeURIComponent(project_id));
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(params)) {
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", { project_id, job_id });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.3.8-next.149",
3
+ "version": "2.3.8",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [